Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2579525ec5 | ||
|
|
1db950e678 | ||
|
|
3a65faea2b | ||
|
|
164b70b9f4 | ||
|
|
b2adc8d671 | ||
|
|
7b88a08610 | ||
|
|
5216befe60 | ||
|
|
f81c1afeef | ||
|
|
022e403125 | ||
|
|
ea2815d33f | ||
|
|
734707e28b | ||
|
|
1d3d016e0d | ||
|
|
d831cbf5ca | ||
|
|
0ad80db089 | ||
|
|
ad77293e06 | ||
|
|
f5f5ae63b4 | ||
|
|
15358d7a0f | ||
|
|
11d287b65b | ||
|
|
562978fdc4 | ||
|
|
d31e3dabf8 | ||
|
|
882f59b24d | ||
|
|
d8e7fae9c7 | ||
|
|
a0454793c9 | ||
|
|
b633293ef1 | ||
|
|
d65387bb3d | ||
|
|
418e3e487d | ||
|
|
9fb2a713c4 | ||
|
|
87e736dacc | ||
|
|
d7f161bcd3 | ||
|
|
880c3453ee | ||
|
|
180d563139 | ||
|
|
43b7c27b34 | ||
|
|
6469fd5236 | ||
|
|
6030bf17c2 | ||
|
|
41158b17af | ||
|
|
5cc5c9327f | ||
|
|
cc47b7bca4 | ||
|
|
6b04d75bb2 | ||
|
|
93a382be92 | ||
|
|
0d23638db0 | ||
|
|
0a3b59cd9c | ||
|
|
2b730b2496 | ||
|
|
fff3c3881a | ||
|
|
75cbe64a86 | ||
|
|
f79ddf82b7 | ||
|
|
c63fefda51 | ||
|
|
f98059c426 | ||
|
|
70ed73e705 | ||
|
|
5398cb4745 | ||
|
|
2eaf7732ff | ||
|
|
c0b87503a4 | ||
|
|
58fb28de29 | ||
|
|
322e4496cf | ||
|
|
06604162a1 | ||
|
|
7d36418e0f | ||
|
|
412fb5da08 | ||
|
|
c3f240489c | ||
|
|
54840759b8 | ||
|
|
9d1f75d63a | ||
|
|
21d794c7b8 | ||
|
|
e3c0af28f7 | ||
|
|
66798886cf | ||
|
|
842ea50cbe | ||
|
|
8bd2a99a22 | ||
|
|
7a9713e877 | ||
|
|
1c6c1bf890 | ||
|
|
f81ffa9fa6 | ||
|
|
88e96dee44 | ||
|
|
ccbf2d2c22 | ||
|
|
baf21980be | ||
|
|
7a86344941 | ||
|
|
c621c13f11 | ||
|
|
004cac04aa | ||
|
|
57553b784e | ||
|
|
13fc6f5854 | ||
|
|
b03dc57072 | ||
|
|
f40d2474e6 | ||
|
|
ae217a3a60 | ||
|
|
7791a992b6 | ||
|
|
82c8726c10 | ||
|
|
60629e3cf3 | ||
|
|
178d407e6f | ||
|
|
7e1cb6d8d8 | ||
|
|
c2a90542d7 | ||
|
|
802d3bfd4b | ||
|
|
09b1912eb9 | ||
|
|
975cd0b384 | ||
|
|
436f6b79b5 | ||
|
|
df8b2f6f90 | ||
|
|
950ce60ec2 | ||
|
|
3658b3fece | ||
|
|
686d0c3c05 | ||
|
|
9425e9ef42 | ||
|
|
556e11373c | ||
|
|
6e87a866b2 | ||
|
|
043ee92598 | ||
|
|
89df3b5fa9 | ||
|
|
c9f52e9239 | ||
|
|
519349b5cf | ||
|
|
71a21e185e | ||
|
|
c8b5780627 | ||
|
|
68ce724ff6 | ||
|
|
ad4af68d1d | ||
|
|
8861982877 | ||
|
|
07ea65bcd8 | ||
|
|
6cb66c675a | ||
|
|
ddec3dadcc | ||
|
|
ac78881711 | ||
|
|
be0a4fcf90 | ||
|
|
0b3fe51184 | ||
|
|
adeda1ef76 | ||
|
|
fb5115a90f | ||
|
|
a5ffc54a25 | ||
|
|
286aa30bfc | ||
|
|
bb4435233f | ||
|
|
aa0f397778 | ||
|
|
7185364e4b | ||
|
|
efc8bb7fd1 | ||
|
|
74d113cf16 | ||
|
|
486cd4a255 | ||
|
|
68011d5c76 | ||
|
|
939e7ab820 | ||
|
|
59953f714a | ||
|
|
7aab5caf98 | ||
|
|
83a2412941 | ||
|
|
84571efaf8 | ||
|
|
771acd1abe | ||
|
|
2bf65c05b2 | ||
|
|
a365dd8ee9 | ||
|
|
363b63384a | ||
|
|
4ec2aa8dbd | ||
|
|
63bbb4c42a | ||
|
|
4cb7f8f97f | ||
|
|
9dfe4af679 |
@@ -1,17 +0,0 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 14
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Check for linked issue in pull request body
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
|
||||
jobs:
|
||||
check_linear_in_pr_body:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for linked issue in pull request body
|
||||
run: |
|
||||
pr_body=$(curl -s \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.number }} | jq -r .body)
|
||||
|
||||
echo "Pull Request Body: $pr_body"
|
||||
|
||||
if echo "$pr_body" | grep -iE "CNCT|DASH"; then
|
||||
echo "Linked issue found in the pull request body."
|
||||
else
|
||||
echo "No linked issue found in the pull request body."
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,15 @@
|
||||
name: 'Close stale issues and PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-message: 'This issue has been inactive for 30 days. It is now marked as stale and will be closed in 5 days if no further activity occurs.'
|
||||
stale-pr-message: 'This PR has been inactive for 30 days. It is now marked as stale and will be closed in 5 days if no further activity occurs.'
|
||||
days-before-stale: 30
|
||||
days-before-close: 5
|
||||
@@ -75,15 +75,6 @@ GITHUB_API_TOKEN="ghp_..."
|
||||
# Upload server url
|
||||
NEXT_PUBLIC_DASHBOARD_UPLOAD_SERVER="https://storage.thirdweb-preview.com"
|
||||
|
||||
# Payments Admin Secret. For dev, get this value from paper-web-dev (on Zeet)'s "HASURA_ADMIN_SECRET_KEY" env var
|
||||
PAYMENTS_ADMIN_SECRET=""
|
||||
|
||||
# Disable telemetry
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Use trench for KYC
|
||||
NEXT_PUBLIC_TRENCH_2_API_ENDPOINT=""
|
||||
|
||||
# Unthread variables - only required for submitting the support form in /support
|
||||
UNTHREAD_API_KEY=""
|
||||
UNTHREAD_TRIAGE_CHANNEL_ID=""
|
||||
@@ -100,4 +91,5 @@ API_SERVER_SECRET=""
|
||||
|
||||
# Used for the Faucet page (/<chain_id>)
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
|
||||
TURNSTILE_SECRET_KEY=""
|
||||
TURNSTILE_SECRET_KEY=""
|
||||
REDIS_URL=""
|
||||
@@ -59,7 +59,6 @@ module.exports = {
|
||||
"FormHelperText",
|
||||
"FormErrorMessage",
|
||||
"MenuGroup",
|
||||
"MenuItem",
|
||||
"VStack",
|
||||
"HStack",
|
||||
"AspectRatio",
|
||||
|
||||
@@ -23,4 +23,17 @@ Some env vars can be overridden that are required for some external services to
|
||||
|
||||
To define env vars please create a `.env` file based on the `.env.example` template at the root level of the project. This file is ignored by git so you can safely add it to your local copy of the project.
|
||||
|
||||
**Add your thirdweb clientID and secret key to build a basic functioning version of the site.**
|
||||
**Add your thirdweb clientID and secret key to build a basic functioning version of the site.**
|
||||
|
||||
## Code Structure and Style Guide
|
||||
|
||||
### Components
|
||||
- Components should go in a `components` folder next to the app page they are used in.
|
||||
- If a component is reused in multiple pages, it should go in the lowest level route folder it is used in. For example, if a chart component is used in all analytics subroutes `/analytics/...`, it should go in the `/analytics/components` folder.
|
||||
- All low-level components such as buttons, inputs, etc. should be shadcn components found in `@/ui`.
|
||||
- All composed components reused across all page routes are `blocks`, and should also go in the `@/ui` folder.
|
||||
|
||||
### Data fetching
|
||||
- Use RSC wherever possible.
|
||||
- Write data fetching code in its own function in the same file as the component it is used in, not exported.
|
||||
- If the same data fetching function is used in multiple components, place it in a file in an `api` folder at the lowest level possible (just like components). If you need to do this you probable aren't organizing your components properly.
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
// add framer paths here
|
||||
module.exports = [
|
||||
// -- home
|
||||
"/",
|
||||
// -- product landing pages --
|
||||
// -- connect
|
||||
"/connect/sign-in",
|
||||
// -- contracts
|
||||
"/contracts/modular-contracts",
|
||||
"/contracts/explore",
|
||||
"/contracts/deployment-tool",
|
||||
// -- engine
|
||||
"/engine",
|
||||
// -- solutions pages --
|
||||
"/solutions/gaming",
|
||||
"/solutions/consumer-apps",
|
||||
"/solutions/defi",
|
||||
// -- campaigns --
|
||||
"/unlimited-wallets",
|
||||
];
|
||||
|
||||
@@ -158,7 +158,6 @@ const moduleExports = {
|
||||
serverSourceMaps: false,
|
||||
},
|
||||
cacheMaxMemorySize: 0,
|
||||
swcMinify: true,
|
||||
compiler: {
|
||||
emotion: true,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"format": "biome format ./src --write",
|
||||
@@ -25,12 +25,12 @@
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@chakra-ui/styled-system": "^2.9.2",
|
||||
"@chakra-ui/theme-tools": "^2.1.2",
|
||||
"@coinbase/onchainkit": "^0.14.2",
|
||||
"@emotion/react": "11.13.3",
|
||||
"@emotion/styled": "11.13.0",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.0.2",
|
||||
"@n8tb1t/use-scroll-position": "^2.0.3",
|
||||
"@radix-ui/react-accordion": "^1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
@@ -46,13 +46,14 @@
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.3",
|
||||
"@sentry/nextjs": "8.34.0",
|
||||
"@shazow/whatsabi": "^0.15.4",
|
||||
"@sentry/nextjs": "8.37.1",
|
||||
"@shazow/whatsabi": "^0.16.0",
|
||||
"@stripe/react-stripe-js": "^2.8.1",
|
||||
"@stripe/stripe-js": "^3.5.0",
|
||||
"@tanstack/react-query": "5.59.16",
|
||||
"@tanstack/react-query": "5.59.19",
|
||||
"@tanstack/react-table": "^8.17.3",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@vercel/functions": "^1.4.2",
|
||||
"@vercel/og": "^0.6.2",
|
||||
"abitype": "1.0.6",
|
||||
"chakra-react-select": "^4.7.6",
|
||||
@@ -62,17 +63,17 @@
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"flat": "^6.0.1",
|
||||
"framer-motion": "11.11.9",
|
||||
"framer-motion": "11.11.11",
|
||||
"fuse.js": "7.0.0",
|
||||
"input-otp": "^1.2.4",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.4.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "0.453.0",
|
||||
"next": "14.2.15",
|
||||
"next-plausible": "^3.12.0",
|
||||
"lucide-react": "0.454.0",
|
||||
"next": "15.0.2",
|
||||
"next-plausible": "^3.12.3",
|
||||
"next-seo": "^6.5.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"next-themes": "^0.4.3",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"papaparse": "^5.4.1",
|
||||
"pluralize": "^8.0.0",
|
||||
@@ -80,20 +81,20 @@
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "18.3.1",
|
||||
"react": "19.0.0-rc-69d4b800-20241021",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-dropzone": "^14.2.9",
|
||||
"react-dom": "19.0.0-rc-69d4b800-20241021",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-error-boundary": "^4.1.2",
|
||||
"react-hook-form": "7.52.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-responsive-carousel": "^3.2.23",
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "^2.13.0",
|
||||
"recharts": "^2.13.3",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^1.5.0",
|
||||
"sonner": "^1.7.0",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"swagger-ui-react": "^5.17.14",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
@@ -105,26 +106,26 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "2.0.2",
|
||||
"@next/bundle-analyzer": "14.2.15",
|
||||
"@next/eslint-plugin-next": "14.2.15",
|
||||
"@playwright/test": "1.47.2",
|
||||
"@storybook/addon-essentials": "8.3.6",
|
||||
"@storybook/addon-interactions": "8.3.6",
|
||||
"@storybook/addon-links": "8.3.6",
|
||||
"@storybook/addon-onboarding": "8.3.6",
|
||||
"@storybook/addon-viewport": "8.3.6",
|
||||
"@storybook/blocks": "8.3.6",
|
||||
"@storybook/nextjs": "8.3.6",
|
||||
"@storybook/react": "8.3.6",
|
||||
"@storybook/test": "8.3.6",
|
||||
"@types/color": "^3.0.6",
|
||||
"@chromatic-com/storybook": "3.2.2",
|
||||
"@next/bundle-analyzer": "15.0.2",
|
||||
"@next/eslint-plugin-next": "15.0.2",
|
||||
"@playwright/test": "1.48.2",
|
||||
"@storybook/addon-essentials": "8.4.2",
|
||||
"@storybook/addon-interactions": "8.4.2",
|
||||
"@storybook/addon-links": "8.4.2",
|
||||
"@storybook/addon-onboarding": "8.4.2",
|
||||
"@storybook/addon-viewport": "8.4.2",
|
||||
"@storybook/blocks": "8.4.2",
|
||||
"@storybook/nextjs": "8.4.2",
|
||||
"@storybook/react": "8.4.2",
|
||||
"@storybook/test": "8.4.2",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "20.14.9",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react": "npm:[email protected].1",
|
||||
"@types/react-dom": "npm:[email protected].1",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/spdx-correct": "^3.1.3",
|
||||
"@types/swagger-ui-react": "^4.18.3",
|
||||
@@ -134,12 +135,12 @@
|
||||
"checkly": "^4.8.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.3",
|
||||
"eslint-plugin-react-compiler": "0.0.0-experimental-fa06e2c-20241014",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-8a03594-20241020",
|
||||
"eslint-plugin-storybook": "^0.9.0",
|
||||
"knip": "^5.33.3",
|
||||
"knip": "5.36.2",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.4.47",
|
||||
"storybook": "8.3.6",
|
||||
"storybook": "8.4.2",
|
||||
"tailwindcss": "3.4.14",
|
||||
"typescript": "5.6.3"
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 551 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 730 KiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 721 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 631 KiB |
|
After Width: | Height: | Size: 134 KiB |
@@ -0,0 +1,26 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="12.813" y="18.824" width="13.4583" height="9.33069" rx="1.49536" transform="rotate(-80 12.813 18.824)" fill="url(#paint0_linear_176_19664)"/>
|
||||
<g filter="url(#filter0_bi_176_19664)">
|
||||
<path d="M8.63443 22.4473C9.10456 22.866 9.75478 22.9806 10.3397 22.748C12.1658 22.0219 16.0712 20.0922 16.6714 16.6883L17.3498 12.8408C17.4946 12.0198 17.1149 11.1951 16.397 10.7712L12.6731 8.57281C12.2567 8.32698 11.7653 8.24034 11.29 8.32893L7.0388 9.12114C6.21922 9.27387 5.58034 9.91897 5.43557 10.74L4.75715 14.5875C4.15696 17.9914 7.1668 21.1405 8.63443 22.4473Z" fill="#B9DDFF" fill-opacity="0.8"/>
|
||||
</g>
|
||||
<path d="M10.6767 15.8535C11.6031 16.0169 12.4866 15.3983 12.6499 14.4719C12.8133 13.5454 12.1947 12.662 11.2683 12.4986C10.3418 12.3353 9.45839 12.9539 9.29504 13.8803C9.13169 14.8067 9.75028 15.6902 10.6767 15.8535Z" fill="white"/>
|
||||
<path d="M10.8049 15.1276C11.13 15.1849 11.3459 15.5036 11.2867 15.8396L10.8578 18.2722C10.7986 18.6079 10.4869 18.8339 10.1615 18.7765C9.83634 18.7192 9.6204 18.4005 9.67966 18.0644L10.1086 15.6319C10.1678 15.2962 10.4794 15.0702 10.8049 15.1276Z" fill="white"/>
|
||||
<defs>
|
||||
<filter id="filter0_bi_176_19664" x="2.6792" y="6.29507" width="16.7012" height="18.5715" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1"/>
|
||||
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_176_19664"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_176_19664" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.2"/>
|
||||
<feGaussianBlur stdDeviation="0.05"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_176_19664"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_176_19664" x1="23.6039" y1="22.7328" x2="13.5455" y2="27.1317" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2567FF"/>
|
||||
<stop offset="1" stop-color="#22A7FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_176_19666)">
|
||||
<path d="M10.2684 4.78857C11.0382 3.45524 12.9627 3.45524 13.7325 4.78857L20.7761 16.9885C21.5459 18.3218 20.5837 19.9885 19.0441 19.9885H4.95684C3.41724 19.9885 2.45499 18.3218 3.22479 16.9885L10.2684 4.78857Z" fill="url(#paint0_linear_176_19666)"/>
|
||||
<g filter="url(#filter0_i_176_19666)">
|
||||
<path d="M11.1341 13.3799C11.519 12.7132 12.4812 12.7132 12.8661 13.3799L15.811 18.4806C16.1959 19.1473 15.7148 19.9806 14.945 19.9806H9.05519C8.28539 19.9806 7.80426 19.1473 8.18916 18.4806L11.1341 13.3799Z" fill="#B9DDFF" fill-opacity="0.8"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_i_176_19666" x="8.05371" y="12.8799" width="7.89258" height="7.20071" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.2"/>
|
||||
<feGaussianBlur stdDeviation="0.05"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_176_19666"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_176_19666" x1="3.35247" y1="20.8199" x2="21.8623" y2="12.4791" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2567FF"/>
|
||||
<stop offset="1" stop-color="#22A7FF"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_176_19666">
|
||||
<rect width="24" height="24" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,31 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_176_19660)">
|
||||
<circle cx="18.6199" cy="17.2472" r="5" transform="rotate(-10 18.6199 17.2472)" fill="url(#paint0_linear_176_19660)"/>
|
||||
<g filter="url(#filter0_bi_176_19660)">
|
||||
<path d="M3.83469 8.17681C3.69084 7.36097 4.23559 6.58298 5.05143 6.43913L17.8539 4.1817C18.6698 4.03785 19.4478 4.5826 19.5916 5.39844L21.1544 14.2617C21.2983 15.0776 20.7536 15.8555 19.9377 15.9994L7.13521 18.2568C6.31937 18.4007 5.54138 17.8559 5.39752 17.0401L3.83469 8.17681Z" fill="#B9DDFF" fill-opacity="0.8"/>
|
||||
</g>
|
||||
<rect x="6.65332" y="12.7048" width="3.54886" height="3.54886" rx="1" transform="rotate(-10 6.65332 12.7048)" fill="white"/>
|
||||
<path d="M4.09668 9.64545L19.8505 6.86762" stroke="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_bi_176_19660" x="1.81152" y="2.15866" width="21.3657" height="18.1212" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1"/>
|
||||
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_176_19660"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_176_19660" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.2"/>
|
||||
<feGaussianBlur stdDeviation="0.05"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_176_19660"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_176_19660" x1="21.6379" y1="16.4364" x2="13.4844" y2="18.9086" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2567FF"/>
|
||||
<stop offset="1" stop-color="#22A7FF"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_176_19660">
|
||||
<rect width="24" height="24" fill="white" transform="translate(0.0986328 4.26613) rotate(-10)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,30 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_176_19662)">
|
||||
<rect x="12.7549" y="5.24113" width="13.4583" height="9.33069" rx="1.49536" transform="rotate(20 12.7549 5.24113)" fill="url(#paint0_linear_176_19662)"/>
|
||||
<g filter="url(#filter0_bi_176_19662)">
|
||||
<path d="M8.56889 10.1027C8.94667 9.06477 10.0944 8.5296 11.1323 8.90738L24.288 13.6957C25.326 14.0735 25.8611 15.2211 25.4834 16.2591L22.0632 25.656C21.6854 26.694 20.5377 27.2291 19.4997 26.8514L6.34403 22.0631C5.30608 21.6853 4.7709 20.5376 5.14869 19.4997L8.56889 10.1027Z" fill="#B9DDFF" fill-opacity="0.8"/>
|
||||
</g>
|
||||
<path d="M11.0957 12.0434L15.3474 18.4228L22.4439 16.1739" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_bi_176_19662" x="3.02734" y="6.78622" width="24.5771" height="22.1863" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feGaussianBlur in="BackgroundImageFix" stdDeviation="1"/>
|
||||
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_176_19662"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_backgroundBlur_176_19662" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.2"/>
|
||||
<feGaussianBlur stdDeviation="0.05"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_176_19662"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_176_19662" x1="23.5457" y1="9.14994" x2="13.4874" y2="13.5488" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2567FF"/>
|
||||
<stop offset="1" stop-color="#22A7FF"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_176_19662">
|
||||
<rect width="24" height="24" fill="white" transform="translate(8.82812 0.619431) rotate(20)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,28 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_176_19658)">
|
||||
<rect x="24.7207" y="12.0584" width="16" height="10" rx="1.5" transform="rotate(132.102 24.7207 12.0584)" fill="url(#paint0_linear_176_19658)"/>
|
||||
<g filter="url(#filter0_i_176_19658)">
|
||||
<path d="M7.17965 18.0792C6.80186 17.0413 7.33703 15.8936 8.37499 15.5158L19.6513 11.4116C20.6893 11.0338 21.8369 11.569 22.2147 12.6069L24.9509 20.1245C25.3287 21.1624 24.7935 22.3101 23.7555 22.6879L12.4792 26.7921C11.4413 27.1699 10.2936 26.6347 9.91581 25.5968L7.17965 18.0792Z" fill="#B9DDFF" fill-opacity="0.8"/>
|
||||
</g>
|
||||
<path d="M9.40967 18.3986L12.344 17.3306C12.8956 17.1298 13.5112 17.2202 13.9819 17.5709V17.5709C14.9874 18.3203 16.4315 17.7947 16.72 16.5744V16.5744C16.8551 16.0031 17.2686 15.5382 17.8202 15.3374L20.712 14.2849" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_i_176_19658" x="7.05859" y="11.2904" width="18.0137" height="15.7229" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.2"/>
|
||||
<feGaussianBlur stdDeviation="0.05"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_176_19658"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_176_19658" x1="37.5495" y1="16.2476" x2="26.0184" y2="21.8418" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2567FF"/>
|
||||
<stop offset="1" stop-color="#22A7FF"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_176_19658">
|
||||
<rect width="24" height="24" fill="white" transform="translate(0.619629 8.82776) rotate(-20)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -284,6 +284,43 @@ function redirects() {
|
||||
destination: "/explore",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
// framer redirects
|
||||
{
|
||||
source: "/deploy",
|
||||
destination: "/contracts/deployment-tool",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/publish",
|
||||
destination: "/contracts/deployment-tool",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/smart-contracts",
|
||||
destination: "/contracts/explore",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/build",
|
||||
destination: "/contracts/modular-contracts",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/ui-components",
|
||||
destination: "/sdk",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/interact",
|
||||
destination: "/sdk",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/sponsored-transactions",
|
||||
destination: "/account-abstraction",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export type Project = {
|
||||
};
|
||||
|
||||
export async function getProjects(teamSlug: string) {
|
||||
const cookiesManager = cookies();
|
||||
const cookiesManager = await cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
@@ -46,7 +46,7 @@ export async function getProjects(teamSlug: string) {
|
||||
}
|
||||
|
||||
export async function getProject(teamSlug: string, projectSlug: string) {
|
||||
const cookiesManager = cookies();
|
||||
const cookiesManager = await cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
|
||||
@@ -25,7 +25,7 @@ export type TeamMember = {
|
||||
};
|
||||
|
||||
export async function getMembers(teamSlug: string) {
|
||||
const token = getAuthToken();
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return undefined;
|
||||
|
||||
@@ -23,7 +23,7 @@ export type Team = {
|
||||
};
|
||||
|
||||
export async function getTeamBySlug(slug: string) {
|
||||
const cookiesManager = cookies();
|
||||
const cookiesManager = await cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
@@ -45,7 +45,7 @@ export async function getTeamBySlug(slug: string) {
|
||||
}
|
||||
|
||||
export async function getTeams() {
|
||||
const cookiesManager = cookies();
|
||||
const cookiesManager = await cookies();
|
||||
const activeAccount = cookiesManager.get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
const token = activeAccount
|
||||
? cookiesManager.get(COOKIE_PREFIX_TOKEN + activeAccount)?.value
|
||||
|
||||
@@ -9,6 +9,7 @@ export function FormFieldSetup(props: {
|
||||
children: React.ReactNode;
|
||||
tooltip?: React.ReactNode;
|
||||
isRequired: boolean;
|
||||
helperText?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
@@ -26,8 +27,13 @@ export function FormFieldSetup(props: {
|
||||
)}
|
||||
</div>
|
||||
{props.children}
|
||||
|
||||
{props.helperText && (
|
||||
<p className="mt-2 text-muted-foreground text-sm">{props.helperText}</p>
|
||||
)}
|
||||
|
||||
{props.errorMessage && (
|
||||
<p className="mt-1 text-destructive-text text-sm">
|
||||
<p className="mt-2 text-destructive-text text-sm">
|
||||
{props.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useIsomorphicLayoutEffect } from "../../lib/useIsomorphicLayoutEffect";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type imgElementProps = React.DetailedHTMLProps<
|
||||
@@ -25,19 +26,39 @@ export function Img(props: imgElementProps) {
|
||||
const { className, fallback, skeleton, ...restProps } = props;
|
||||
const defaultSkeleton = <div className="animate-pulse bg-accent" />;
|
||||
const defaultFallback = <div className="bg-muted" />;
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
const imgEl = imgRef.current;
|
||||
if (!imgEl) {
|
||||
return;
|
||||
}
|
||||
if (imgEl.complete) {
|
||||
setStatus("loaded");
|
||||
} else {
|
||||
function handleLoad() {
|
||||
setStatus("loaded");
|
||||
}
|
||||
imgEl.addEventListener("load", handleLoad);
|
||||
return () => {
|
||||
imgEl.removeEventListener("load", handleLoad);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<img
|
||||
{...restProps}
|
||||
onLoad={() => {
|
||||
setStatus("loaded");
|
||||
}}
|
||||
// avoid setting empty src string to prevent request to the entire page
|
||||
src={restProps.src || undefined}
|
||||
ref={imgRef}
|
||||
onError={() => {
|
||||
setStatus("fallback");
|
||||
}}
|
||||
style={{
|
||||
opacity: status === "loaded" ? 1 : 0,
|
||||
...restProps.style,
|
||||
}}
|
||||
alt={restProps.alt || ""}
|
||||
className={cn(
|
||||
@@ -48,6 +69,7 @@ export function Img(props: imgElementProps) {
|
||||
|
||||
{status !== "loaded" && (
|
||||
<div
|
||||
style={restProps.style}
|
||||
className={cn(
|
||||
"fade-in-0 absolute inset-0 overflow-hidden transition-opacity duration-300 [&>*]:h-full [&>*]:w-full",
|
||||
className,
|
||||
|
||||
@@ -19,7 +19,7 @@ export function AppFooter(props: AppFooterProps) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
"w-full border-border border-t bg-muted/50 py-6 md:py-8",
|
||||
"w-full border-border border-t bg-white py-6 md:py-8 dark:bg-muted/50",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
|
||||
export function GenericLoadingPage() {
|
||||
return (
|
||||
<div className="flex min-h-[500px] grow items-center justify-center rounded-lg border border-border">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes";
|
||||
import type { ThemeProviderProps } from "next-themes/dist/types";
|
||||
import type { ThemeProviderProps } from "next-themes";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
|
||||
@@ -50,7 +50,7 @@ export function DatePickerWithRange(props: {
|
||||
id="date"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"justify-start gap-2 text-left font-normal",
|
||||
"justify-start gap-2 bg-card text-left font-normal",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
@@ -69,14 +69,14 @@ export function DatePickerWithRange(props: {
|
||||
<DynamicHeight>
|
||||
<div>
|
||||
{!isValid && (
|
||||
<p className="flex items-center justify-center gap-2 pt-2 text-center text-destructive-text text-sm">
|
||||
<p className="flex items-center justify-center gap-2 py-2 text-center text-destructive-text text-sm">
|
||||
<CalendarX2Icon className="h-4 w-4" />
|
||||
Invalid date range
|
||||
</p>
|
||||
)}
|
||||
{props.header}
|
||||
|
||||
<div className="px-4">
|
||||
<div className={cn("px-4", !props.header && "py-4")}>
|
||||
<TabButtons
|
||||
tabClassName="!text-sm"
|
||||
activeTabClassName="!bg-inverted !text-inverted-foreground"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-border border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AccordionItem.displayName = "AccordionItem";
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -73,19 +73,7 @@ const SelectContent = React.forwardRef<
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
// Fixes https://github.com/radix-ui/primitives/issues/1658
|
||||
ref={(instance) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(instance);
|
||||
} else if (ref) {
|
||||
ref.current = instance;
|
||||
}
|
||||
if (!instance) return;
|
||||
|
||||
instance.ontouchstart = (e) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
}}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
position === "popper" &&
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
const LOGGED_IN_ONLY_PATHS = [
|
||||
// anything that _starts_ with /dashboard is logged in only
|
||||
"/dashboard",
|
||||
// team pages are logged in only
|
||||
"/team",
|
||||
// anything that _starts_ with /cli is logged in only
|
||||
"/cli",
|
||||
// publish page
|
||||
"/contracts/publish",
|
||||
];
|
||||
|
||||
export function isLoginRequired(pathname: string) {
|
||||
return LOGGED_IN_ONLY_PATHS.some((path) => pathname.startsWith(path));
|
||||
// remove '/' in front and then split by '/'
|
||||
const paths = pathname.slice(1).split("/");
|
||||
|
||||
// /dashboard, /team, /cli
|
||||
if (paths[0] === "dashboard" || paths[0] === "team" || paths[0] === "cli") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// /contracts/publish
|
||||
if (paths[0] === "contracts" && paths[1] === "publish") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { cookies } from "next/headers";
|
||||
export const COOKIE_ACTIVE_ACCOUNT = "tw_active_account";
|
||||
export const COOKIE_PREFIX_TOKEN = "tw_token_";
|
||||
|
||||
export function getActiveAccountCookie() {
|
||||
return cookies().get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
export async function getActiveAccountCookie() {
|
||||
return (await cookies()).get(COOKIE_ACTIVE_ACCOUNT)?.value;
|
||||
}
|
||||
|
||||
export function getJWTCookie(address: string) {
|
||||
return cookies().get(COOKIE_PREFIX_TOKEN + address)?.value;
|
||||
export async function getJWTCookie(address: string) {
|
||||
return (await cookies()).get(COOKIE_PREFIX_TOKEN + address)?.value;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@ export const IPFS_GATEWAY_URL =
|
||||
(process.env.NEXT_PUBLIC_IPFS_GATEWAY_URL as string) ||
|
||||
"https://{clientId}.ipfscdn.io/ipfs/{cid}/{path}";
|
||||
|
||||
export const THIRDWEB_ENGINE_FAUCET_WALLET =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET || "";
|
||||
|
||||
export const isProd =
|
||||
(process.env.VERCEL_ENV || process.env.NEXT_PUBLIC_VERCEL_ENV) ===
|
||||
"production";
|
||||
@@ -24,5 +21,15 @@ export const DASHBOARD_STORAGE_URL =
|
||||
export const API_SERVER_URL =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_API_HOST || "https://api.thirdweb.com";
|
||||
|
||||
/**
|
||||
* Faucet stuff
|
||||
*/
|
||||
// Cloudflare Turnstile Site key
|
||||
export const TURNSTILE_SITE_KEY =
|
||||
process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "";
|
||||
export const THIRDWEB_ENGINE_FAUCET_WALLET =
|
||||
process.env.NEXT_PUBLIC_THIRDWEB_ENGINE_FAUCET_WALLET || "";
|
||||
export const THIRDWEB_ENGINE_URL = process.env.THIRDWEB_ENGINE_URL;
|
||||
export const THIRDWEB_ACCESS_TOKEN = process.env.THIRDWEB_ACCESS_TOKEN;
|
||||
// Comma-separated list of chain IDs to disable faucet for.
|
||||
export const DISABLE_FAUCET_CHAIN_IDS = process.env.DISABLE_FAUCET_CHAIN_IDS;
|
||||
|
||||
@@ -24,7 +24,10 @@ export function useThirdwebClient(jwt?: string) {
|
||||
throw new Error("Failed to get auth token");
|
||||
}
|
||||
const json = (await res.json()) as GetAuthTokenResponse;
|
||||
return json.jwt || undefined;
|
||||
if (!json.jwt) {
|
||||
throw new Error("No JWT in response");
|
||||
}
|
||||
return json.jwt;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
/* bg - neutral */
|
||||
--background: 0 0% 100%;
|
||||
--background: 0 0% 98%;
|
||||
--popover: 0 0% 100%;
|
||||
--card: 0 0% 100%;
|
||||
--secondary: 0 0% 92%;
|
||||
@@ -46,7 +46,7 @@
|
||||
[data-theme="dark"] {
|
||||
/* bg - neutral */
|
||||
--background: 0 0% 0%;
|
||||
--card: 0 0% 0%;
|
||||
--card: 0 0% 5%;
|
||||
--popover: 0 0% 0%;
|
||||
--secondary: 0 0% 10%;
|
||||
--muted: 0 0% 9%;
|
||||
|
||||
@@ -63,8 +63,11 @@ export const embeddedWalletsKeys = {
|
||||
all: ["embeddedWallets"] as const,
|
||||
wallet: (walletAddress: string) =>
|
||||
[...embeddedWalletsKeys.all, walletAddress] as const,
|
||||
embeddedWallets: (walletAddress: string, clientId: string | undefined) =>
|
||||
[...embeddedWalletsKeys.wallet(walletAddress), clientId] as const,
|
||||
embeddedWallets: (
|
||||
walletAddress: string,
|
||||
clientId: string | undefined,
|
||||
page: number,
|
||||
) => [...embeddedWalletsKeys.wallet(walletAddress), clientId, page] as const,
|
||||
};
|
||||
|
||||
export const engineKeys = {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { popularChains } from "../popularChains";
|
||||
export const CustomConnectWallet = (props: {
|
||||
loginRequired?: boolean;
|
||||
connectButtonClassName?: string;
|
||||
signInLinkButtonClassName?: string;
|
||||
detailsButtonClassName?: string;
|
||||
}) => {
|
||||
const thirdwebClient = useThirdwebClient();
|
||||
@@ -134,7 +135,12 @@ export const CustomConnectWallet = (props: {
|
||||
if (!isLoggedIn && loginRequired) {
|
||||
return (
|
||||
<>
|
||||
<Button asChild variant="default" className="gap-2" size="lg">
|
||||
<Button
|
||||
asChild
|
||||
variant="default"
|
||||
className={props.signInLinkButtonClassName}
|
||||
size="lg"
|
||||
>
|
||||
<Link
|
||||
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
|
||||
>
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ComponentWithChildren } from "types/component-with-children";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
interface AdminOnlyProps {
|
||||
contract: ThirdwebContract;
|
||||
failOpen?: boolean;
|
||||
|
||||
@@ -12,21 +12,24 @@ import { useLoggedInUser } from "./useLoggedInUser";
|
||||
|
||||
// FIXME: We keep repeating types, API server should provide them
|
||||
|
||||
export enum AccountStatus {
|
||||
NoCustomer = "noCustomer",
|
||||
NoPayment = "noPayment",
|
||||
PaymentVerification = "paymentVerification",
|
||||
ValidPayment = "validPayment",
|
||||
InvalidPayment = "invalidPayment",
|
||||
InvalidPaymentMethod = "invalidPaymentMethod",
|
||||
}
|
||||
export const accountStatus = {
|
||||
noCustomer: "noCustomer",
|
||||
noPayment: "noPayment",
|
||||
paymentVerification: "paymentVerification",
|
||||
validPayment: "validPayment",
|
||||
invalidPayment: "invalidPayment",
|
||||
invalidPaymentMethod: "invalidPaymentMethod",
|
||||
} as const;
|
||||
|
||||
export enum AccountPlan {
|
||||
Free = "free",
|
||||
Growth = "growth",
|
||||
Pro = "pro",
|
||||
Enterprise = "enterprise",
|
||||
}
|
||||
export const accountPlan = {
|
||||
free: "free",
|
||||
growth: "growth",
|
||||
pro: "pro",
|
||||
enterprise: "enterprise",
|
||||
} as const;
|
||||
|
||||
export type AccountStatus = (typeof accountStatus)[keyof typeof accountStatus];
|
||||
export type AccountPlan = (typeof accountPlan)[keyof typeof accountPlan];
|
||||
|
||||
export type AuthorizedWallet = {
|
||||
id: string;
|
||||
@@ -235,6 +238,14 @@ export interface WalletStats {
|
||||
walletType: string;
|
||||
}
|
||||
|
||||
export interface InAppWalletStats {
|
||||
date: string;
|
||||
authenticationMethod: string;
|
||||
uniqueWalletsConnected: number;
|
||||
}
|
||||
|
||||
export interface EcosystemWalletStats extends InAppWalletStats {}
|
||||
|
||||
export interface UserOpStats {
|
||||
date: string;
|
||||
successful: number;
|
||||
@@ -436,6 +447,43 @@ async function getUserOpUsage(args: {
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export async function getInAppWalletUsage(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period?: "day" | "week" | "month" | "year" | "all";
|
||||
}) {
|
||||
const { clientId, from, to, period } = args;
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append("clientId", clientId);
|
||||
if (from) {
|
||||
searchParams.append("from", from.toISOString());
|
||||
}
|
||||
if (to) {
|
||||
searchParams.append("to", to.toISOString());
|
||||
}
|
||||
if (period) {
|
||||
searchParams.append("period", period);
|
||||
}
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_ANALYTICS_API_HOST}/v1/wallets/in-app?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
const json = await res?.json();
|
||||
|
||||
if (!res || res.status !== 200) {
|
||||
throw new Error(json.message);
|
||||
}
|
||||
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export function useUserOpUsageAggregate(args: {
|
||||
clientId: string;
|
||||
from?: Date;
|
||||
|
||||
@@ -18,7 +18,7 @@ export function useDashboardContractMetadata(contract: ThirdwebContract) {
|
||||
});
|
||||
}
|
||||
|
||||
type DashboardContractMetadata = {
|
||||
export type DashboardContractMetadata = {
|
||||
name: string;
|
||||
symbol: string;
|
||||
image: string;
|
||||
|
||||
@@ -1,51 +1,90 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { THIRDWEB_EWS_API_HOST } from "constants/urls";
|
||||
import type { WalletUser } from "thirdweb/wallets";
|
||||
import { embeddedWalletsKeys } from "../cache-keys";
|
||||
import { useLoggedInUser } from "./useLoggedInUser";
|
||||
|
||||
// FIXME: Make API to return camelCase or transform
|
||||
export type EmbeddedWalletUser = {
|
||||
id: string;
|
||||
client_id: string;
|
||||
created_at: string;
|
||||
last_accessed_at: string;
|
||||
embedded_wallet?: {
|
||||
id: string;
|
||||
address: string;
|
||||
chain: string;
|
||||
wallet_user_id: string;
|
||||
}[];
|
||||
ews_authed_user: {
|
||||
id: string;
|
||||
authed_user_id: string;
|
||||
email: string;
|
||||
}[];
|
||||
const fetchAccountList = ({
|
||||
jwt,
|
||||
clientId,
|
||||
pageNumber,
|
||||
}: { jwt: string; clientId: string; pageNumber: number }) => {
|
||||
return async () => {
|
||||
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
|
||||
url.searchParams.append("clientId", clientId);
|
||||
url.searchParams.append("page", pageNumber.toString());
|
||||
|
||||
const res = await fetch(url.href, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch wallets: ${await res.text()}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json as {
|
||||
users: WalletUser[];
|
||||
totalPages: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export function useEmbeddedWallets(clientId: string) {
|
||||
export function useEmbeddedWallets(clientId: string, page: number) {
|
||||
const { user, isLoggedIn } = useLoggedInUser();
|
||||
|
||||
return useQuery({
|
||||
queryKey: embeddedWalletsKeys.embeddedWallets(
|
||||
user?.address as string,
|
||||
clientId as string,
|
||||
clientId,
|
||||
page,
|
||||
),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`${THIRDWEB_EWS_API_HOST}/api/thirdweb/embedded-wallet?clientId=${clientId}&lastAccessedAt=0`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${user?.jwt}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
return json.walletUsers as EmbeddedWalletUser[];
|
||||
},
|
||||
queryFn: fetchAccountList({
|
||||
jwt: user?.jwt ?? "",
|
||||
clientId,
|
||||
pageNumber: page,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
enabled: !!user?.address && isLoggedIn && !!clientId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAllEmbeddedWallets() {
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useLoggedInUser();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
clientId,
|
||||
totalPages,
|
||||
}: { clientId: string; totalPages: number }) => {
|
||||
const walletRes = [];
|
||||
for (let page = 1; page <= totalPages; page++) {
|
||||
const res = queryClient.fetchQuery<{
|
||||
users: WalletUser[];
|
||||
totalPages: number;
|
||||
}>({
|
||||
queryKey: embeddedWalletsKeys.embeddedWallets(
|
||||
user?.address as string,
|
||||
clientId,
|
||||
page,
|
||||
),
|
||||
queryFn: fetchAccountList({
|
||||
jwt: user?.jwt ?? "",
|
||||
clientId,
|
||||
pageNumber: page,
|
||||
}),
|
||||
});
|
||||
walletRes.push(res);
|
||||
}
|
||||
return (await Promise.all(walletRes)).flatMap((res) => res.users);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ResultItem } from "components/engine/system-metrics/components/StatusCodes";
|
||||
import type { ResultItem } from "app/team/[team_slug]/[project_slug]/engine/(instance)/[engineId]/metrics/components/StatusCodes";
|
||||
import { THIRDWEB_API_HOST } from "constants/urls";
|
||||
import type { EngineBackendWalletType } from "lib/engine";
|
||||
import { useState } from "react";
|
||||
@@ -116,7 +116,8 @@ type EngineFeature =
|
||||
| "KEYPAIR_AUTH"
|
||||
| "CONTRACT_SUBSCRIPTIONS"
|
||||
| "IP_ALLOWLIST"
|
||||
| "HETEROGENEOUS_WALLET_TYPES";
|
||||
| "HETEROGENEOUS_WALLET_TYPES"
|
||||
| "SMART_BACKEND_WALLETS";
|
||||
|
||||
interface EngineSystemHealth {
|
||||
status: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
|
||||
@@ -9,11 +10,15 @@ import {
|
||||
} from "@/constants/env";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { CustomConnectWallet } from "@3rdweb-sdk/react/components/connect-wallet";
|
||||
import { useAccount } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { useLoggedInUser } from "@3rdweb-sdk/react/hooks/useLoggedInUser";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { CanClaimResponseType } from "app/api/testnet-faucet/can-claim/CanClaimResponseType";
|
||||
import { Onboarding } from "components/onboarding";
|
||||
import { mapV4ChainToV5Chain } from "contexts/map-chains";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { toUnits } from "thirdweb";
|
||||
@@ -113,6 +118,10 @@ export function FaucetButton({
|
||||
},
|
||||
});
|
||||
|
||||
const accountQuery = useAccount();
|
||||
const userQuery = useLoggedInUser();
|
||||
const [showOnboarding, setShowOnBoarding] = useState(false);
|
||||
|
||||
const canClaimFaucetQuery = useQuery({
|
||||
queryKey: ["testnet-faucet-can-claim", chainId],
|
||||
queryFn: async () => {
|
||||
@@ -133,6 +142,32 @@ export function FaucetButton({
|
||||
|
||||
const form = useForm<z.infer<typeof claimFaucetSchema>>();
|
||||
|
||||
// Force users to log in to claim the faucet
|
||||
if (!address || !userQuery.user) {
|
||||
return (
|
||||
<CustomConnectWallet
|
||||
loginRequired={true}
|
||||
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (accountQuery.isPending) {
|
||||
return (
|
||||
<Button variant="outline" className="w-full gap-2">
|
||||
Loading account <Spinner className="size-3" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (!accountQuery.data) {
|
||||
return (
|
||||
<Button variant="outline" className="w-full gap-2" disabled>
|
||||
Failed to load account
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// loading state
|
||||
if (faucetWalletBalanceQuery.isPending || canClaimFaucetQuery.isPending) {
|
||||
return (
|
||||
@@ -145,7 +180,7 @@ export function FaucetButton({
|
||||
// faucet is empty
|
||||
if (isFaucetEmpty) {
|
||||
return (
|
||||
<Button variant="outline" disabled className="!opacity-100 w-full ">
|
||||
<Button variant="outline" disabled className="!opacity-100 w-full">
|
||||
Faucet is empty right now
|
||||
</Button>
|
||||
);
|
||||
@@ -168,12 +203,24 @@ export function FaucetButton({
|
||||
);
|
||||
}
|
||||
|
||||
if (!address) {
|
||||
// Email verification is required to claim from the faucet
|
||||
if (accountQuery.data.status === "noCustomer") {
|
||||
return (
|
||||
<CustomConnectWallet
|
||||
loginRequired={false}
|
||||
connectButtonClassName="!w-full !rounded !bg-primary !text-primary-foreground !px-4 !py-2 !text-sm"
|
||||
/>
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!opacity-100 w-full"
|
||||
onClick={() => setShowOnBoarding(true)}
|
||||
>
|
||||
Verify your Email
|
||||
</Button>
|
||||
{/* We will show the modal only if the user click on it, because this is a public page */}
|
||||
{showOnboarding && (
|
||||
<ChakraProviderSetup>
|
||||
<Onboarding onOpenChange={setShowOnBoarding} />
|
||||
</ChakraProviderSetup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -208,6 +255,7 @@ export function FaucetButton({
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Turnstile
|
||||
className="mt-4"
|
||||
siteKey={TURNSTILE_SITE_KEY}
|
||||
onSuccess={(token) => field.onChange(token)}
|
||||
/>
|
||||
|
||||
@@ -32,9 +32,10 @@ import { ChainHeader } from "./components/server/chain-header";
|
||||
|
||||
// TODO: improve the behavior when clicking "Get started with thirdweb", currently just redirects to the dashboard
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: { params: { chain_id: string } }): Promise<Metadata> {
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ chain_id: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const chain = await getChain(params.chain_id);
|
||||
const sanitizedChainName = chain.name.replace("Mainnet", "").trim();
|
||||
const title = `${sanitizedChainName}: RPC and Chain Settings`;
|
||||
@@ -54,13 +55,12 @@ export async function generateMetadata({
|
||||
}
|
||||
|
||||
// this is the dashboard layout file
|
||||
export default async function ChainPageLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
export default async function ChainPageLayout(props: {
|
||||
children: React.ReactNode;
|
||||
params: { chain_id: string };
|
||||
params: Promise<{ chain_id: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
const chain = await getChain(params.chain_id);
|
||||
|
||||
if (params.chain_id !== chain.slug) {
|
||||
|
||||
@@ -13,9 +13,10 @@ import { ChainCTA } from "./components/server/cta-card";
|
||||
import { ExplorersSection } from "./components/server/explorer-section";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: { chain_id: string };
|
||||
params: Promise<{ chain_id: string }>;
|
||||
}) {
|
||||
const chain = await getChain(props.params.chain_id);
|
||||
const params = await props.params;
|
||||
const chain = await getChain(params.chain_id);
|
||||
const chainMetadata = await getChainMetadata(chain.chainId);
|
||||
const isDeprecated = chain.status === "deprecated";
|
||||
|
||||
|
||||
@@ -3,18 +3,17 @@ import { CircleAlertIcon } from "lucide-react";
|
||||
import { TrendingContractSection } from "../../../../trending/components/trending-table";
|
||||
import { getChain } from "../../../utils";
|
||||
|
||||
// we're using searchParams here - use dynamic rendering
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: { chain_id: string };
|
||||
searchParams: { page?: number; sortBy?: SortBy };
|
||||
params: Promise<{ chain_id: string }>;
|
||||
searchParams: Promise<{ page?: number; sortBy?: SortBy }>;
|
||||
}) {
|
||||
const chain = await getChain(props.params.chain_id);
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const chain = await getChain(params.chain_id);
|
||||
const topContracts = await fetchTopContracts({
|
||||
chainId: chain.chainId,
|
||||
page: props.searchParams.page,
|
||||
sortBy: props.searchParams.sortBy,
|
||||
page: searchParams.page,
|
||||
sortBy: searchParams.sortBy,
|
||||
perPage: 15,
|
||||
timeRange: "month",
|
||||
});
|
||||
@@ -24,22 +23,18 @@ export default async function Page(props: {
|
||||
<h2 className="mb-2 font-semibold text-2xl tracking-tighter">
|
||||
Popular Contracts
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Explore contracts on Ethereum and sort them by your preferred metrics
|
||||
</p>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
{topContracts.length > 0 && (
|
||||
<TrendingContractSection
|
||||
topContracts={topContracts}
|
||||
chainId={chain.chainId}
|
||||
searchParams={props.searchParams}
|
||||
searchParams={searchParams}
|
||||
showPagination={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{topContracts.length === 0 && (
|
||||
<div className="flex h-[200px] items-center justify-center rounded-lg border border-border text-lg text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ListerOnly } from "@3rdweb-sdk/react/components/roles/lister-only";
|
||||
import { useDisclosure } from "@chakra-ui/react";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { Button, Drawer } from "tw-components";
|
||||
import { CreateListingsForm } from "./list-form";
|
||||
|
||||
const LIST_FORM_ID = "marketplace-list-form";
|
||||
|
||||
interface CreateListingButtonProps {
|
||||
contract: ThirdwebContract;
|
||||
createText?: string;
|
||||
@@ -25,59 +28,28 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
|
||||
...restButtonProps
|
||||
}) => {
|
||||
const address = useActiveAccount()?.address;
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [isFormLoading, setIsFormLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<ListerOnly contract={contract}>
|
||||
<Drawer
|
||||
allowPinchZoom
|
||||
preserveScrollBarGap
|
||||
size="lg"
|
||||
onClose={onClose}
|
||||
isOpen={isOpen}
|
||||
header={{ children: createText }}
|
||||
footer={{
|
||||
children: (
|
||||
<>
|
||||
<Button
|
||||
isDisabled={isFormLoading}
|
||||
variant="outline"
|
||||
mr={3}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TransactionButton
|
||||
txChainID={contract.chain.id}
|
||||
isLoading={isFormLoading}
|
||||
transactionCount={2}
|
||||
form={LIST_FORM_ID}
|
||||
type="submit"
|
||||
colorScheme="primary"
|
||||
>
|
||||
{createText}
|
||||
</TransactionButton>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<CreateListingsForm
|
||||
contract={contract}
|
||||
formId={LIST_FORM_ID}
|
||||
type={type}
|
||||
setIsFormLoading={setIsFormLoading}
|
||||
/>
|
||||
</Drawer>
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
leftIcon={<PlusIcon className="size-5" />}
|
||||
{...restButtonProps}
|
||||
onClick={onOpen}
|
||||
isDisabled={!address}
|
||||
>
|
||||
{createText}
|
||||
</Button>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="primary" {...restButtonProps} disabled={!address}>
|
||||
{createText} <PlusIcon className="ml-2 size-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[700px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="mb-5 text-left">{createText}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<CreateListingsForm
|
||||
contract={contract}
|
||||
type={type}
|
||||
actionText={createText}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</ListerOnly>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Alert, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useDashboardOwnedNFTs } from "@3rdweb-sdk/react/hooks/useDashboardOwnedNFTs";
|
||||
import { useWalletNFTs } from "@3rdweb-sdk/react/hooks/useWalletNFTs";
|
||||
@@ -10,19 +11,18 @@ import {
|
||||
Select,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
useModalContext,
|
||||
} from "@chakra-ui/react";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { CurrencySelector } from "components/shared/CurrencySelector";
|
||||
import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { isAlchemySupported } from "lib/wallet/nfts/alchemy";
|
||||
import { isMoralisSupported } from "lib/wallet/nfts/moralis";
|
||||
import { isSimpleHashSupported } from "lib/wallet/nfts/simpleHash";
|
||||
import type { WalletNFT } from "lib/wallet/nfts/types";
|
||||
import { CircleAlertIcon, InfoIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { type Dispatch, type SetStateAction, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -58,6 +58,8 @@ import {
|
||||
import { NFTMediaWithEmptyState } from "tw-components/nft-media";
|
||||
import { shortenIfAddress } from "utils/usedapp-external";
|
||||
|
||||
const LIST_FORM_ID = "marketplace-list-form";
|
||||
|
||||
type ListForm =
|
||||
| (Omit<CreateListingParams, "quantity" | "currencyContractAddress"> & {
|
||||
currencyContractAddress: string;
|
||||
@@ -79,9 +81,9 @@ type ListForm =
|
||||
|
||||
type CreateListingsFormProps = {
|
||||
contract: ThirdwebContract;
|
||||
formId: string;
|
||||
actionText: string;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
type?: "direct-listings" | "english-auctions";
|
||||
setIsFormLoading: (loading: boolean) => void;
|
||||
};
|
||||
|
||||
const auctionTimes = [
|
||||
@@ -96,14 +98,15 @@ const auctionTimes = [
|
||||
|
||||
export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
contract,
|
||||
formId,
|
||||
type,
|
||||
setIsFormLoading,
|
||||
actionText,
|
||||
setOpen,
|
||||
}) => {
|
||||
const trackEvent = useTrack();
|
||||
const chainId = contract.chain.id;
|
||||
const { idToChain } = useAllChainsData();
|
||||
const network = idToChain.get(chainId);
|
||||
const [isFormLoading, setIsFormLoading] = useState(false);
|
||||
|
||||
const isSupportedChain =
|
||||
chainId &&
|
||||
@@ -203,17 +206,10 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
|
||||
const noNfts = !nfts?.length;
|
||||
|
||||
const modalContext = useModalContext();
|
||||
|
||||
const { onSuccess, onError } = useTxNotifications(
|
||||
"NFT listed successfully",
|
||||
"Failed to list NFT",
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
id={formId}
|
||||
className="flex flex-col gap-6 pb-16"
|
||||
id={LIST_FORM_ID}
|
||||
onSubmit={form.handleSubmit(async (formData) => {
|
||||
if (!formData.selected || !selectedContract) {
|
||||
return;
|
||||
@@ -247,12 +243,13 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
approved: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await sendAndConfirmTx.mutateAsync(approveTx);
|
||||
} catch {
|
||||
setIsFormLoading(false);
|
||||
return toast.error("Failed to approve NFT for marketplace");
|
||||
}
|
||||
const promise = sendAndConfirmTx.mutateAsync(approveTx);
|
||||
toast.promise(promise, {
|
||||
loading: "Approving NFT for listing",
|
||||
success: "NFT approved succesfully",
|
||||
error: "Failed to approve NFT",
|
||||
});
|
||||
await promise;
|
||||
}
|
||||
|
||||
if (formData.listingType === "direct") {
|
||||
@@ -270,12 +267,14 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
pricePerToken: String(formData.pricePerToken),
|
||||
endTimestamp,
|
||||
});
|
||||
await sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onSuccess: () => {
|
||||
onSuccess();
|
||||
modalContext.onClose();
|
||||
},
|
||||
onError,
|
||||
|
||||
const promise = sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onSuccess: () => setOpen(false),
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Listing NFT",
|
||||
success: "NFT listed successfully",
|
||||
error: "Failed to list NFT",
|
||||
});
|
||||
} else if (formData.listingType === "auction") {
|
||||
let minimumBidAmountWei: bigint;
|
||||
@@ -323,16 +322,15 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
buyoutBidAmountWei * BigInt(formData.quantity),
|
||||
});
|
||||
|
||||
await sendAndConfirmTx.mutateAsync(transaction, {
|
||||
const promise = sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onSuccess: () => {
|
||||
onSuccess();
|
||||
trackEvent({
|
||||
category: "marketplace",
|
||||
action: "add-listing",
|
||||
label: "success",
|
||||
network,
|
||||
});
|
||||
modalContext.onClose();
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
trackEvent({
|
||||
@@ -342,11 +340,16 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
network,
|
||||
error,
|
||||
});
|
||||
onError(error);
|
||||
},
|
||||
});
|
||||
toast.promise(promise, {
|
||||
loading: "Creating auction",
|
||||
success: "Auction created successfully",
|
||||
error: "Failed to create auction",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Failed to list NFT");
|
||||
}
|
||||
|
||||
@@ -540,6 +543,27 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
<AlertTitle>No NFT selected</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Need to pin these at the bottom because this is a very long form */}
|
||||
<div className="fixed right-6 bottom-4 flex flex-row items-center justify-end gap-3">
|
||||
<Button
|
||||
disabled={isFormLoading}
|
||||
variant="default"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TransactionButton
|
||||
txChainID={contract.chain.id}
|
||||
isLoading={isFormLoading}
|
||||
transactionCount={2}
|
||||
form={LIST_FORM_ID}
|
||||
type="submit"
|
||||
colorScheme="primary"
|
||||
>
|
||||
{actionText}
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { ErrorPage, LoadingPage } from "../../_components/page-skeletons";
|
||||
import { RedirectToContractOverview } from "../../_components/redirect-contract-overview.client";
|
||||
import { useContractPageMetadata } from "../../_hooks/useContractPageMetadata";
|
||||
import { ContractDirectListingsPage } from "./ContractDirectListingsPage";
|
||||
|
||||
export function ContractDirectListingsPageClient(props: {
|
||||
contract: ThirdwebContract;
|
||||
}) {
|
||||
const metadataQuery = useContractPageMetadata(props.contract);
|
||||
|
||||
if (metadataQuery.isPending) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
||||
if (metadataQuery.isError) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
if (!metadataQuery.data.isDirectListingSupported) {
|
||||
return <RedirectToContractOverview contract={props.contract} />;
|
||||
}
|
||||
|
||||
return <ContractDirectListingsPage contract={props.contract} />;
|
||||
}
|
||||
@@ -1,26 +1,33 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { getContractPageParamsInfo } from "../../_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "../../_utils/getContractPageMetadata";
|
||||
import { ContractDirectListingsPage } from "./ContractDirectListingsPage";
|
||||
import { ContractDirectListingsPageClient } from "./ContractDirectListingsPage.client";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (info.chainMetadata.chainId === localhost.id) {
|
||||
return <ContractDirectListingsPageClient contract={info.contract} />;
|
||||
}
|
||||
|
||||
const { isDirectListingSupported } = await getContractPageMetadata(
|
||||
info.contract,
|
||||
);
|
||||
|
||||
if (!isDirectListingSupported) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <ContractDirectListingsPage contract={info.contract} />;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { ErrorPage, LoadingPage } from "../../_components/page-skeletons";
|
||||
import { RedirectToContractOverview } from "../../_components/redirect-contract-overview.client";
|
||||
import { useContractPageMetadata } from "../../_hooks/useContractPageMetadata";
|
||||
import { ContractEnglishAuctionsPage } from "./ContractEnglishAuctionsPage";
|
||||
|
||||
export function ContractEnglishAuctionsPageClient(props: {
|
||||
contract: ThirdwebContract;
|
||||
}) {
|
||||
const metadataQuery = useContractPageMetadata(props.contract);
|
||||
|
||||
if (metadataQuery.isPending) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
||||
if (metadataQuery.isError) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
if (!metadataQuery.data.isEnglishAuctionSupported) {
|
||||
return <RedirectToContractOverview contract={props.contract} />;
|
||||
}
|
||||
|
||||
return <ContractEnglishAuctionsPage contract={props.contract} />;
|
||||
}
|
||||
@@ -1,26 +1,33 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { getContractPageParamsInfo } from "../../_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "../../_utils/getContractPageMetadata";
|
||||
import { ContractEnglishAuctionsPage } from "./ContractEnglishAuctionsPage";
|
||||
import { ContractEnglishAuctionsPageClient } from "./ContractEnglishAuctionsPage.client";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (info.chainMetadata.chainId === localhost.id) {
|
||||
return <ContractEnglishAuctionsPageClient contract={info.contract} />;
|
||||
}
|
||||
|
||||
const { isEnglishAuctionSupported } = await getContractPageMetadata(
|
||||
info.contract,
|
||||
);
|
||||
|
||||
if (!isEnglishAuctionSupported) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <ContractEnglishAuctionsPage contract={info.contract} />;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Input } from "@chakra-ui/react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { CustomFormControl } from "../common";
|
||||
import { useClaimConditionsFormContext } from "../index";
|
||||
|
||||
@@ -54,7 +54,9 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
|
||||
<QuantityInputWithUnlimited
|
||||
isRequired
|
||||
decimals={tokenDecimals}
|
||||
isDisabled={dropType === "specific" || formDisabled}
|
||||
isDisabled={
|
||||
dropType === "specific" || formDisabled || (isErc20 && !tokenDecimals)
|
||||
}
|
||||
value={field?.maxClaimablePerWallet?.toString() || "0"}
|
||||
onChange={(value) =>
|
||||
form.setValue(
|
||||
|
||||
@@ -35,7 +35,7 @@ export const MaxClaimableSupplyInput: React.FC = () => {
|
||||
>
|
||||
<QuantityInputWithUnlimited
|
||||
isRequired
|
||||
isDisabled={formDisabled}
|
||||
isDisabled={formDisabled || (isErc20 && !tokenDecimals)}
|
||||
decimals={tokenDecimals}
|
||||
value={field.maxClaimableSupply?.toString() || "0"}
|
||||
onChange={(value) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Input } from "@chakra-ui/react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CustomFormControl } from "../common";
|
||||
import { useClaimConditionsFormContext } from "../index";
|
||||
|
||||
@@ -18,7 +18,7 @@ export const PhaseNameInput: React.FC = () => {
|
||||
helperText="This does not affect how your claim phase functions and is for organizational purposes only."
|
||||
>
|
||||
<Input
|
||||
isDisabled={formDisabled}
|
||||
disabled={formDisabled}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
placeholder={inputPlaceholder}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type BaseTransactionOptions,
|
||||
type ThirdwebClient,
|
||||
toTokens,
|
||||
toUnits,
|
||||
} from "thirdweb";
|
||||
import type { OverrideEntry } from "thirdweb/dist/types/utils/extensions/drops/types";
|
||||
import type { Prettify } from "thirdweb/dist/types/utils/type-utils";
|
||||
@@ -42,6 +43,7 @@ type CombinedClaimCondition = Prettify<
|
||||
type Options =
|
||||
| {
|
||||
type: "erc20";
|
||||
decimals?: number;
|
||||
}
|
||||
| {
|
||||
type: "erc721";
|
||||
@@ -91,12 +93,24 @@ export async function getClaimPhasesInLegacyFormat(
|
||||
startTime: new Date(Number(condition.startTimestamp * 1000n)),
|
||||
currencyAddress: condition.currency,
|
||||
price: condition.pricePerToken,
|
||||
maxClaimableSupply: toUnlimited(condition.maxClaimableSupply),
|
||||
maxClaimableSupply:
|
||||
options.type === "erc20"
|
||||
? convertERC20ValueToDisplayValue(
|
||||
condition.maxClaimableSupply,
|
||||
options.decimals,
|
||||
)
|
||||
: toUnlimited(condition.maxClaimableSupply),
|
||||
currencyMetadata,
|
||||
currentMintSupply: (
|
||||
condition.maxClaimableSupply - condition.supplyClaimed
|
||||
).toString(),
|
||||
maxClaimablePerWallet: toUnlimited(condition.quantityLimitPerWallet),
|
||||
maxClaimablePerWallet:
|
||||
options.type === "erc20"
|
||||
? convertERC20ValueToDisplayValue(
|
||||
condition.quantityLimitPerWallet,
|
||||
options.decimals,
|
||||
)
|
||||
: toUnlimited(condition.quantityLimitPerWallet),
|
||||
merkleRootHash: condition.merkleRoot,
|
||||
metadata,
|
||||
snapshot,
|
||||
@@ -114,8 +128,20 @@ export function setClaimPhasesTx(
|
||||
const phases = rawPhases.map((phase) => {
|
||||
return {
|
||||
startTime: toDate(phase.startTime),
|
||||
maxClaimableSupply: toBigInt(phase.maxClaimableSupply),
|
||||
maxClaimablePerWallet: toBigInt(phase.maxClaimablePerWallet),
|
||||
maxClaimableSupply:
|
||||
baseOptions.type === "erc20"
|
||||
? convertERC20ValueToWei(
|
||||
phase.maxClaimableSupply,
|
||||
baseOptions.decimals,
|
||||
)
|
||||
: toBigInt(phase.maxClaimableSupply),
|
||||
maxClaimablePerWallet:
|
||||
baseOptions.type === "erc20"
|
||||
? convertERC20ValueToWei(
|
||||
phase.maxClaimablePerWallet,
|
||||
baseOptions.decimals,
|
||||
)
|
||||
: toBigInt(phase.maxClaimablePerWallet),
|
||||
merkleRootHash: phase.merkleRootHash as string | undefined,
|
||||
overrideList: phase.snapshot?.length
|
||||
? snapshotToOverrides(phase.snapshot)
|
||||
@@ -175,7 +201,7 @@ function toDate(timestamp: number | Date | undefined) {
|
||||
}
|
||||
return new Date(timestamp);
|
||||
}
|
||||
function toBigInt(value: string | number | undefined) {
|
||||
function toBigInt(value: string | number | undefined): bigint | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -185,8 +211,47 @@ function toBigInt(value: string | number | undefined) {
|
||||
return BigInt(value);
|
||||
}
|
||||
|
||||
// The input from client-side is non-wei, but the extension is expecting value in wei
|
||||
// so we need to convert it using toUnits
|
||||
function convertERC20ValueToWei(
|
||||
value: string | number | undefined,
|
||||
decimals?: number,
|
||||
) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === "unlimited") {
|
||||
return maxUint256;
|
||||
}
|
||||
// The ERC20Claim condition extension in v5 does not convert to wei for us
|
||||
// so we have to, manually
|
||||
if (decimals) {
|
||||
return toUnits(value.toString(), decimals);
|
||||
}
|
||||
return BigInt(value);
|
||||
}
|
||||
|
||||
// This value we get from ERC20Ext.getClaimConditions is in wei
|
||||
// so we have to convert it using toTokens for readability, and for users to update
|
||||
// (when user updates this value, we convert it back to wei - see `function setClaimPhasesTx`)
|
||||
function convertERC20ValueToDisplayValue(
|
||||
value: bigint,
|
||||
decimals?: number,
|
||||
): string {
|
||||
if (value === maxUint256) {
|
||||
return "unlimited";
|
||||
}
|
||||
if (decimals) {
|
||||
return toTokens(value, decimals);
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
function toUnlimited(value: bigint) {
|
||||
return value === maxUint256 ? "unlimited" : value.toString();
|
||||
if (value === maxUint256) {
|
||||
return "unlimited";
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
async function fetchSnapshot(
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Flex,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
} from "@chakra-ui/react";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
useFieldArray,
|
||||
useForm,
|
||||
} from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
NATIVE_TOKEN_ADDRESS,
|
||||
type ThirdwebContract,
|
||||
@@ -38,7 +40,7 @@ import {
|
||||
useSendAndConfirmTransaction,
|
||||
} from "thirdweb/react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Button, Heading, MenuItem, Text } from "tw-components";
|
||||
import { Button, Heading, Text } from "tw-components";
|
||||
import * as z from "zod";
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
@@ -152,7 +154,7 @@ interface ClaimsConditionFormContextData {
|
||||
field: ControlledField;
|
||||
phaseIndex: number;
|
||||
formDisabled: boolean;
|
||||
tokenDecimals: number;
|
||||
tokenDecimals: number | undefined;
|
||||
isMultiPhase: boolean;
|
||||
isActive: boolean;
|
||||
dropType: DropType;
|
||||
@@ -210,7 +212,6 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
enabled: isErc20,
|
||||
},
|
||||
});
|
||||
const tokenDecimalsData = tokenDecimals.data ?? 0;
|
||||
const saveClaimPhaseNotification = useTxNotifications(
|
||||
"Saved claim phases",
|
||||
"Failed to save claim phases",
|
||||
@@ -219,7 +220,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
const claimConditionsQuery = useReadContract(getClaimPhasesInLegacyFormat, {
|
||||
contract,
|
||||
...(isErc20
|
||||
? { type: "erc20" }
|
||||
? { type: "erc20", decimals: tokenDecimals.data }
|
||||
: isErc721
|
||||
? { type: "erc721" }
|
||||
: { type: "erc1155", tokenId: BigInt(tokenId || 0) }),
|
||||
@@ -259,7 +260,11 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
);
|
||||
}, [claimConditionsQuery.data, isMultiPhase]);
|
||||
|
||||
const isFetchingData = claimConditionsQuery.isFetching || sendTx.isPending;
|
||||
const isFetchingData =
|
||||
claimConditionsQuery.isFetching ||
|
||||
sendTx.isPending ||
|
||||
// Need to make sure the tokenDecimals.data is present when interacting with ERC20 claim conditions
|
||||
(isErc20 && tokenDecimals.isLoading);
|
||||
|
||||
const canEditForm = isAdmin && !isFetchingData;
|
||||
|
||||
@@ -353,13 +358,17 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
action: "set-claim-conditions",
|
||||
label: "attempt",
|
||||
});
|
||||
|
||||
if (isErc20 && !tokenDecimals.data) {
|
||||
return toast.error(
|
||||
`Could not fetch token decimals for contract ${contract.address}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const tx = setClaimPhasesTx(
|
||||
{
|
||||
contract,
|
||||
...(isErc20
|
||||
? { type: "erc20" }
|
||||
? { type: "erc20", decimals: tokenDecimals.data }
|
||||
: isErc721
|
||||
? { type: "erc721" }
|
||||
: { type: "erc1155", tokenId: BigInt(tokenId || 0) }),
|
||||
@@ -453,6 +462,15 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Do not proceed if fails to load the tokenDecimals.data - for ERC20 drop contracts specifically
|
||||
if (isErc20 && tokenDecimals.data === undefined) {
|
||||
return (
|
||||
<div className="flex h-[400px] w-full items-center justify-center rounded-lg border border-border">
|
||||
Failed to load token decimals
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex onSubmit={handleFormSubmit} direction="column" as="form" gap={10}>
|
||||
@@ -492,8 +510,9 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
<Fragment key={`snapshot_${field.id}_${index}`}>
|
||||
<SnapshotUpload
|
||||
dropType={dropType}
|
||||
isOpen={openSnapshotIndex === index}
|
||||
onClose={() => setOpenSnapshotIndex(-1)}
|
||||
snapshotIndex={openSnapshotIndex}
|
||||
index={index}
|
||||
setOpenSnapshotIndex={setOpenSnapshotIndex}
|
||||
value={snapshotValue}
|
||||
setSnapshot={(snapshot) =>
|
||||
form.setValue(`phases.${index}.snapshot`, snapshot)
|
||||
@@ -508,7 +527,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
phaseIndex: index,
|
||||
formDisabled: !canEditForm,
|
||||
isErc20,
|
||||
tokenDecimals: tokenDecimalsData,
|
||||
tokenDecimals: tokenDecimals.data,
|
||||
dropType,
|
||||
setOpenSnapshotIndex,
|
||||
isAdmin,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { ClaimConditionsForm } from "./claim-conditions-form/index";
|
||||
|
||||
@@ -17,31 +16,26 @@ export const ClaimConditions: React.FC<ClaimConditionsProps> = ({
|
||||
isERC20,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-8">
|
||||
<Flex p={0} position="relative">
|
||||
<Flex direction="column" gap={6} w="full">
|
||||
{/* Info */}
|
||||
<section>
|
||||
<h2 className="mb-1 font-semibold text-xl tracking-tight">
|
||||
Set Claim Conditions
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Control when the {isERC20 ? "tokens" : "NFTs"} get dropped, how
|
||||
much they cost, and more.
|
||||
</p>
|
||||
</section>
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<section>
|
||||
<h2 className="mb-1 font-semibold text-xl tracking-tight">
|
||||
Set Claim Conditions
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Control when the {isERC20 ? "tokens" : "NFTs"} get dropped, how much
|
||||
they cost, and more.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Set Claim Conditions */}
|
||||
<ClaimConditionsForm
|
||||
isErc20={isERC20}
|
||||
contract={contract}
|
||||
tokenId={tokenId}
|
||||
isColumn={isColumn}
|
||||
// always multi phase!
|
||||
isMultiPhase={true}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{/* Set Claim Conditions */}
|
||||
<ClaimConditionsForm
|
||||
isErc20={isERC20}
|
||||
contract={contract}
|
||||
tokenId={tokenId}
|
||||
isColumn={isColumn}
|
||||
// always multi phase!
|
||||
isMultiPhase={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ export const QuantityInputWithUnlimited: React.FC<
|
||||
<InputGroup {...restInputProps}>
|
||||
<Input
|
||||
isRequired={isRequired}
|
||||
isDisabled={decimals === undefined || isDisabled}
|
||||
isDisabled={isDisabled}
|
||||
value={stringValue === "unlimited" ? "Unlimited" : stringValue}
|
||||
onChange={(e) => updateValue(e.currentTarget.value)}
|
||||
onBlur={() => {
|
||||
@@ -69,7 +69,7 @@ export const QuantityInputWithUnlimited: React.FC<
|
||||
{hideMaxButton ? null : (
|
||||
<InputRightElement w="auto">
|
||||
<Button
|
||||
isDisabled={decimals === undefined || isDisabled}
|
||||
isDisabled={isDisabled}
|
||||
colorScheme="primary"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -1,43 +1,21 @@
|
||||
import { UnorderedList } from "@/components/ui/List/List";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Flex,
|
||||
IconButton,
|
||||
Link,
|
||||
ListItem,
|
||||
Portal,
|
||||
Select,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tooltip,
|
||||
Tr,
|
||||
UnorderedList,
|
||||
} from "@chakra-ui/react";
|
||||
import { Logo } from "components/logo";
|
||||
import {
|
||||
ChevronFirstIcon,
|
||||
ChevronLastIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CircleAlertIcon,
|
||||
DownloadIcon,
|
||||
UploadIcon,
|
||||
} from "lucide-react";
|
||||
import Papa from "papaparse";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type DropzoneOptions, useDropzone } from "react-dropzone";
|
||||
import { type Column, usePagination, useTable } from "react-table";
|
||||
import { isAddress } from "thirdweb";
|
||||
import { resolveAddress } from "thirdweb/extensions/ens";
|
||||
import { Button, Drawer, Heading, Text } from "tw-components";
|
||||
import { csvMimeTypes } from "utils/batch";
|
||||
import { Box, Flex, Link } from "@chakra-ui/react";
|
||||
import { useCsvUpload } from "hooks/useCsvUpload";
|
||||
import { CircleAlertIcon, DownloadIcon, UploadIcon } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction, useRef } from "react";
|
||||
import type { Column } from "react-table";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import { CsvDataTable } from "../csv-data-table";
|
||||
|
||||
interface SnapshotAddressInput {
|
||||
address: string;
|
||||
@@ -48,324 +26,271 @@ interface SnapshotAddressInput {
|
||||
}
|
||||
interface SnapshotUploadProps {
|
||||
setSnapshot: (snapshot: SnapshotAddressInput[]) => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
value?:
|
||||
| {
|
||||
address: string;
|
||||
maxClaimable: string;
|
||||
price?: string | undefined;
|
||||
currencyAddress?: string | undefined;
|
||||
}[]
|
||||
| null;
|
||||
snapshotIndex: number;
|
||||
setOpenSnapshotIndex: Dispatch<SetStateAction<number>>;
|
||||
index: number;
|
||||
dropType: "specific" | "any" | "overrides";
|
||||
isDisabled: boolean;
|
||||
value?: SnapshotAddressInput[] | undefined;
|
||||
}
|
||||
|
||||
const csvParser = (items: SnapshotAddressInput[]): SnapshotAddressInput[] => {
|
||||
return items
|
||||
.map(({ address, maxClaimable, price, currencyAddress }) => ({
|
||||
address: (address || "").trim(),
|
||||
maxClaimable: (maxClaimable || "0").trim().toLowerCase(),
|
||||
price: (price || "").trim() || undefined,
|
||||
currencyAddress: (currencyAddress || "").trim() || undefined,
|
||||
}))
|
||||
.filter(({ address }) => address !== "");
|
||||
};
|
||||
|
||||
export const SnapshotUpload: React.FC<SnapshotUploadProps> = ({
|
||||
setSnapshot,
|
||||
isOpen,
|
||||
onClose,
|
||||
value,
|
||||
snapshotIndex,
|
||||
index,
|
||||
setOpenSnapshotIndex,
|
||||
dropType,
|
||||
isDisabled,
|
||||
value,
|
||||
}) => {
|
||||
const client = useThirdwebClient();
|
||||
const [validSnapshot, setValidSnapshot] = useState<SnapshotAddressInput[]>(
|
||||
value || [],
|
||||
);
|
||||
const [snapshotData, setSnapshotData] = useState<SnapshotAddressInput[]>([]);
|
||||
const [noCsv, setNoCsv] = useState(false);
|
||||
const [invalidFound, setInvalidFound] = useState(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setValidSnapshot([]);
|
||||
setNoCsv(false);
|
||||
}, []);
|
||||
|
||||
const _onClose = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const onDrop = useCallback<Required<DropzoneOptions>["onDrop"]>(
|
||||
(acceptedFiles) => {
|
||||
setNoCsv(false);
|
||||
|
||||
const csv = acceptedFiles.find(
|
||||
(f) => csvMimeTypes.includes(f.type) || f.name?.endsWith(".csv"),
|
||||
);
|
||||
if (!csv) {
|
||||
console.error(
|
||||
"No valid CSV file found, make sure you have an address column.",
|
||||
);
|
||||
setNoCsv(true);
|
||||
return;
|
||||
}
|
||||
|
||||
Papa.parse(csv, {
|
||||
header: true,
|
||||
complete: (results) => {
|
||||
const data: SnapshotAddressInput[] = (
|
||||
results.data as SnapshotAddressInput[]
|
||||
)
|
||||
.map(({ address, maxClaimable, price, currencyAddress }) => ({
|
||||
address: (address || "").trim(),
|
||||
maxClaimable: (maxClaimable || "0").trim().toLowerCase(),
|
||||
price: (price || "").trim() || undefined,
|
||||
currencyAddress: (currencyAddress || "").trim() || undefined,
|
||||
}))
|
||||
.filter(({ address }) => address !== "");
|
||||
|
||||
if (!data[0]?.address) {
|
||||
setNoCsv(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidSnapshot(data);
|
||||
},
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// FIXME: this can be a mutation or query instead!
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (validSnapshot.length === 0) {
|
||||
return setSnapshotData([]);
|
||||
}
|
||||
|
||||
const normalizeAddresses = async (snapshot: SnapshotAddressInput[]) => {
|
||||
const normalized = await Promise.all(
|
||||
snapshot.map(async ({ address, ...rest }) => {
|
||||
let isValid = true;
|
||||
let resolvedAddress = address;
|
||||
|
||||
try {
|
||||
resolvedAddress = isAddress(address)
|
||||
? address
|
||||
: await resolveAddress({ client, name: address });
|
||||
isValid = !!resolvedAddress;
|
||||
} catch {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return {
|
||||
address,
|
||||
resolvedAddress,
|
||||
isValid,
|
||||
...rest,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const seen = new Set();
|
||||
const filteredData = normalized.filter((el) => {
|
||||
const duplicate = seen.has(el.resolvedAddress);
|
||||
seen.add(el.resolvedAddress);
|
||||
return !duplicate;
|
||||
});
|
||||
|
||||
const valid = filteredData.filter(({ isValid }) => isValid);
|
||||
const invalid = filteredData.filter(({ isValid }) => !isValid);
|
||||
|
||||
if (invalid?.length > 0) {
|
||||
setInvalidFound(true);
|
||||
}
|
||||
const ordered = [...invalid, ...valid];
|
||||
setSnapshotData(ordered);
|
||||
};
|
||||
normalizeAddresses(validSnapshot);
|
||||
}, [validSnapshot, client]);
|
||||
|
||||
const removeInvalid = useCallback(() => {
|
||||
const filteredData = snapshotData.filter(({ isValid }) => isValid);
|
||||
setValidSnapshot(filteredData);
|
||||
setInvalidFound(false);
|
||||
}, [snapshotData]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
});
|
||||
const {
|
||||
normalizeQuery,
|
||||
getInputProps,
|
||||
getRootProps,
|
||||
isDragActive,
|
||||
rawData,
|
||||
noCsv,
|
||||
reset,
|
||||
removeInvalid,
|
||||
} = useCsvUpload<SnapshotAddressInput>({ csvParser, defaultRawData: value });
|
||||
|
||||
const paginationPortalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onSave = () => {
|
||||
setSnapshot(snapshotData);
|
||||
onClose();
|
||||
setSnapshot(normalizeQuery.data.result);
|
||||
setOpenSnapshotIndex(-1);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
Header: "Address",
|
||||
accessor: ({ address, isValid }) => {
|
||||
if (isValid) {
|
||||
return address;
|
||||
}
|
||||
return (
|
||||
<ToolTipLabel
|
||||
label={
|
||||
address === ZERO_ADDRESS
|
||||
? "Cannot send tokens to ZERO_ADDRESS"
|
||||
: address.startsWith("0x")
|
||||
? "Address is not valid"
|
||||
: "Address couldn't be resolved"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<CircleAlertIcon className="size-4 text-red-500" />
|
||||
<div className="cursor-default font-bold text-red-500">
|
||||
{address}
|
||||
</div>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Max claimable",
|
||||
accessor: ({ maxClaimable }) => {
|
||||
return maxClaimable === "0" || !maxClaimable
|
||||
? "Default"
|
||||
: maxClaimable === "unlimited"
|
||||
? "Unlimited"
|
||||
: maxClaimable;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Price",
|
||||
accessor: ({ price }) => {
|
||||
return price === "0"
|
||||
? "Free"
|
||||
: !price || price === "unlimited"
|
||||
? "Default"
|
||||
: price;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Currency Address",
|
||||
accessor: ({ currencyAddress }) => {
|
||||
return currencyAddress ===
|
||||
"0x0000000000000000000000000000000000000000" || !currencyAddress
|
||||
? "Default"
|
||||
: currencyAddress;
|
||||
},
|
||||
},
|
||||
] as Column<SnapshotAddressInput>[];
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpenSnapshotIndex(isOpen ? snapshotIndex : -1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
allowPinchZoom
|
||||
preserveScrollBarGap
|
||||
size="xl"
|
||||
onClose={_onClose}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<Flex direction="column" gap={6} h="100%">
|
||||
<Flex shadow="sm">
|
||||
<Container maxW="container.page">
|
||||
<Flex align="center" justify="space-between" p={4}>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Logo hideWordmark />
|
||||
<Heading size="title.md">
|
||||
{validSnapshot.length ? "Edit" : "Upload"} Snapshot
|
||||
</Heading>
|
||||
</div>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Flex>
|
||||
|
||||
{validSnapshot.length > 0 ? (
|
||||
<SnapshotTable portalRef={paginationPortalRef} data={snapshotData} />
|
||||
) : (
|
||||
<Flex flexGrow={1} align="center" overflow="auto">
|
||||
<Container maxW="container.page">
|
||||
<Flex gap={8} flexDir="column">
|
||||
<div className="relative aspect-[21/9] w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full cursor-pointer rounded-md border border-border hover:border-primary",
|
||||
noCsv ? "bg-red-200" : "bg-card",
|
||||
)}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="!m-auto flex flex-col">
|
||||
<UploadIcon
|
||||
className={cn(
|
||||
"mx-auto mb-2 size-8",
|
||||
noCsv ? "text-red-500" : "text-gray-600",
|
||||
)}
|
||||
/>
|
||||
{isDragActive ? (
|
||||
<Heading as={Text} size="label.md">
|
||||
Drop the files here
|
||||
</Heading>
|
||||
) : (
|
||||
<Heading
|
||||
as={Text}
|
||||
size="label.md"
|
||||
color={noCsv ? "red.500" : "gray.600"}
|
||||
>
|
||||
{noCsv
|
||||
? `No valid CSV file found, make sure your CSV includes the "address" column.`
|
||||
: "Drag & Drop a CSV file here"}
|
||||
</Heading>
|
||||
<Sheet open={snapshotIndex === index} onOpenChange={handleOpenChange}>
|
||||
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[900px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">
|
||||
{rawData.length ? "Edit" : "Upload"} Snapshot
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6 flex flex-col gap-6">
|
||||
{rawData.length > 0 ? (
|
||||
<CsvDataTable<SnapshotAddressInput>
|
||||
portalRef={paginationPortalRef}
|
||||
data={normalizeQuery.data.result}
|
||||
columns={columns}
|
||||
/>
|
||||
) : (
|
||||
<Flex
|
||||
flexGrow={1}
|
||||
align="center"
|
||||
overflow="auto"
|
||||
gap={8}
|
||||
flexDir="column"
|
||||
>
|
||||
<div className="relative aspect-[21/9] w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full cursor-pointer rounded-md border border-border hover:border-primary",
|
||||
noCsv ? "bg-red-200" : "bg-card",
|
||||
)}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="!m-auto flex flex-col">
|
||||
<UploadIcon
|
||||
className={cn(
|
||||
"mx-auto mb-2 size-8",
|
||||
noCsv ? "text-red-500" : "text-gray-600",
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
{isDragActive ? (
|
||||
<p>Drop the files here</p>
|
||||
) : (
|
||||
<p className={noCsv ? "text-red-500" : "text-gray-600"}>
|
||||
{noCsv
|
||||
? `No valid CSV file found, make sure your CSV includes the "address" column.`
|
||||
: "Drag & Drop a CSV file here"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Heading size="label.md">Requirements</Heading>
|
||||
<UnorderedList spacing={1}>
|
||||
{dropType === "specific" ? (
|
||||
<>
|
||||
<Text as={ListItem}>
|
||||
Files <em>must</em> contain one .csv file with a list
|
||||
of addresses and their{" "}
|
||||
<InlineCode code="maxClaimable" />. (amount each
|
||||
wallet is allowed to claim)
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-maxclaimable.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
You may optionally add <InlineCode code="price" /> and
|
||||
<InlineCode code="currencyAddress" /> overrides as
|
||||
well. This lets you override the currency and price
|
||||
you would like to charge per wallet you specified
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-overrides.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text as={ListItem}>
|
||||
Files <em>must</em> contain one .csv file with a list
|
||||
of addresses.
|
||||
<br />
|
||||
<Link download color="blue.500" href="/snapshot.csv">
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
You may optionally add a{" "}
|
||||
<InlineCode code="maxClaimable" />
|
||||
column override. (amount each wallet is allowed to
|
||||
claim) If not specified, the default value is the one
|
||||
you have set on your claim phase.
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-maxclaimable.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
You may optionally add <InlineCode code="price" /> and
|
||||
<InlineCode code="currencyAddress" /> overrides. This
|
||||
lets you override the currency and price you would
|
||||
like to charge per wallet you specified.{" "}
|
||||
<strong>
|
||||
When defining a custom currency address, you must
|
||||
also define a price override.
|
||||
</strong>
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-overrides.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Text as={ListItem}>
|
||||
Repeated addresses will be removed and only the first
|
||||
found will be kept.
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
The limit you set is for the maximum amount of NFTs a
|
||||
wallet can claim, not how many they can receive in total.
|
||||
</Text>
|
||||
</UnorderedList>
|
||||
</div>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex borderTop="1px solid" borderTopColor="borderColor">
|
||||
<Container maxW="container.page">
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
p={{ base: 0, md: 4 }}
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
mt={{ base: 4, md: 0 }}
|
||||
>
|
||||
<Box ref={paginationPortalRef} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-lg">Requirements</p>
|
||||
<UnorderedList>
|
||||
{dropType === "specific" ? (
|
||||
<>
|
||||
<li>
|
||||
Files <em>must</em> contain one .csv file with a list of
|
||||
addresses and their <InlineCode code="maxClaimable" />.
|
||||
(amount each wallet is allowed to claim)
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-maxclaimable.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
You may optionally add <InlineCode code="price" /> and
|
||||
<InlineCode code="currencyAddress" /> overrides as well.
|
||||
This lets you override the currency and price you would
|
||||
like to charge per wallet you specified
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-overrides.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>
|
||||
Files <em>must</em> contain one .csv file with a list of
|
||||
addresses.
|
||||
<br />
|
||||
<Link download color="blue.500" href="/snapshot.csv">
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
You may optionally add a{" "}
|
||||
<InlineCode code="maxClaimable" />
|
||||
column override. (amount each wallet is allowed to
|
||||
claim) If not specified, the default value is the one
|
||||
you have set on your claim phase.
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-maxclaimable.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
You may optionally add <InlineCode code="price" /> and
|
||||
<InlineCode code="currencyAddress" /> overrides. This
|
||||
lets you override the currency and price you would like
|
||||
to charge per wallet you specified.{" "}
|
||||
<strong>
|
||||
When defining a custom currency address, you must also
|
||||
define a price override.
|
||||
</strong>
|
||||
<br />
|
||||
<Link
|
||||
download
|
||||
color="blue.500"
|
||||
href="/snapshot-with-overrides.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
<li>
|
||||
Repeated addresses will be removed and only the first found
|
||||
will be kept.
|
||||
</li>
|
||||
<li>
|
||||
The limit you set is for the maximum amount of NFTs a wallet
|
||||
can claim, not how many they can receive in total.
|
||||
</li>
|
||||
</UnorderedList>
|
||||
</div>
|
||||
</Flex>
|
||||
)}
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
p={{ base: 0, md: 4 }}
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
mt={{ base: 4, md: 0 }}
|
||||
borderTop="1px solid"
|
||||
borderTopColor="borderColor"
|
||||
>
|
||||
<Box ref={paginationPortalRef} />
|
||||
{!isDisabled && (
|
||||
<Flex
|
||||
gap={2}
|
||||
align="center"
|
||||
@@ -373,235 +298,40 @@ export const SnapshotUpload: React.FC<SnapshotUploadProps> = ({
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
>
|
||||
<Button
|
||||
borderRadius="md"
|
||||
disabled={isDisabled || validSnapshot.length === 0}
|
||||
className="w-full rounded-md md:w-auto"
|
||||
disabled={isDisabled || rawData.length === 0}
|
||||
onClick={() => {
|
||||
reset();
|
||||
}}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{invalidFound ? (
|
||||
{normalizeQuery.data.invalidFound ? (
|
||||
<Button
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
disabled={isDisabled || validSnapshot.length === 0}
|
||||
className="w-full rounded-md md:w-auto"
|
||||
variant="primary"
|
||||
disabled={isDisabled || rawData.length === 0}
|
||||
onClick={() => {
|
||||
removeInvalid();
|
||||
}}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
>
|
||||
Remove invalid
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
className="w-full rounded-md md:w-auto"
|
||||
variant="primary"
|
||||
onClick={onSave}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
isDisabled={isDisabled || validSnapshot.length === 0}
|
||||
disabled={isDisabled || rawData.length === 0}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
interface SnapshotTableProps {
|
||||
data: SnapshotAddressInput[];
|
||||
portalRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
const SnapshotTableColumns = [
|
||||
{
|
||||
Header: "Address",
|
||||
accessor: ({ address, isValid }) => {
|
||||
if (isValid) {
|
||||
return address;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Tooltip
|
||||
label={
|
||||
address.startsWith("0x")
|
||||
? "Address is not valid"
|
||||
: "Address couldn't be resolved"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<CircleAlertIcon className="size-4 text-red-500" />
|
||||
<Text fontWeight="bold" color="red.500" cursor="default">
|
||||
{address}
|
||||
</Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Max claimable",
|
||||
accessor: ({ maxClaimable }) => {
|
||||
return maxClaimable === "0" || !maxClaimable
|
||||
? "Default"
|
||||
: maxClaimable === "unlimited"
|
||||
? "Unlimited"
|
||||
: maxClaimable;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Price",
|
||||
accessor: ({ price }) => {
|
||||
return price === "0"
|
||||
? "Free"
|
||||
: !price || price === "unlimited"
|
||||
? "Default"
|
||||
: price;
|
||||
},
|
||||
},
|
||||
{
|
||||
Header: "Currency Address",
|
||||
accessor: ({ currencyAddress }) => {
|
||||
return currencyAddress === "0x0000000000000000000000000000000000000000" ||
|
||||
!currencyAddress
|
||||
? "Default"
|
||||
: currencyAddress;
|
||||
},
|
||||
},
|
||||
] as Column<SnapshotAddressInput>[];
|
||||
|
||||
const SnapshotTable: React.FC<SnapshotTableProps> = ({ data, portalRef }) => {
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
prepareRow,
|
||||
// Instead of using 'rows', we'll use page,
|
||||
page,
|
||||
// which has only the rows for the active page
|
||||
|
||||
// The rest of these things are super handy, too ;)
|
||||
canPreviousPage,
|
||||
canNextPage,
|
||||
pageOptions,
|
||||
pageCount,
|
||||
gotoPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
setPageSize,
|
||||
state: { pageIndex, pageSize },
|
||||
} = useTable(
|
||||
{
|
||||
columns: SnapshotTableColumns,
|
||||
data,
|
||||
initialState: {
|
||||
pageSize: 50,
|
||||
pageIndex: 0,
|
||||
},
|
||||
},
|
||||
// old package, this will be removed
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
usePagination,
|
||||
);
|
||||
|
||||
// Render the UI for your table
|
||||
return (
|
||||
<Flex flexGrow={1}>
|
||||
<TableContainer>
|
||||
<Table {...getTableProps()}>
|
||||
<Thead>
|
||||
{headerGroups.map((headerGroup, headerGroupIndex) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Tr {...headerGroup.getHeaderGroupProps()} key={headerGroupIndex}>
|
||||
{headerGroup.headers.map((column, columnIndex) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Th {...column.getHeaderProps()} key={columnIndex}>
|
||||
<Text as="label" size="label.sm" color="faded">
|
||||
{column.render("Header")}
|
||||
</Text>
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody {...getTableBodyProps()}>
|
||||
{page.map((row, rowIndex) => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Tr {...row.getRowProps()} key={rowIndex}>
|
||||
{row.cells.map((cell, cellIndex) => {
|
||||
return (
|
||||
<Td
|
||||
{...cell.getCellProps()}
|
||||
borderColor="borderColor"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={cellIndex}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Portal containerRef={portalRef}>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className="flex flex-row gap-1">
|
||||
<IconButton
|
||||
isDisabled={!canPreviousPage}
|
||||
aria-label="first page"
|
||||
icon={<ChevronFirstIcon className="size-4" />}
|
||||
onClick={() => gotoPage(0)}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={!canPreviousPage}
|
||||
aria-label="previous page"
|
||||
icon={<ChevronLeftIcon className="size-4" />}
|
||||
onClick={() => previousPage()}
|
||||
/>
|
||||
<p className="my-auto whitespace-nowrap">
|
||||
Page <strong>{pageIndex + 1}</strong> of{" "}
|
||||
<strong>{pageOptions.length}</strong>
|
||||
</p>
|
||||
<IconButton
|
||||
isDisabled={!canNextPage}
|
||||
aria-label="next page"
|
||||
icon={<ChevronRightIcon className="size-4" />}
|
||||
onClick={() => nextPage()}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={!canNextPage}
|
||||
aria-label="last page"
|
||||
icon={<ChevronLastIcon className="size-4" />}
|
||||
onClick={() => gotoPage(pageCount - 1)}
|
||||
/>
|
||||
|
||||
<Select
|
||||
onChange={(e) => {
|
||||
setPageSize(Number.parseInt(e.target.value as string, 10));
|
||||
}}
|
||||
value={pageSize}
|
||||
>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="250">250</option>
|
||||
<option value="500">500</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { TableContainer } from "@/components/ui/table";
|
||||
import {
|
||||
IconButton,
|
||||
Portal,
|
||||
Select,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
ChevronFirstIcon,
|
||||
ChevronLastIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { type Column, usePagination, useTable } from "react-table";
|
||||
import { Text } from "tw-components";
|
||||
|
||||
interface CsvDataTableProps<T extends object> {
|
||||
data: T[];
|
||||
portalRef: React.RefObject<HTMLDivElement | null>;
|
||||
columns: Column<T>[];
|
||||
}
|
||||
/**
|
||||
* Display the data uploaded from useCsvUpload, using react-table
|
||||
*/
|
||||
export function CsvDataTable<T extends object>({
|
||||
data,
|
||||
portalRef,
|
||||
columns,
|
||||
}: CsvDataTableProps<T>) {
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
prepareRow,
|
||||
// Instead of using 'rows', we'll use page,
|
||||
page,
|
||||
// which has only the rows for the active page
|
||||
// The rest of these things are super handy, too ;)
|
||||
canPreviousPage,
|
||||
canNextPage,
|
||||
pageOptions,
|
||||
pageCount,
|
||||
gotoPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
setPageSize,
|
||||
state: { pageIndex, pageSize },
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
initialState: {
|
||||
pageSize: 50,
|
||||
pageIndex: 0,
|
||||
},
|
||||
},
|
||||
// old package: this will be removed
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
usePagination,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table {...getTableProps()}>
|
||||
<Thead>
|
||||
{headerGroups.map((headerGroup, headerGroupIndex) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Tr {...headerGroup.getHeaderGroupProps()} key={headerGroupIndex}>
|
||||
{headerGroup.headers.map((column, columnIndex) => (
|
||||
<Th
|
||||
{...column.getHeaderProps()}
|
||||
border="none"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={columnIndex}
|
||||
>
|
||||
<Text as="label" size="label.sm" color="faded">
|
||||
{column.render("Header")}
|
||||
</Text>
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody {...getTableBodyProps()}>
|
||||
{page.map((row, rowIndex) => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Tr {...row.getRowProps()} key={rowIndex}>
|
||||
{row.cells.map((cell, cellIndex) => (
|
||||
<Td
|
||||
{...cell.getCellProps()}
|
||||
borderColor="borderColor"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={cellIndex}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Td>
|
||||
))}
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{/* Only need to show the Pagination components if we have more than 25 records */}
|
||||
{data.length > 0 && (
|
||||
<Portal containerRef={portalRef}>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className="flex flex-row gap-1">
|
||||
<IconButton
|
||||
isDisabled={!canPreviousPage}
|
||||
aria-label="first page"
|
||||
icon={<ChevronFirstIcon className="size-4" />}
|
||||
onClick={() => gotoPage(0)}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={!canPreviousPage}
|
||||
aria-label="previous page"
|
||||
icon={<ChevronLeftIcon className="size-4" />}
|
||||
onClick={() => previousPage()}
|
||||
/>
|
||||
<p className="my-auto whitespace-nowrap">
|
||||
Page <strong>{pageIndex + 1}</strong> of{" "}
|
||||
<strong>{pageOptions.length}</strong>
|
||||
</p>
|
||||
<IconButton
|
||||
isDisabled={!canNextPage}
|
||||
aria-label="next page"
|
||||
icon={<ChevronRightIcon className="size-4" />}
|
||||
onClick={() => nextPage()}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={!canNextPage}
|
||||
aria-label="last page"
|
||||
icon={<ChevronLastIcon className="size-4" />}
|
||||
onClick={() => gotoPage(pageCount - 1)}
|
||||
/>
|
||||
<Select
|
||||
onChange={(e) => {
|
||||
setPageSize(Number.parseInt(e.target.value as string, 10));
|
||||
}}
|
||||
value={pageSize}
|
||||
>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="250">250</option>
|
||||
<option value="500">500</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
|
||||
export function LoadingPage() {
|
||||
return (
|
||||
<div className="flex min-h-[300px] grow items-center justify-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorPage() {
|
||||
return (
|
||||
<div className="flex min-h-[300px] grow items-center justify-center">
|
||||
<p className="text-destructive-text">Failed to load contract</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { LoadingPage } from "./page-skeletons";
|
||||
|
||||
export function RedirectToContractOverview(props: {
|
||||
contract: ThirdwebContract;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const redirected = useRef(false);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (redirected.current) {
|
||||
return;
|
||||
}
|
||||
redirected.current = true;
|
||||
router.replace(`/${props.contract.chain.id}/${props.contract.address}`);
|
||||
}, [router, props.contract]);
|
||||
|
||||
return <LoadingPage />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { getContractPageMetadataSetup } from "../_utils/getContractPageMetadata";
|
||||
|
||||
export function useContractPageMetadata(contract: ThirdwebContract) {
|
||||
return useQuery({
|
||||
queryKey: ["getContractPageMetadataSetup", contract],
|
||||
queryFn: () => {
|
||||
return getContractPageMetadataSetup(contract, () =>
|
||||
Promise.resolve(false),
|
||||
);
|
||||
},
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type ThirdwebContract, resolveContractAbi } from "thirdweb/contract";
|
||||
|
||||
export function useResolveContractABI(contract: ThirdwebContract) {
|
||||
return useQuery({
|
||||
queryKey: ["resolveContractAbi", contract],
|
||||
queryFn: () => {
|
||||
return resolveContractAbi(contract);
|
||||
},
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckIcon, CircleAlertIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ConfigureNetworks } from "../../../../../components/configure-networks/ConfigureNetworks";
|
||||
import { addChainOverrides } from "../../../../../stores/chainStores";
|
||||
import { addChainOverrides } from "stores/chainStores";
|
||||
import { ConfigureNetworks } from "../../../../../../components/configure-networks/ConfigureNetworks";
|
||||
|
||||
export function ConfigureCustomChain(props: {
|
||||
chainSlug: string;
|
||||
@@ -1,5 +1,8 @@
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { fetchDashboardContractMetadata } from "@3rdweb-sdk/react/hooks/useDashboardContractMetadata";
|
||||
import {
|
||||
type DashboardContractMetadata,
|
||||
fetchDashboardContractMetadata,
|
||||
} from "@3rdweb-sdk/react/hooks/useDashboardContractMetadata";
|
||||
import { fetchPublishedContractsFromDeploy } from "components/contract-components/fetchPublishedContractsFromDeploy";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
@@ -8,12 +11,34 @@ import { MetadataHeader } from "./metadata-header";
|
||||
interface ContractMetadataProps {
|
||||
contract: ThirdwebContract;
|
||||
chain: ChainMetadata;
|
||||
contractMetadata: DashboardContractMetadata | undefined;
|
||||
externalLinks:
|
||||
| {
|
||||
name: string;
|
||||
url: string;
|
||||
}[]
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export async function ContractMetadata({
|
||||
export function ContractMetadata({
|
||||
contract,
|
||||
chain,
|
||||
contractMetadata,
|
||||
externalLinks,
|
||||
}: ContractMetadataProps) {
|
||||
return (
|
||||
<MetadataHeader
|
||||
data={contractMetadata}
|
||||
chain={chain}
|
||||
address={contract.address}
|
||||
externalLinks={externalLinks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export async function getContractMetadataHeaderData(
|
||||
contract: ThirdwebContract,
|
||||
) {
|
||||
const settledResults = await Promise.allSettled([
|
||||
fetchDashboardContractMetadata(contract),
|
||||
fetchPublishedContractsFromDeploy({
|
||||
@@ -34,12 +59,8 @@ export async function ContractMetadata({
|
||||
|
||||
const latestPublished = publishedContractsFromDeploy?.slice(-1)[0];
|
||||
|
||||
return (
|
||||
<MetadataHeader
|
||||
data={contractMetadata}
|
||||
chain={chain}
|
||||
address={contract.address}
|
||||
externalLinks={latestPublished?.externalLinks}
|
||||
/>
|
||||
);
|
||||
return {
|
||||
contractMetadata,
|
||||
externalLinks: latestPublished?.externalLinks,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { ErrorPage, LoadingPage } from "../_components/page-skeletons";
|
||||
import { useContractPageMetadata } from "../_hooks/useContractPageMetadata";
|
||||
import { getContractPageSidebarLinks } from "../_utils/getContractPageSidebarLinks";
|
||||
import { getContractMetadataHeaderData } from "./contract-metadata";
|
||||
import { ContractPageLayout } from "./contract-page-layout";
|
||||
|
||||
export function ContractPageLayoutClient(props: {
|
||||
chainMetadata: ChainMetadata;
|
||||
contract: ThirdwebContract;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const metadataQuery = useContractPageMetadata(props.contract);
|
||||
const headerMetadataQuery = useQuery({
|
||||
queryKey: ["getContractMetadataHeaderData", props.contract],
|
||||
queryFn: async () => {
|
||||
return await getContractMetadataHeaderData(props.contract);
|
||||
},
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
if (metadataQuery.isPending) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
||||
if (metadataQuery.isError) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
const sidebarLinks = getContractPageSidebarLinks({
|
||||
chainSlug: props.chainMetadata.slug,
|
||||
contractAddress: props.contract.address,
|
||||
metadata: metadataQuery.data,
|
||||
});
|
||||
|
||||
return (
|
||||
<ContractPageLayout
|
||||
{...props}
|
||||
sidebarLinks={sidebarLinks}
|
||||
dashboardContractMetadata={headerMetadataQuery.data?.contractMetadata}
|
||||
externalLinks={headerMetadataQuery.data?.externalLinks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import type { SidebarLink } from "@/components/blocks/Sidebar";
|
||||
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
|
||||
import type { DashboardContractMetadata } from "@3rdweb-sdk/react/hooks/useDashboardContractMetadata";
|
||||
import { DeprecatedAlert } from "components/shared/DeprecatedAlert";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { ContractMetadata } from "./contract-metadata";
|
||||
import { PrimaryDashboardButton } from "./primary-dashboard-button";
|
||||
|
||||
export function ContractPageLayout(props: {
|
||||
chainMetadata: ChainMetadata;
|
||||
contract: ThirdwebContract;
|
||||
children: React.ReactNode;
|
||||
sidebarLinks: SidebarLink[];
|
||||
dashboardContractMetadata: DashboardContractMetadata | undefined;
|
||||
externalLinks:
|
||||
| {
|
||||
name: string;
|
||||
url: string;
|
||||
}[]
|
||||
| undefined;
|
||||
}) {
|
||||
const {
|
||||
chainMetadata,
|
||||
contract,
|
||||
sidebarLinks,
|
||||
dashboardContractMetadata,
|
||||
externalLinks,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<ChakraProviderSetup>
|
||||
<SidebarLayout sidebarLinks={sidebarLinks}>
|
||||
<div className="border-border border-b pb-8">
|
||||
<div className="flex flex-col gap-4 ">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row">
|
||||
<ContractMetadata
|
||||
contract={contract}
|
||||
chain={chainMetadata}
|
||||
contractMetadata={dashboardContractMetadata}
|
||||
externalLinks={externalLinks}
|
||||
/>
|
||||
<PrimaryDashboardButton
|
||||
contractAddress={contract.address}
|
||||
chain={contract.chain}
|
||||
contractInfo={{
|
||||
chain: chainMetadata,
|
||||
chainSlug: chainMetadata.slug,
|
||||
contractAddress: contract.address,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DeprecatedAlert chain={chainMetadata} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-8" />
|
||||
<div className="pb-10">{props.children}</div>
|
||||
</SidebarLayout>
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
}
|
||||
@@ -13,34 +13,27 @@ export async function getContractPageParamsInfo(params: {
|
||||
}) {
|
||||
const contractAddress = getCheckSummedAddress(params.contractAddress);
|
||||
const chainSlugOrId = params.chain_id;
|
||||
let chainMetadata: ChainMetadata | null = null;
|
||||
try {
|
||||
chainMetadata = await fetchChain(chainSlugOrId);
|
||||
} catch {
|
||||
// move on
|
||||
}
|
||||
let chainMetadata = await fetchChain(chainSlugOrId).catch(() => null);
|
||||
|
||||
if (!chainMetadata) {
|
||||
const cookieStore = cookies();
|
||||
const localChainStoreValue = cookieStore.get(TW_LOCAL_CHAIN_STORE)?.value;
|
||||
const cookieStore = await cookies();
|
||||
const localChainStoreValue = cookieStore.get(TW_LOCAL_CHAIN_STORE)?.value;
|
||||
|
||||
if (localChainStoreValue) {
|
||||
try {
|
||||
const chains = JSON.parse(decodeURIComponent(localChainStoreValue));
|
||||
if (typeof chains === "object" && Array.isArray(chains)) {
|
||||
const chainOverrides = chains as ChainMetadata[];
|
||||
const chain = chainOverrides.find(
|
||||
(c) =>
|
||||
c.slug === chainSlugOrId ||
|
||||
c.chainId === Number.parseInt(chainSlugOrId),
|
||||
);
|
||||
if (chain) {
|
||||
chainMetadata = chain;
|
||||
}
|
||||
if (localChainStoreValue) {
|
||||
try {
|
||||
const chains = JSON.parse(decodeURIComponent(localChainStoreValue));
|
||||
if (typeof chains === "object" && Array.isArray(chains)) {
|
||||
const chainOverrides = chains as ChainMetadata[];
|
||||
const chain = chainOverrides.find(
|
||||
(c) =>
|
||||
c.slug === chainSlugOrId ||
|
||||
c.chainId === Number.parseInt(chainSlugOrId),
|
||||
);
|
||||
if (chain) {
|
||||
chainMetadata = chain;
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +42,7 @@ export async function getContractPageParamsInfo(params: {
|
||||
}
|
||||
|
||||
// attempt to get the auth token
|
||||
const token = getAuthToken();
|
||||
const token = await getAuthToken();
|
||||
|
||||
const contract = getContract({
|
||||
address: contractAddress,
|
||||
|
||||
@@ -46,8 +46,13 @@ export type ContractPageMetadata = {
|
||||
functionSelectors: string[];
|
||||
};
|
||||
|
||||
export async function getContractPageMetadata(
|
||||
export async function getContractPageMetadata(contract: ThirdwebContract) {
|
||||
return getContractPageMetadataSetup(contract, isAnalyticsSupportedForChain);
|
||||
}
|
||||
|
||||
export async function getContractPageMetadataSetup(
|
||||
contract: ThirdwebContract,
|
||||
isAnalyticsSupportedFn: (chainId: number) => Promise<boolean>,
|
||||
): Promise<ContractPageMetadata> {
|
||||
const [
|
||||
functionSelectorsResult,
|
||||
@@ -55,7 +60,7 @@ export async function getContractPageMetadata(
|
||||
contractTypeResult,
|
||||
] = await Promise.allSettled([
|
||||
resolveFunctionSelectors(contract),
|
||||
isAnalyticsSupportedForChain(contract.chain.id),
|
||||
isAnalyticsSupportedFn(contract.chain.id),
|
||||
getContractType({ contract }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { ErrorPage, LoadingPage } from "../_components/page-skeletons";
|
||||
import { RedirectToContractOverview } from "../_components/redirect-contract-overview.client";
|
||||
import { useContractPageMetadata } from "../_hooks/useContractPageMetadata";
|
||||
import { AccountSigners } from "./components/account-signers";
|
||||
|
||||
export function AccountSignersClient(props: {
|
||||
contract: ThirdwebContract;
|
||||
}) {
|
||||
const metadataQuery = useContractPageMetadata(props.contract);
|
||||
|
||||
if (metadataQuery.isPending) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
||||
if (metadataQuery.isError) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
if (!metadataQuery.data.isAccountPermissionsSupported) {
|
||||
return <RedirectToContractOverview contract={props.contract} />;
|
||||
}
|
||||
|
||||
return <AccountSigners contract={props.contract} />;
|
||||
}
|
||||
@@ -31,14 +31,19 @@ export const AccountSigners: React.FC<AccountSignersProps> = ({ contract }) => {
|
||||
);
|
||||
const data = transformedAdmins.concat(transformedSigners || []);
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{data.map((item) => (
|
||||
<AccountSigner
|
||||
key={item.signer}
|
||||
item={item}
|
||||
contractChainId={contract.chain.id}
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
<h1 className="mb-4 font-semibold text-2xl tracking-tight">
|
||||
Account Signers
|
||||
</h1>
|
||||
<div className="flex flex-col gap-6">
|
||||
{data.map((item) => (
|
||||
<AccountSigner
|
||||
key={item.signer}
|
||||
item={item}
|
||||
contractChainId={contract.chain.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
|
||||
import { AccountSignersClient } from "./AccountSigners.client";
|
||||
import { AccountSigners } from "./components/account-signers";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { contract } = info;
|
||||
const { contract, chainMetadata } = info;
|
||||
|
||||
if (chainMetadata.chainId === localhost.id) {
|
||||
return <AccountSignersClient contract={contract} />;
|
||||
}
|
||||
|
||||
const { isAccountPermissionsSupported } =
|
||||
await getContractPageMetadata(contract);
|
||||
|
||||
if (!isAccountPermissionsSupported) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-4 font-semibold text-2xl tracking-tight">
|
||||
Account Signers
|
||||
</h1>
|
||||
<AccountSigners contract={contract} />
|
||||
</div>
|
||||
);
|
||||
return <AccountSigners contract={contract} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { ErrorPage, LoadingPage } from "../_components/page-skeletons";
|
||||
import { RedirectToContractOverview } from "../_components/redirect-contract-overview.client";
|
||||
import { useContractPageMetadata } from "../_hooks/useContractPageMetadata";
|
||||
import { AccountPage } from "./AccountPage";
|
||||
|
||||
export function AccountPageClient(props: {
|
||||
contract: ThirdwebContract;
|
||||
chainMetadata: ChainMetadata;
|
||||
}) {
|
||||
const metadataQuery = useContractPageMetadata(props.contract);
|
||||
|
||||
if (metadataQuery.isPending) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
||||
if (metadataQuery.isError) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
if (!metadataQuery.data.isAccount) {
|
||||
return <RedirectToContractOverview contract={props.contract} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountPage
|
||||
contract={props.contract}
|
||||
chainMetadata={props.chainMetadata}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,34 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
|
||||
import { AccountPage } from "./AccountPage";
|
||||
import { AccountPageClient } from "./AccountPage.client";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { contract, chainMetadata } = info;
|
||||
if (contract.chain.id === localhost.id) {
|
||||
return (
|
||||
<AccountPageClient contract={contract} chainMetadata={chainMetadata} />
|
||||
);
|
||||
}
|
||||
|
||||
const { isAccount } = await getContractPageMetadata(contract);
|
||||
|
||||
if (!isAccount) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <AccountPage contract={contract} chainMetadata={chainMetadata} />;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { ErrorPage, LoadingPage } from "../_components/page-skeletons";
|
||||
import { RedirectToContractOverview } from "../_components/redirect-contract-overview.client";
|
||||
import { useContractPageMetadata } from "../_hooks/useContractPageMetadata";
|
||||
import { AccountsPage } from "./AccountsPage";
|
||||
|
||||
export function AccountsPageClient(props: {
|
||||
contract: ThirdwebContract;
|
||||
}) {
|
||||
const metadataQuery = useContractPageMetadata(props.contract);
|
||||
|
||||
if (metadataQuery.isPending) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
||||
if (metadataQuery.isError) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
if (!metadataQuery.data.isAccountFactory) {
|
||||
return <RedirectToContractOverview contract={props.contract} />;
|
||||
}
|
||||
|
||||
return <AccountsPage contract={props.contract} />;
|
||||
}
|
||||
@@ -1,25 +1,33 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
|
||||
import { AccountsPage } from "./AccountsPage";
|
||||
import { AccountsPageClient } from "./AccountsPage.client";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { contract } = info;
|
||||
const { contract, chainMetadata } = info;
|
||||
|
||||
if (chainMetadata.chainId === localhost.id) {
|
||||
return <AccountsPageClient contract={contract} />;
|
||||
}
|
||||
|
||||
const { isAccountFactory } = await getContractPageMetadata(contract);
|
||||
|
||||
if (!isAccountFactory) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <AccountsPage contract={contract} />;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { ErrorPage, LoadingPage } from "../_components/page-skeletons";
|
||||
import { RedirectToContractOverview } from "../_components/redirect-contract-overview.client";
|
||||
import { useContractPageMetadata } from "../_hooks/useContractPageMetadata";
|
||||
import { ContractAnalyticsPage } from "./ContractAnalyticsPage";
|
||||
|
||||
export function ContractAnalyticsPageClient(props: {
|
||||
contract: ThirdwebContract;
|
||||
}) {
|
||||
const metadataQuery = useContractPageMetadata(props.contract);
|
||||
|
||||
if (metadataQuery.isPending) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
||||
if (metadataQuery.isError) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
if (!metadataQuery.data.isAnalyticsSupported) {
|
||||
return <RedirectToContractOverview contract={props.contract} />;
|
||||
}
|
||||
|
||||
return <ContractAnalyticsPage contract={props.contract} />;
|
||||
}
|
||||
@@ -1,24 +1,31 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
|
||||
import { ContractAnalyticsPage } from "./ContractAnalyticsPage";
|
||||
import { ContractAnalyticsPageClient } from "./ContractAnalyticsPage.client";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (info.chainMetadata.chainId === localhost.id) {
|
||||
return <ContractAnalyticsPageClient contract={info.contract} />;
|
||||
}
|
||||
|
||||
const { isAnalyticsSupported } = await getContractPageMetadata(info.contract);
|
||||
|
||||
if (!isAnalyticsSupported) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <ContractAnalyticsPage contract={info.contract} />;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { ClaimConditions } from "../_components/claim-conditions/claim-conditions";
|
||||
import { ErrorPage, LoadingPage } from "../_components/page-skeletons";
|
||||
import { RedirectToContractOverview } from "../_components/redirect-contract-overview.client";
|
||||
import { useContractPageMetadata } from "../_hooks/useContractPageMetadata";
|
||||
|
||||
export function ClaimConditionsClient(props: {
|
||||
contract: ThirdwebContract;
|
||||
}) {
|
||||
const metadataQuery = useContractPageMetadata(props.contract);
|
||||
|
||||
if (metadataQuery.isPending) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
||||
if (metadataQuery.isError) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
const {
|
||||
isERC20ClaimConditionsSupported,
|
||||
isERC721ClaimConditionsSupported,
|
||||
supportedERCs,
|
||||
} = metadataQuery.data;
|
||||
|
||||
if (!isERC20ClaimConditionsSupported && !isERC721ClaimConditionsSupported) {
|
||||
return <RedirectToContractOverview contract={props.contract} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClaimConditions
|
||||
contract={props.contract}
|
||||
isERC20={supportedERCs.isERC20}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,28 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { ClaimConditions } from "../_components/claim-conditions/claim-conditions";
|
||||
import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
|
||||
import { getContractPageMetadata } from "../_utils/getContractPageMetadata";
|
||||
import { ClaimConditionsClient } from "./ClaimConditions.client";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
notFound();
|
||||
}
|
||||
const { contract, chainMetadata } = info;
|
||||
|
||||
if (chainMetadata.chainId === localhost.id) {
|
||||
return <ClaimConditionsClient contract={contract} />;
|
||||
}
|
||||
|
||||
const { contract } = info;
|
||||
const {
|
||||
isERC20ClaimConditionsSupported,
|
||||
isERC721ClaimConditionsSupported,
|
||||
@@ -23,7 +30,7 @@ export default async function Page(props: {
|
||||
} = await getContractPageMetadata(contract);
|
||||
|
||||
if (!isERC20ClaimConditionsSupported && !isERC721ClaimConditionsSupported) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import type { ThirdwebContract } from "thirdweb/contract";
|
||||
import { LoadingPage } from "../_components/page-skeletons";
|
||||
import { useResolveContractABI } from "../_hooks/useResolveContractABI";
|
||||
import { ContractCodePage } from "./contract-code-page";
|
||||
|
||||
export function ContractCodePageClient(props: {
|
||||
contract: ThirdwebContract;
|
||||
chainMetadata: ChainMetadata;
|
||||
}) {
|
||||
const abiQuery = useResolveContractABI(props.contract);
|
||||
|
||||
if (abiQuery.isPending) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ContractCodePage
|
||||
abi={abiQuery.data}
|
||||
contract={props.contract}
|
||||
chainMetadata={props.chainMetadata}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import type { Abi } from "abitype";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import type { ThirdwebContract } from "thirdweb/contract";
|
||||
import { CodeOverview } from "../../../../../../contract-ui/tabs/code/components/code-overview";
|
||||
|
||||
export function ContractCodePage(props: {
|
||||
abi: Abi | undefined;
|
||||
contract: ThirdwebContract;
|
||||
chainMetadata: ChainMetadata;
|
||||
}) {
|
||||
if (props.abi) {
|
||||
return (
|
||||
<CodeOverview
|
||||
abi={props.abi}
|
||||
contractAddress={props.contract.address}
|
||||
chainId={props.contract.chain.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>Failed to resolve contract ABI</AlertTitle>
|
||||
<AlertDescription>
|
||||
Please verify that contract address is correct and deployed on "
|
||||
{props.chainMetadata.name}" chain.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||