Compare commits
42
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 |
@@ -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
|
||||
@@ -91,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",
|
||||
|
||||
@@ -158,7 +158,6 @@ const moduleExports = {
|
||||
serverSourceMaps: false,
|
||||
},
|
||||
cacheMaxMemorySize: 0,
|
||||
swcMinify: true,
|
||||
compiler: {
|
||||
emotion: true,
|
||||
},
|
||||
|
||||
+34
-32
@@ -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",
|
||||
@@ -27,9 +27,10 @@
|
||||
"@chakra-ui/theme-tools": "^2.1.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",
|
||||
@@ -45,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.35.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",
|
||||
@@ -61,17 +63,17 @@
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"flat": "^6.0.1",
|
||||
"framer-motion": "11.11.10",
|
||||
"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.454.0",
|
||||
"next": "14.2.15",
|
||||
"next-plausible": "^3.12.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",
|
||||
@@ -79,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",
|
||||
@@ -104,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",
|
||||
"@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.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",
|
||||
"@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",
|
||||
@@ -133,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.34.2",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +1,43 @@
|
||||
import { keepPreviousData, 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";
|
||||
|
||||
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, page: number) {
|
||||
const { user, isLoggedIn } = useLoggedInUser();
|
||||
|
||||
@@ -13,31 +47,44 @@ export function useEmbeddedWallets(clientId: string, page: number) {
|
||||
clientId,
|
||||
page,
|
||||
),
|
||||
queryFn: async () => {
|
||||
const url = new URL(
|
||||
`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`,
|
||||
);
|
||||
url.searchParams.append("clientId", clientId);
|
||||
url.searchParams.append("page", page.toString());
|
||||
|
||||
const res = await fetch(url.href, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${user?.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;
|
||||
};
|
||||
},
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+53
-6
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ 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();
|
||||
@@ -26,7 +27,7 @@ export default async function Page(props: {
|
||||
);
|
||||
|
||||
if (!isDirectListingSupported) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <ContractDirectListingsPage contract={info.contract} />;
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ 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();
|
||||
@@ -26,7 +27,7 @@ export default async function Page(props: {
|
||||
);
|
||||
|
||||
if (!isEnglishAuctionSupported) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <ContractEnglishAuctionsPage contract={info.contract} />;
|
||||
|
||||
+3
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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) =>
|
||||
|
||||
+71
-6
@@ -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(
|
||||
|
||||
+29
-10
@@ -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,
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
+276
-546
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+162
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+3
-8
@@ -13,14 +13,9 @@ 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);
|
||||
|
||||
const cookieStore = cookies();
|
||||
const cookieStore = await cookies();
|
||||
const localChainStoreValue = cookieStore.get(TW_LOCAL_CHAIN_STORE)?.value;
|
||||
|
||||
if (localChainStoreValue) {
|
||||
@@ -47,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,
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ 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();
|
||||
@@ -27,7 +28,7 @@ export default async function Page(props: {
|
||||
await getContractPageMetadata(contract);
|
||||
|
||||
if (!isAccountPermissionsSupported) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <AccountSigners contract={contract} />;
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ 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();
|
||||
@@ -27,7 +28,7 @@ export default async function Page(props: {
|
||||
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} />;
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ 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();
|
||||
@@ -26,7 +27,7 @@ export default async function Page(props: {
|
||||
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} />;
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ 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();
|
||||
@@ -24,7 +25,7 @@ export default async function Page(props: {
|
||||
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} />;
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ 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();
|
||||
@@ -29,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 (
|
||||
|
||||
+4
-3
@@ -6,12 +6,13 @@ import { ContractCodePage } from "./contract-code-page";
|
||||
import { ContractCodePageClient } from "./contract-code-page.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();
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ import { EmbedSetupClient } from "./EmbedSetup.client";
|
||||
import { EmbedSetup } from "./embed-setup";
|
||||
|
||||
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();
|
||||
@@ -25,7 +26,7 @@ export default async function Page(props: {
|
||||
const { embedType } = await getContractPageMetadata(contract);
|
||||
|
||||
if (embedType === null) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <EmbedSetup contract={contract} ercOrMarketplace={embedType} />;
|
||||
|
||||
+4
-3
@@ -3,12 +3,13 @@ import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
|
||||
import { EventsFeed } from "./events-feed";
|
||||
|
||||
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();
|
||||
|
||||
+4
-3
@@ -6,12 +6,13 @@ import { ContractExplorerPage } from "./ContractExplorerPage";
|
||||
import { ContractExplorerPageClient } from "./ContractExplorerPage.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();
|
||||
|
||||
+10
-10
@@ -15,20 +15,21 @@ import { getContractPageMetadata } from "./_utils/getContractPageMetadata";
|
||||
import { getContractPageSidebarLinks } from "./_utils/getContractPageSidebarLinks";
|
||||
|
||||
export default async function Layout(props: {
|
||||
params: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
};
|
||||
}>;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!isAddress(props.params.contractAddress)) {
|
||||
const params = await props.params;
|
||||
if (!isAddress(params.contractAddress)) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
if (!info) {
|
||||
return <ConfigureCustomChain chainSlug={props.params.chain_id} />;
|
||||
return <ConfigureCustomChain chainSlug={params.chain_id} />;
|
||||
}
|
||||
|
||||
const { contract, chainMetadata } = info;
|
||||
@@ -78,14 +79,13 @@ export default async function Layout(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: {
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
};
|
||||
}>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
|
||||
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { sendAndConfirmTransaction } from "thirdweb";
|
||||
import { BatchMetadataERC721, BatchMetadataERC1155 } from "thirdweb/modules";
|
||||
import { parseAttributes } from "utils/parseAttributes";
|
||||
import { z } from "zod";
|
||||
import { fileBufferOrStringSchema } from "../zod-schemas";
|
||||
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
|
||||
import type { ModuleInstanceProps } from "./module-instance";
|
||||
import { AdvancedNFTMetadataFormGroup } from "./nft/AdvancedNFTMetadataFormGroup";
|
||||
import { NFTMediaFormGroup } from "./nft/NFTMediaFormGroup";
|
||||
import { PropertiesFormControl } from "./nft/PropertiesFormControl";
|
||||
|
||||
const uploadMetadataFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
image: fileBufferOrStringSchema.optional(),
|
||||
animationUri: fileBufferOrStringSchema.optional(),
|
||||
external_url: fileBufferOrStringSchema.optional(),
|
||||
customImage: z.string().optional(),
|
||||
customAnimationUrl: z.string().optional(),
|
||||
background_color: z
|
||||
.string()
|
||||
.refine(
|
||||
(c) => {
|
||||
return /^#[0-9a-f]{6}$/i.test(c.toLowerCase());
|
||||
},
|
||||
{
|
||||
message: "Invalid Hex Color",
|
||||
},
|
||||
)
|
||||
.optional(),
|
||||
attributes: z.array(
|
||||
z.object({
|
||||
trait_type: z.string().min(1),
|
||||
value: z.string().min(1),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type UploadMetadataFormValues = z.infer<typeof uploadMetadataFormSchema>;
|
||||
|
||||
function BatchMetadataModule(props: ModuleInstanceProps) {
|
||||
const { contract, ownerAccount } = props;
|
||||
|
||||
const isErc721 = props.contractInfo.name === "BatchMetadataERC721";
|
||||
|
||||
const uploadMetadata = useCallback(
|
||||
async (values: UploadMetadataFormValues) => {
|
||||
if (!ownerAccount) {
|
||||
throw new Error("Not an owner account");
|
||||
}
|
||||
|
||||
const nft = parseAttributes(values);
|
||||
const uploadMetadata = isErc721
|
||||
? BatchMetadataERC721.uploadMetadata
|
||||
: BatchMetadataERC1155.uploadMetadata;
|
||||
const uploadMetadataTx = uploadMetadata({
|
||||
contract,
|
||||
metadatas: [nft],
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account: ownerAccount,
|
||||
transaction: uploadMetadataTx,
|
||||
});
|
||||
},
|
||||
[contract, ownerAccount, isErc721],
|
||||
);
|
||||
|
||||
return (
|
||||
<BatchMetadataModuleUI
|
||||
{...props}
|
||||
uploadMetadata={uploadMetadata}
|
||||
isOwnerAccount={!!ownerAccount}
|
||||
contractChainId={contract.chain.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BatchMetadataModuleUI(
|
||||
props: Omit<ModuleCardUIProps, "children" | "updateButton"> & {
|
||||
isOwnerAccount: boolean;
|
||||
uploadMetadata: (values: UploadMetadataFormValues) => Promise<void>;
|
||||
contractChainId: number;
|
||||
},
|
||||
) {
|
||||
return (
|
||||
<ModuleCardUI {...props}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Accordion type="single" collapsible className="-mx-1">
|
||||
{/* uploadMetadata */}
|
||||
<AccordionItem value="metadata" className="border-none">
|
||||
<AccordionTrigger className="border-border border-t px-1">
|
||||
Upload NFT Metadata
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
{props.isOwnerAccount && (
|
||||
<UploadMetadataNFTSection
|
||||
uploadMetadata={props.uploadMetadata}
|
||||
contractChainId={props.contractChainId}
|
||||
/>
|
||||
)}
|
||||
{!props.isOwnerAccount && (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
You don't have permission to upload metadata on this
|
||||
contract
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* batchMetadata */}
|
||||
<AccordionItem value="batch-metadata" className="border-none">
|
||||
<AccordionTrigger className="border-border border-t px-1">
|
||||
Batch Upload NFT Metadata
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
{props.isOwnerAccount && <BatchMetadataNFTSection />}
|
||||
{!props.isOwnerAccount && (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
You don't have permission to upload metadata on this
|
||||
contract
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</ModuleCardUI>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadMetadataNFTSection(props: {
|
||||
uploadMetadata: (values: UploadMetadataFormValues) => Promise<void>;
|
||||
contractChainId: number;
|
||||
}) {
|
||||
const form = useForm<UploadMetadataFormValues>({
|
||||
resolver: zodResolver(uploadMetadataFormSchema),
|
||||
values: {
|
||||
name: "",
|
||||
attributes: [],
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const uploadNotifications = useTxNotifications(
|
||||
"NFT metadata uploaded successfully",
|
||||
"Failed to uploadMetadata NFT metadata",
|
||||
);
|
||||
|
||||
const uploadMetadataMutation = useMutation({
|
||||
mutationFn: props.uploadMetadata,
|
||||
onSuccess: uploadNotifications.onSuccess,
|
||||
onError: uploadNotifications.onError,
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
uploadMetadataMutation.mutateAsync(form.getValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
{/* Left */}
|
||||
<div className="shrink-0 lg:w-[300px]">
|
||||
<NFTMediaFormGroup form={form} previewMaxWidth="300px" />
|
||||
</div>
|
||||
|
||||
{/* Right */}
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{/* name */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PropertiesFormControl form={form} />
|
||||
|
||||
{/* Advanced options */}
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible={
|
||||
!(
|
||||
form.formState.errors.background_color ||
|
||||
form.formState.errors.external_url
|
||||
)
|
||||
}
|
||||
>
|
||||
<AccordionItem
|
||||
value="advanced-options"
|
||||
className="-mx-1 border-y"
|
||||
>
|
||||
<AccordionTrigger className="px-1">
|
||||
Advanced Options
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
<AdvancedNFTMetadataFormGroup form={form} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<TransactionButton
|
||||
size="sm"
|
||||
className="min-w-24"
|
||||
disabled={uploadMetadataMutation.isPending}
|
||||
type="submit"
|
||||
isLoading={uploadMetadataMutation.isPending}
|
||||
txChainID={props.contractChainId}
|
||||
transactionCount={1}
|
||||
colorScheme="primary"
|
||||
>
|
||||
Upload
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchMetadataNFTSection() {
|
||||
return (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>Coming soon!</AlertTitle>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export default BatchMetadataModule;
|
||||
+873
@@ -0,0 +1,873 @@
|
||||
"use client";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { DatePickerWithRange } from "@/components/ui/DatePickerWithRange";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { addDays, fromUnixTime } from "date-fns";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { CircleAlertIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import {
|
||||
type PreparedTransaction,
|
||||
ZERO_ADDRESS,
|
||||
getContract,
|
||||
sendAndConfirmTransaction,
|
||||
toTokens,
|
||||
} from "thirdweb";
|
||||
import { decimals } from "thirdweb/extensions/erc20";
|
||||
import { ClaimableERC721, ClaimableERC1155 } from "thirdweb/modules";
|
||||
import { useActiveAccount, useReadContract } from "thirdweb/react";
|
||||
import { z } from "zod";
|
||||
import { addressSchema } from "../zod-schemas";
|
||||
import { CurrencySelector } from "./CurrencySelector";
|
||||
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
|
||||
import type { ModuleInstanceProps } from "./module-instance";
|
||||
|
||||
export type ClaimConditionValue = {
|
||||
availableSupply: bigint;
|
||||
allowlistMerkleRoot: `0x${string}`;
|
||||
pricePerUnit: bigint;
|
||||
currency: string;
|
||||
maxMintPerWallet: bigint;
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
auxData: string;
|
||||
};
|
||||
|
||||
type ClaimCondition = {
|
||||
availableSupply: bigint;
|
||||
allowlistMerkleRoot: `0x${string}`;
|
||||
pricePerUnit: bigint;
|
||||
currency: string;
|
||||
maxMintPerWallet: bigint;
|
||||
startTimestamp: number;
|
||||
endTimestamp: number;
|
||||
auxData: string;
|
||||
};
|
||||
|
||||
function ClaimableModule(props: ModuleInstanceProps) {
|
||||
const { contract, ownerAccount } = props;
|
||||
const account = useActiveAccount();
|
||||
|
||||
const isErc721 = props.contractInfo.name === "ClaimableERC721";
|
||||
|
||||
const primarySaleRecipientQuery = useReadContract(
|
||||
isErc721 ? ClaimableERC721.getSaleConfig : ClaimableERC1155.getSaleConfig,
|
||||
{
|
||||
contract: contract,
|
||||
},
|
||||
);
|
||||
|
||||
const claimConditionQuery = useReadContract(
|
||||
ClaimableERC721.getClaimCondition,
|
||||
{
|
||||
contract: contract,
|
||||
queryOptions: {
|
||||
enabled: isErc721,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const getClaimConditionErc1155 = (tokenId: string) =>
|
||||
ClaimableERC1155.getClaimCondition({
|
||||
contract: contract,
|
||||
tokenId: BigInt(tokenId),
|
||||
});
|
||||
|
||||
const currencyContract = getContract({
|
||||
address: claimConditionQuery.data?.currency || "",
|
||||
chain: props.contract.chain,
|
||||
client: props.contract.client,
|
||||
});
|
||||
|
||||
const shouldFetchTokenDecimals =
|
||||
isErc721 &&
|
||||
claimConditionQuery.data &&
|
||||
claimConditionQuery.data?.currency !== ZERO_ADDRESS;
|
||||
|
||||
const tokenDecimalsQuery = useReadContract(decimals, {
|
||||
contract: currencyContract,
|
||||
queryOptions: {
|
||||
enabled: shouldFetchTokenDecimals,
|
||||
},
|
||||
});
|
||||
|
||||
const mint = useCallback(
|
||||
async (values: MintFormValues) => {
|
||||
if (!account) {
|
||||
throw new Error("Wallet not connected");
|
||||
}
|
||||
|
||||
let mintTx: PreparedTransaction;
|
||||
if (isErc721) {
|
||||
mintTx = ClaimableERC721.mint({
|
||||
contract,
|
||||
to: values.recipient,
|
||||
quantity: values.quantity,
|
||||
});
|
||||
} else if (values.tokenId) {
|
||||
mintTx = ClaimableERC1155.mint({
|
||||
contract,
|
||||
to: values.recipient,
|
||||
quantity: values.quantity,
|
||||
tokenId: BigInt(values.tokenId),
|
||||
});
|
||||
} else {
|
||||
throw new Error("Invalid tokenId");
|
||||
}
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account: account,
|
||||
transaction: mintTx,
|
||||
});
|
||||
},
|
||||
[contract, account, isErc721],
|
||||
);
|
||||
|
||||
const setClaimCondition = useCallback(
|
||||
async (values: ClaimConditionFormValues) => {
|
||||
if (!ownerAccount) {
|
||||
throw new Error("Not an owner account");
|
||||
}
|
||||
|
||||
let setClaimConditionTx: PreparedTransaction;
|
||||
if (isErc721) {
|
||||
setClaimConditionTx = ClaimableERC721.setClaimCondition({
|
||||
...values,
|
||||
contract,
|
||||
allowList:
|
||||
values.allowList && values.allowList.length > 0
|
||||
? values.allowList.map(({ address }) => address)
|
||||
: undefined,
|
||||
});
|
||||
} else if (values.tokenId) {
|
||||
setClaimConditionTx = ClaimableERC1155.setClaimCondition({
|
||||
...values,
|
||||
contract,
|
||||
allowList:
|
||||
values.allowList && values.allowList.length > 0
|
||||
? values.allowList.map(({ address }) => address)
|
||||
: undefined,
|
||||
tokenId: BigInt(values.tokenId),
|
||||
});
|
||||
} else {
|
||||
throw new Error("Invalid tokenId");
|
||||
}
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account: ownerAccount,
|
||||
transaction: setClaimConditionTx,
|
||||
});
|
||||
},
|
||||
[contract, ownerAccount, isErc721],
|
||||
);
|
||||
|
||||
const setPrimarySaleRecipient = useCallback(
|
||||
async (values: PrimarySaleRecipientFormValues) => {
|
||||
if (!ownerAccount) {
|
||||
throw new Error("Not an owner account");
|
||||
}
|
||||
const setSaleConfig = isErc721
|
||||
? ClaimableERC721.setSaleConfig
|
||||
: ClaimableERC1155.setSaleConfig;
|
||||
const setSaleConfigTx = setSaleConfig({
|
||||
contract: contract,
|
||||
primarySaleRecipient: values.primarySaleRecipient,
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account: ownerAccount,
|
||||
transaction: setSaleConfigTx,
|
||||
});
|
||||
},
|
||||
[contract, ownerAccount, isErc721],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClaimableModuleUI
|
||||
{...props}
|
||||
primarySaleRecipientSection={{
|
||||
data: primarySaleRecipientQuery.data
|
||||
? { primarySaleRecipient: primarySaleRecipientQuery.data }
|
||||
: undefined,
|
||||
setPrimarySaleRecipient,
|
||||
}}
|
||||
claimConditionSection={{
|
||||
data:
|
||||
// claim condition is common for all tokens
|
||||
isErc721 &&
|
||||
// claim conditions is fetched
|
||||
claimConditionQuery.isFetched &&
|
||||
// token decimals is fetched if it should be fetched
|
||||
(shouldFetchTokenDecimals ? tokenDecimalsQuery.isFetched : true)
|
||||
? {
|
||||
claimCondition: claimConditionQuery.data,
|
||||
tokenDecimals: tokenDecimalsQuery.data,
|
||||
}
|
||||
: undefined,
|
||||
setClaimCondition,
|
||||
getClaimConditionErc1155,
|
||||
}}
|
||||
isOwnerAccount={!!ownerAccount}
|
||||
isErc721={isErc721}
|
||||
contractChainId={props.contract.chain.id}
|
||||
mintSection={{
|
||||
mint,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClaimableModuleUI(
|
||||
props: Omit<ModuleCardUIProps, "children" | "updateButton"> & {
|
||||
isOwnerAccount: boolean;
|
||||
isErc721: boolean;
|
||||
contractChainId: number;
|
||||
primarySaleRecipientSection: {
|
||||
setPrimarySaleRecipient: (
|
||||
values: PrimarySaleRecipientFormValues,
|
||||
) => Promise<void>;
|
||||
data:
|
||||
| {
|
||||
primarySaleRecipient: string;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
mintSection: {
|
||||
mint: (values: MintFormValues) => Promise<void>;
|
||||
};
|
||||
claimConditionSection: {
|
||||
setClaimCondition: (values: ClaimConditionFormValues) => Promise<void>;
|
||||
getClaimConditionErc1155: (tokenId: string) => Promise<ClaimCondition>;
|
||||
data:
|
||||
| {
|
||||
claimCondition: ClaimConditionValue | undefined;
|
||||
tokenDecimals: number | undefined;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
},
|
||||
) {
|
||||
return (
|
||||
<ModuleCardUI {...props}>
|
||||
<div className="h-1" />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Mint NFT */}
|
||||
<Accordion type="single" collapsible className="-mx-1">
|
||||
<AccordionItem value="metadata" className="border-none">
|
||||
<AccordionTrigger className="border-border border-t px-1">
|
||||
Mint NFT
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
<MintNFTSection
|
||||
mint={props.mintSection.mint}
|
||||
isErc721={props.isErc721}
|
||||
contractChainId={props.contractChainId}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="claim-conditions" className="border-none ">
|
||||
<AccordionTrigger className="border-border border-t px-1 text-left">
|
||||
Claim Conditions
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
{!props.isErc721 || props.claimConditionSection.data ? (
|
||||
<ClaimConditionSection
|
||||
isOwnerAccount={props.isOwnerAccount}
|
||||
claimCondition={
|
||||
props.claimConditionSection.data?.claimCondition
|
||||
}
|
||||
update={props.claimConditionSection.setClaimCondition}
|
||||
isErc721={props.isErc721}
|
||||
chainId={props.contractChainId}
|
||||
tokenDecimals={
|
||||
props.claimConditionSection.data?.tokenDecimals
|
||||
}
|
||||
getClaimConditionErc1155={
|
||||
props.claimConditionSection.getClaimConditionErc1155
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-[350px]" />
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="primary-sale-recipient"
|
||||
className="border-none "
|
||||
>
|
||||
<AccordionTrigger className="border-border border-t px-1 text-left">
|
||||
Primary Sale Recipient
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
{props.primarySaleRecipientSection.data ? (
|
||||
<PrimarySaleRecipientSection
|
||||
isOwnerAccount={props.isOwnerAccount}
|
||||
primarySaleRecipient={
|
||||
props.primarySaleRecipientSection.data.primarySaleRecipient
|
||||
}
|
||||
update={
|
||||
props.primarySaleRecipientSection.setPrimarySaleRecipient
|
||||
}
|
||||
contractChainId={props.contractChainId}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-[74px]" />
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</ModuleCardUI>
|
||||
);
|
||||
}
|
||||
|
||||
const claimConditionFormSchema = z.object({
|
||||
tokenId: z.string().refine((v) => BigInt(v) >= 0n, {
|
||||
message: "Invalid tokenId",
|
||||
}),
|
||||
pricePerToken: z.coerce
|
||||
.number()
|
||||
.min(0, { message: "Invalid price per token" })
|
||||
.optional(),
|
||||
currencyAddress: z.string().optional(),
|
||||
|
||||
maxClaimableSupply: z
|
||||
.string()
|
||||
.refine((v) => v.length === 0 || Number(v) >= 0, {
|
||||
message: "Invalid max claimable supply",
|
||||
}),
|
||||
maxClaimablePerWallet: z
|
||||
.string()
|
||||
.refine((v) => v.length === 0 || Number(v) >= 0, {
|
||||
message: "Invalid max claimable per wallet",
|
||||
}),
|
||||
|
||||
startTime: z.date(),
|
||||
endTime: z.date(),
|
||||
allowList: z.array(z.object({ address: addressSchema })).optional(),
|
||||
});
|
||||
|
||||
export type ClaimConditionFormValues = z.infer<typeof claimConditionFormSchema>;
|
||||
|
||||
const MAX_UINT_256 =
|
||||
"115792089237316195423570985008687907853269984665640564039457584007913129639935";
|
||||
|
||||
const defaultStartDate = addDays(new Date(), 7);
|
||||
const defaultEndDate = addDays(new Date(), 14);
|
||||
|
||||
function ClaimConditionSection(props: {
|
||||
claimCondition?: ClaimConditionValue;
|
||||
update: (values: ClaimConditionFormValues) => Promise<void>;
|
||||
isOwnerAccount: boolean;
|
||||
isErc721: boolean;
|
||||
chainId: number;
|
||||
tokenDecimals?: number;
|
||||
getClaimConditionErc1155: (tokenId: string) => Promise<ClaimCondition>;
|
||||
}) {
|
||||
const { idToChain } = useAllChainsData();
|
||||
const chain = idToChain.get(props.chainId);
|
||||
const { claimCondition } = props;
|
||||
|
||||
const tokenIdForm = useForm<{ tokenId: string }>({
|
||||
defaultValues: {
|
||||
tokenId: "",
|
||||
},
|
||||
});
|
||||
|
||||
const tokenId = tokenIdForm.watch("tokenId");
|
||||
|
||||
const claimConditionErc1155Query = useQuery({
|
||||
queryKey: ["claimConditionErc1155", props.chainId, tokenId],
|
||||
queryFn: () => props.getClaimConditionErc1155(tokenId),
|
||||
enabled: !props.isErc721 && BigInt(tokenId) >= 0n,
|
||||
});
|
||||
|
||||
const conditions = props.isErc721
|
||||
? claimCondition
|
||||
: claimConditionErc1155Query.data;
|
||||
|
||||
const form = useForm<ClaimConditionFormValues>({
|
||||
resolver: zodResolver(claimConditionFormSchema),
|
||||
values: {
|
||||
tokenId,
|
||||
currencyAddress:
|
||||
conditions?.currency === ZERO_ADDRESS ? "" : conditions?.currency,
|
||||
pricePerToken:
|
||||
conditions?.pricePerUnit &&
|
||||
conditions?.currency !== ZERO_ADDRESS &&
|
||||
props.tokenDecimals
|
||||
? Number(toTokens(conditions?.pricePerUnit, props.tokenDecimals))
|
||||
: 0,
|
||||
maxClaimableSupply:
|
||||
conditions?.availableSupply.toString() === "0" ||
|
||||
conditions?.availableSupply.toString() === MAX_UINT_256
|
||||
? ""
|
||||
: conditions?.availableSupply.toString() || "",
|
||||
maxClaimablePerWallet:
|
||||
conditions?.maxMintPerWallet.toString() === "0" ||
|
||||
conditions?.maxMintPerWallet.toString() === MAX_UINT_256
|
||||
? ""
|
||||
: conditions?.maxMintPerWallet.toString() || "",
|
||||
startTime: conditions?.startTimestamp
|
||||
? fromUnixTime(conditions?.startTimestamp)
|
||||
: defaultStartDate,
|
||||
endTime: conditions?.endTimestamp
|
||||
? fromUnixTime(conditions?.endTimestamp)
|
||||
: defaultEndDate,
|
||||
allowList: [],
|
||||
},
|
||||
});
|
||||
|
||||
const allowListFields = useFieldArray({
|
||||
control: form.control,
|
||||
name: "allowList",
|
||||
});
|
||||
|
||||
const updateNotifications = useTxNotifications(
|
||||
"Successfully updated claim conditions",
|
||||
"Failed to update claim conditions",
|
||||
);
|
||||
|
||||
const [startTime, endTime] = form.watch(["startTime", "endTime"]);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: props.update,
|
||||
onSuccess: updateNotifications.onSuccess,
|
||||
onError: updateNotifications.onError,
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
if (!props.isErc721 && !values.tokenId) {
|
||||
form.setError("tokenId", { message: "Token ID is required" });
|
||||
return;
|
||||
}
|
||||
updateMutation.mutateAsync(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{!props.isErc721 && (
|
||||
<Form {...tokenIdForm}>
|
||||
<form>
|
||||
<FormField
|
||||
control={tokenIdForm.control}
|
||||
name="tokenId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Token ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={!props.isOwnerAccount} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
{!props.isErc721 &&
|
||||
tokenId !== "" &&
|
||||
BigInt(tokenId) >= 0n &&
|
||||
claimConditionErc1155Query.isPending && (
|
||||
<Skeleton className="h-[350px]" />
|
||||
)}
|
||||
|
||||
{(props.isErc721 ||
|
||||
(tokenId !== "" &&
|
||||
BigInt(tokenId) >= 0n &&
|
||||
!claimConditionErc1155Query.isPending)) && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pricePerToken"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Price Per Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={!props.isOwnerAccount} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currencyAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Currency</FormLabel>
|
||||
<CurrencySelector chain={chain} field={field} />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxClaimableSupply"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Max Available Supply</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={!props.isOwnerAccount} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxClaimablePerWallet"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Maximum number of mints per wallet</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={!props.isOwnerAccount} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormFieldSetup
|
||||
htmlFor="duration"
|
||||
label="Duration"
|
||||
isRequired
|
||||
errorMessage={
|
||||
form.formState.errors?.startTime?.message ||
|
||||
form.formState.errors?.endTime?.message
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<DatePickerWithRange
|
||||
from={startTime}
|
||||
to={endTime}
|
||||
setFrom={(from: Date) => form.setValue("startTime", from)}
|
||||
setTo={(to: Date) => form.setValue("endTime", to)}
|
||||
/>
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="w-full space-y-2">
|
||||
<FormLabel>Allowlist</FormLabel>
|
||||
<div className="flex flex-col gap-3">
|
||||
{allowListFields.fields.map((fieldItem, index) => (
|
||||
<div className="flex items-start gap-3" key={fieldItem.id}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`allowList.${index}.address`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="0x..."
|
||||
{...field}
|
||||
disabled={!props.isOwnerAccount}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<ToolTipLabel label="Remove address">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!text-destructive-text bg-background"
|
||||
onClick={() => {
|
||||
allowListFields.remove(index);
|
||||
}}
|
||||
disabled={!props.isOwnerAccount}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{allowListFields.fields.length === 0 && (
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon className="size-5 max-sm:hidden" />
|
||||
<AlertTitle className="max-sm:!pl-0">
|
||||
No allowlist configured
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-1" />
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// add admin by default if adding the first input
|
||||
allowListFields.append({
|
||||
address: "",
|
||||
});
|
||||
}}
|
||||
className="gap-2"
|
||||
disabled={!props.isOwnerAccount}
|
||||
>
|
||||
<PlusIcon className="size-3" />
|
||||
Add Address
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<TransactionButton
|
||||
size="sm"
|
||||
className="min-w-24"
|
||||
disabled={updateMutation.isPending || !props.isOwnerAccount}
|
||||
type="submit"
|
||||
isLoading={updateMutation.isPending}
|
||||
txChainID={props.chainId}
|
||||
transactionCount={1}
|
||||
colorScheme="primary"
|
||||
>
|
||||
Update
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>{" "}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const primarySaleRecipientFormSchema = z.object({
|
||||
primarySaleRecipient: addressSchema,
|
||||
});
|
||||
|
||||
export type PrimarySaleRecipientFormValues = z.infer<
|
||||
typeof primarySaleRecipientFormSchema
|
||||
>;
|
||||
|
||||
function PrimarySaleRecipientSection(props: {
|
||||
primarySaleRecipient: string | undefined;
|
||||
update: (values: PrimarySaleRecipientFormValues) => Promise<void>;
|
||||
isOwnerAccount: boolean;
|
||||
contractChainId: number;
|
||||
}) {
|
||||
const form = useForm<PrimarySaleRecipientFormValues>({
|
||||
resolver: zodResolver(primarySaleRecipientFormSchema),
|
||||
values: {
|
||||
primarySaleRecipient: props.primarySaleRecipient || "",
|
||||
},
|
||||
});
|
||||
|
||||
const updateNotifications = useTxNotifications(
|
||||
"Successfully update primary sale recipient",
|
||||
"Failed to update primary sale recipient",
|
||||
);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: props.update,
|
||||
onSuccess: updateNotifications.onSuccess,
|
||||
onError: updateNotifications.onError,
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
updateMutation.mutateAsync(form.getValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="primarySaleRecipient"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Sale Recipient</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="0x..."
|
||||
{...field}
|
||||
disabled={!props.isOwnerAccount}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="h-5" />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<TransactionButton
|
||||
size="sm"
|
||||
className="min-w-24 gap-2"
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
!props.isOwnerAccount ||
|
||||
!form.formState.isDirty
|
||||
}
|
||||
type="submit"
|
||||
isLoading={updateMutation.isPending}
|
||||
txChainID={props.contractChainId}
|
||||
transactionCount={1}
|
||||
colorScheme="primary"
|
||||
>
|
||||
Update
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</form>{" "}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
const mintFormSchema = z.object({
|
||||
tokenId: z.string().refine((v) => v.length === 0 || Number(v) >= 0, {
|
||||
message: "Invalid tokenId",
|
||||
}),
|
||||
quantity: z.coerce.number().min(0, { message: "Invalid quantity" }),
|
||||
recipient: addressSchema,
|
||||
});
|
||||
|
||||
export type MintFormValues = z.infer<typeof mintFormSchema>;
|
||||
|
||||
function MintNFTSection(props: {
|
||||
mint: (values: MintFormValues) => Promise<void>;
|
||||
isErc721: boolean;
|
||||
contractChainId: number;
|
||||
}) {
|
||||
const form = useForm<MintFormValues>({
|
||||
resolver: zodResolver(mintFormSchema),
|
||||
values: {
|
||||
tokenId: "",
|
||||
quantity: 0,
|
||||
recipient: "",
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const updateNotifications = useTxNotifications(
|
||||
"Successfully minted NFT",
|
||||
"Failed to mint NFT",
|
||||
);
|
||||
|
||||
const mintMutation = useMutation({
|
||||
mutationFn: props.mint,
|
||||
onSuccess: updateNotifications.onSuccess,
|
||||
onError: updateNotifications.onError,
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
if (!props.isErc721 && !values.tokenId) {
|
||||
form.setError("tokenId", { message: "Token ID is required" });
|
||||
return;
|
||||
}
|
||||
mintMutation.mutateAsync(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipient"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0x..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="quantity"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>quantity</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!props.isErc721 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Token ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<TransactionButton
|
||||
size="sm"
|
||||
className="min-w-24 gap-2"
|
||||
disabled={mintMutation.isPending}
|
||||
type="submit"
|
||||
isLoading={mintMutation.isPending}
|
||||
txChainID={props.contractChainId}
|
||||
transactionCount={1}
|
||||
colorScheme="primary"
|
||||
>
|
||||
Mint
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaimableModule;
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormControl } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CURRENCIES, type CurrencyMetadata } from "constants/currencies";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ControllerRenderProps, FieldValues } from "react-hook-form";
|
||||
import type { StoredChain } from "stores/chainStores";
|
||||
import { NATIVE_TOKEN_ADDRESS, ZERO_ADDRESS, isAddress } from "thirdweb";
|
||||
|
||||
interface CurrencySelectorProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
> {
|
||||
small?: boolean;
|
||||
hideDefaultCurrencies?: boolean;
|
||||
showCustomCurrency?: boolean;
|
||||
isPaymentsSelector?: boolean;
|
||||
defaultCurrencies?: CurrencyMetadata[];
|
||||
chain?: StoredChain;
|
||||
field: ControllerRenderProps<TFieldValues>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CurrencySelector<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
>({
|
||||
hideDefaultCurrencies,
|
||||
showCustomCurrency = true,
|
||||
isPaymentsSelector = false,
|
||||
defaultCurrencies = [],
|
||||
className,
|
||||
field,
|
||||
chain,
|
||||
}: CurrencySelectorProps<TFieldValues>) {
|
||||
const { chainId } = chain || {};
|
||||
|
||||
const helperCurrencies =
|
||||
defaultCurrencies.length > 0
|
||||
? defaultCurrencies
|
||||
: chainId
|
||||
? CURRENCIES[chainId] || []
|
||||
: [];
|
||||
|
||||
const [isAddingCurrency, setIsAddingCurrency] = useState(false);
|
||||
const [editCustomCurrency, setEditCustomCurrency] = useState("");
|
||||
const [customCurrency, setCustomCurrency] = useState("");
|
||||
const [initialValue] = useState(field.value);
|
||||
|
||||
const isCustomCurrency: boolean = useMemo(() => {
|
||||
if (initialValue && chainId && initialValue !== customCurrency) {
|
||||
if (chainId in CURRENCIES) {
|
||||
return !CURRENCIES[chainId]?.find(
|
||||
(currency: CurrencyMetadata) =>
|
||||
currency.address.toLowerCase() === initialValue.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
// for non-default chains
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [chainId, customCurrency, initialValue]);
|
||||
|
||||
const currencyOptions: CurrencyMetadata[] = [
|
||||
...(isPaymentsSelector
|
||||
? []
|
||||
: [
|
||||
{
|
||||
address: NATIVE_TOKEN_ADDRESS.toLowerCase(),
|
||||
name: chain?.nativeCurrency.name || "Native Token",
|
||||
symbol: chain?.nativeCurrency.symbol || "",
|
||||
},
|
||||
]),
|
||||
...(hideDefaultCurrencies ? [] : helperCurrencies),
|
||||
];
|
||||
|
||||
const addCustomCurrency = () => {
|
||||
if (!isAddress(editCustomCurrency)) {
|
||||
return;
|
||||
}
|
||||
if (editCustomCurrency) {
|
||||
setCustomCurrency(editCustomCurrency);
|
||||
if (field.onChange) {
|
||||
field.onChange({
|
||||
target: { value: editCustomCurrency },
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
} as any);
|
||||
}
|
||||
} else {
|
||||
setEditCustomCurrency(customCurrency);
|
||||
}
|
||||
|
||||
setIsAddingCurrency(false);
|
||||
setEditCustomCurrency("");
|
||||
};
|
||||
|
||||
if (isAddingCurrency && !hideDefaultCurrencies) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="rounded-r-none rounded-l-lg"
|
||||
onClick={() => setIsAddingCurrency(false)}
|
||||
>
|
||||
<-
|
||||
</Button>
|
||||
<Input
|
||||
{...field}
|
||||
className="w-full rounded-none"
|
||||
required
|
||||
placeholder="ERC20 Address"
|
||||
value={editCustomCurrency}
|
||||
onChange={(e) => setEditCustomCurrency(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className="rounded-r-lg rounded-l-none"
|
||||
onClick={addCustomCurrency}
|
||||
disabled={!isAddress(editCustomCurrency)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", className)}>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
value === "custom" ? setIsAddingCurrency(true) : field.onChange(value)
|
||||
}
|
||||
value={
|
||||
isPaymentsSelector
|
||||
? field.value
|
||||
: field.value?.toLowerCase() === ZERO_ADDRESS.toLowerCase()
|
||||
? NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
: field.value?.toLowerCase()
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Currency" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{chainId &&
|
||||
!hideDefaultCurrencies &&
|
||||
currencyOptions.map((currency: CurrencyMetadata, idx: number) => (
|
||||
<SelectItem
|
||||
key={`${currency.address}-${idx}`}
|
||||
value={
|
||||
isPaymentsSelector
|
||||
? currency.symbol
|
||||
: currency.address.toLowerCase()
|
||||
}
|
||||
>
|
||||
{currency.symbol} ({currency.name})
|
||||
</SelectItem>
|
||||
))}
|
||||
{isCustomCurrency &&
|
||||
!isPaymentsSelector &&
|
||||
initialValue !== NATIVE_TOKEN_ADDRESS.toLowerCase() && (
|
||||
<SelectItem key={initialValue} value={initialValue}>
|
||||
{initialValue}
|
||||
</SelectItem>
|
||||
)}
|
||||
{customCurrency && (
|
||||
<SelectItem
|
||||
key={customCurrency}
|
||||
value={customCurrency.toLowerCase()}
|
||||
>
|
||||
{customCurrency}
|
||||
</SelectItem>
|
||||
)}
|
||||
{!hideDefaultCurrencies && showCustomCurrency && (
|
||||
<SelectItem value="custom">Custom ERC20</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+7
@@ -6,6 +6,7 @@ import { CircleSlash } from "lucide-react";
|
||||
import type { ContractOptions } from "thirdweb";
|
||||
import type { Account } from "thirdweb/wallets";
|
||||
import { ModuleCard } from "./module-card";
|
||||
import { useAllModuleContractInfo } from "./moduleContractInfo";
|
||||
|
||||
export const InstalledModulesTable = (props: {
|
||||
contract: ContractOptions;
|
||||
@@ -18,6 +19,11 @@ export const InstalledModulesTable = (props: {
|
||||
}) => {
|
||||
const { installedModules, ownerAccount } = props;
|
||||
|
||||
const allModuleContractInfo = useAllModuleContractInfo(
|
||||
installedModules.data || [],
|
||||
props.contract,
|
||||
);
|
||||
|
||||
const sectionTitle = (
|
||||
<h2 className="mb-3 font-bold text-2xl tracking-tight">
|
||||
Installed Modules
|
||||
@@ -47,6 +53,7 @@ export const InstalledModulesTable = (props: {
|
||||
<ModuleCard
|
||||
key={moduleAddress}
|
||||
moduleAddress={moduleAddress}
|
||||
allModuleContractInfo={allModuleContractInfo?.data || []}
|
||||
contract={props.contract}
|
||||
onRemoveModule={props.refetchModules}
|
||||
ownerAccount={ownerAccount}
|
||||
|
||||
+531
@@ -0,0 +1,531 @@
|
||||
"use client";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertTitle } from "@/components/ui/alert";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
type PreparedTransaction,
|
||||
prepareContractCall,
|
||||
sendAndConfirmTransaction,
|
||||
} from "thirdweb";
|
||||
import { MintableERC721, MintableERC1155 } from "thirdweb/modules";
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import type { NFTMetadataInputLimited } from "types/modified-types";
|
||||
import { parseAttributes } from "utils/parseAttributes";
|
||||
import { z } from "zod";
|
||||
import { addressSchema } from "../zod-schemas";
|
||||
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
|
||||
import type { ModuleInstanceProps } from "./module-instance";
|
||||
import { AdvancedNFTMetadataFormGroup } from "./nft/AdvancedNFTMetadataFormGroup";
|
||||
import { NFTMediaFormGroup } from "./nft/NFTMediaFormGroup";
|
||||
import { PropertiesFormControl } from "./nft/PropertiesFormControl";
|
||||
|
||||
export type UpdateFormValues = {
|
||||
primarySaleRecipient: string;
|
||||
};
|
||||
|
||||
export type MintFormValues = NFTMetadataInputLimited & {
|
||||
useNextTokenId: boolean;
|
||||
recipient: string;
|
||||
amount: number;
|
||||
customImage: string;
|
||||
customAnimationUrl: string;
|
||||
attributes: { trait_type: string; value: string }[];
|
||||
tokenId?: string;
|
||||
};
|
||||
|
||||
const isValidNft = (values: MintFormValues) =>
|
||||
values.name ||
|
||||
values.description ||
|
||||
values.image ||
|
||||
values.animation_url ||
|
||||
values.external_url ||
|
||||
values.customAnimationUrl;
|
||||
|
||||
const MINTER_ROLE = 1n;
|
||||
|
||||
function MintableModule(props: ModuleInstanceProps) {
|
||||
const { contract, ownerAccount } = props;
|
||||
|
||||
const isErc721 = props.contractInfo.name === "MintableERC721";
|
||||
|
||||
const primarySaleRecipientQuery = useReadContract(
|
||||
isErc721 ? MintableERC721.getSaleConfig : MintableERC1155.getSaleConfig,
|
||||
{
|
||||
contract: contract,
|
||||
},
|
||||
);
|
||||
const hasMinterRole = useReadContract({
|
||||
contract: contract,
|
||||
method: "function hasAllRoles(address user, uint256 roles) returns (bool)",
|
||||
params: [ownerAccount?.address || "", MINTER_ROLE],
|
||||
});
|
||||
|
||||
const isBatchMetadataInstalled = !!props.allModuleContractInfo.find(
|
||||
(module) => module.name.includes("BatchMetadata"),
|
||||
);
|
||||
|
||||
const mint = useCallback(
|
||||
async (values: MintFormValues) => {
|
||||
const nft = isValidNft(values) ? parseAttributes(values) : "";
|
||||
if (!ownerAccount) {
|
||||
throw new Error("Not an owner account");
|
||||
}
|
||||
|
||||
if (!hasMinterRole.data) {
|
||||
const grantRoleTx = prepareContractCall({
|
||||
contract,
|
||||
method: "function grantRole(address user, uint256 role) public",
|
||||
params: [ownerAccount.address || "", MINTER_ROLE],
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account: ownerAccount,
|
||||
transaction: grantRoleTx,
|
||||
});
|
||||
}
|
||||
|
||||
let mintTx: PreparedTransaction;
|
||||
if (isErc721) {
|
||||
mintTx = MintableERC721.mintWithRole({
|
||||
contract,
|
||||
to: values.recipient,
|
||||
nfts: [nft],
|
||||
});
|
||||
} else if (values.useNextTokenId || values.tokenId) {
|
||||
mintTx = MintableERC1155.mintWithRole({
|
||||
contract,
|
||||
to: values.recipient,
|
||||
amount: BigInt(values.amount),
|
||||
tokenId: values.tokenId ? BigInt(values.tokenId) : undefined,
|
||||
nft,
|
||||
});
|
||||
} else {
|
||||
throw new Error("Invalid token ID");
|
||||
}
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account: ownerAccount,
|
||||
transaction: mintTx,
|
||||
});
|
||||
},
|
||||
[contract, ownerAccount, isErc721, hasMinterRole.data],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (values: UpdateFormValues) => {
|
||||
if (!ownerAccount) {
|
||||
throw new Error("Not an owner account");
|
||||
}
|
||||
|
||||
const setSaleConfig = isErc721
|
||||
? MintableERC721.setSaleConfig
|
||||
: MintableERC1155.setSaleConfig;
|
||||
const setSaleConfigTx = setSaleConfig({
|
||||
contract: contract,
|
||||
primarySaleRecipient: values.primarySaleRecipient,
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account: ownerAccount,
|
||||
transaction: setSaleConfigTx,
|
||||
});
|
||||
},
|
||||
[contract, ownerAccount, isErc721],
|
||||
);
|
||||
|
||||
return (
|
||||
<MintableModuleUI
|
||||
{...props}
|
||||
isPending={primarySaleRecipientQuery.isPending}
|
||||
primarySaleRecipient={primarySaleRecipientQuery.data}
|
||||
updatePrimaryRecipient={update}
|
||||
mint={mint}
|
||||
isOwnerAccount={!!ownerAccount}
|
||||
isErc721={isErc721}
|
||||
isBatchMetadataInstalled={isBatchMetadataInstalled}
|
||||
contractChainId={contract.chain.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MintableModuleUI(
|
||||
props: Omit<ModuleCardUIProps, "children" | "updateButton"> & {
|
||||
primarySaleRecipient: string | undefined;
|
||||
isPending: boolean;
|
||||
isOwnerAccount: boolean;
|
||||
updatePrimaryRecipient: (values: UpdateFormValues) => Promise<void>;
|
||||
mint: (values: MintFormValues) => Promise<void>;
|
||||
isErc721: boolean;
|
||||
isBatchMetadataInstalled: boolean;
|
||||
contractChainId: number;
|
||||
},
|
||||
) {
|
||||
return (
|
||||
<ModuleCardUI {...props}>
|
||||
<div className="h-1" />
|
||||
{props.isPending && <Skeleton className="h-[74px]" />}
|
||||
|
||||
{!props.isPending && (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Mint NFT */}
|
||||
<Accordion type="single" collapsible className="-mx-1">
|
||||
<AccordionItem value="metadata" className="border-none">
|
||||
<AccordionTrigger className="border-border border-t px-1">
|
||||
Mint NFT
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
{props.isOwnerAccount && (
|
||||
<MintNFTSection
|
||||
mint={props.mint}
|
||||
isErc721={props.isErc721}
|
||||
isBatchMetadataInstalled={props.isBatchMetadataInstalled}
|
||||
contractChainId={props.contractChainId}
|
||||
/>
|
||||
)}
|
||||
{!props.isOwnerAccount && (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
You don't have permission to mint NFTs on this contract
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="primary-sale-recipient"
|
||||
className="border-none "
|
||||
>
|
||||
<AccordionTrigger className="border-border border-t px-1">
|
||||
Primary Sale Recipient
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
<PrimarySalesSection
|
||||
isOwnerAccount={props.isOwnerAccount}
|
||||
primarySaleRecipient={props.primarySaleRecipient}
|
||||
update={props.updatePrimaryRecipient}
|
||||
contractChainId={props.contractChainId}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
</ModuleCardUI>
|
||||
);
|
||||
}
|
||||
|
||||
const primarySaleRecipientFormSchema = z.object({
|
||||
primarySaleRecipient: addressSchema,
|
||||
});
|
||||
|
||||
function PrimarySalesSection(props: {
|
||||
primarySaleRecipient: string | undefined;
|
||||
update: (values: UpdateFormValues) => Promise<void>;
|
||||
isOwnerAccount: boolean;
|
||||
contractChainId: number;
|
||||
}) {
|
||||
const form = useForm<UpdateFormValues>({
|
||||
resolver: zodResolver(primarySaleRecipientFormSchema),
|
||||
values: {
|
||||
primarySaleRecipient: props.primarySaleRecipient ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const updateNotifications = useTxNotifications(
|
||||
"Successfully updated primary sale recipient",
|
||||
"Failed to update primary sale recipient",
|
||||
);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: props.update,
|
||||
onSuccess: updateNotifications.onSuccess,
|
||||
onError: updateNotifications.onError,
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
updateMutation.mutateAsync(form.getValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="primarySaleRecipient"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormDescription>
|
||||
The wallet address that should receive the revenue from initial
|
||||
sales of the assets.
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="0x..."
|
||||
{...field}
|
||||
disabled={!props.isOwnerAccount}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<TransactionButton
|
||||
size="sm"
|
||||
className="min-w-24"
|
||||
disabled={updateMutation.isPending || !props.isOwnerAccount}
|
||||
type="submit"
|
||||
isLoading={updateMutation.isPending}
|
||||
colorScheme="primary"
|
||||
transactionCount={1}
|
||||
txChainID={props.contractChainId}
|
||||
>
|
||||
Update
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</form>{" "}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
const mintFormSchema = z.object({
|
||||
useNextTokenId: z.boolean(),
|
||||
customImage: z.string().optional(),
|
||||
customAnimationUrl: z.string().optional(),
|
||||
recipient: addressSchema,
|
||||
tokenId: z.coerce.number().min(0, { message: "Invalid tokenId" }).optional(),
|
||||
});
|
||||
|
||||
function MintNFTSection(props: {
|
||||
mint: (values: MintFormValues) => Promise<void>;
|
||||
isErc721: boolean;
|
||||
isBatchMetadataInstalled: boolean;
|
||||
contractChainId: number;
|
||||
}) {
|
||||
const form = useForm<MintFormValues>({
|
||||
resolver: zodResolver(mintFormSchema),
|
||||
values: {
|
||||
useNextTokenId: false,
|
||||
customImage: "",
|
||||
customAnimationUrl: "",
|
||||
recipient: "",
|
||||
attributes: [{ trait_type: "", value: "" }],
|
||||
amount: 1,
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const mintNotifications = useTxNotifications(
|
||||
"Successfully minted NFT",
|
||||
"Failed to mint NFT",
|
||||
);
|
||||
|
||||
const mintMutation = useMutation({
|
||||
mutationFn: props.mint,
|
||||
onSuccess: mintNotifications.onSuccess,
|
||||
onError: mintNotifications.onError,
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
if (!props.isErc721 && !values.useNextTokenId && !values.tokenId) {
|
||||
form.setError("tokenId", { message: "Token ID is required" });
|
||||
return;
|
||||
}
|
||||
mintMutation.mutateAsync(values);
|
||||
};
|
||||
|
||||
const useNextTokenId = form.watch("useNextTokenId");
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-6">
|
||||
{props.isBatchMetadataInstalled && (
|
||||
<div className="flex flex-col gap-6 lg:flex-row">
|
||||
{/* Left */}
|
||||
<div className="shrink-0 lg:w-[300px]">
|
||||
<NFTMediaFormGroup form={form} previewMaxWidth="300px" />
|
||||
</div>
|
||||
|
||||
{/* Right */}
|
||||
<div className="flex grow flex-col gap-6">
|
||||
{/* name */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PropertiesFormControl form={form} />
|
||||
|
||||
{/* Advanced options */}
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible={
|
||||
!(
|
||||
form.formState.errors.background_color ||
|
||||
form.formState.errors.external_url
|
||||
)
|
||||
}
|
||||
>
|
||||
<AccordionItem
|
||||
value="advanced-options"
|
||||
className="-mx-1 border-t border-b-0"
|
||||
>
|
||||
<AccordionTrigger className="px-1">
|
||||
Advanced Options
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
<AdvancedNFTMetadataFormGroup form={form} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Other options */}
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipient"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Recipient Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0x..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!props.isErc721 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!props.isErc721 && (
|
||||
<div className="relative flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Token ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={useNextTokenId} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="useNextTokenId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="absolute top-0 right-0 flex items-center gap-2">
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
className="mt-0"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
Use next token ID
|
||||
</CheckboxWithLabel>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<TransactionButton
|
||||
size="sm"
|
||||
className="min-w-24"
|
||||
disabled={mintMutation.isPending}
|
||||
type="submit"
|
||||
isLoading={mintMutation.isPending}
|
||||
colorScheme="primary"
|
||||
txChainID={props.contractChainId}
|
||||
transactionCount={1}
|
||||
>
|
||||
Mint
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default MintableModule;
|
||||
+1
@@ -61,6 +61,7 @@ export const InstallModuleForm = (props: InstallModuleFormProps) => {
|
||||
const client = useThirdwebClient();
|
||||
const form = useForm<FormData>({
|
||||
defaultValues: {
|
||||
publisherAddress: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024", // thirdweb publisher address
|
||||
version: "latest",
|
||||
},
|
||||
});
|
||||
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { sendAndConfirmTransaction } from "thirdweb";
|
||||
import { OpenEditionMetadataERC721 } from "thirdweb/modules";
|
||||
import { z } from "zod";
|
||||
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
|
||||
import type { ModuleInstanceProps } from "./module-instance";
|
||||
|
||||
const setSharedMetadataFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
imageUri: z.string().optional(),
|
||||
animationUri: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SetSharedMetadataFormValues = z.infer<
|
||||
typeof setSharedMetadataFormSchema
|
||||
>;
|
||||
|
||||
function OpenEditionMetadataModule(props: ModuleInstanceProps) {
|
||||
const { contract, ownerAccount } = props;
|
||||
|
||||
const setSharedMetadata = useCallback(
|
||||
async (values: SetSharedMetadataFormValues) => {
|
||||
if (!ownerAccount) {
|
||||
throw new Error("Not an Owner account");
|
||||
}
|
||||
|
||||
const setSharedMetadataTx = OpenEditionMetadataERC721.setSharedMetadata({
|
||||
contract,
|
||||
metadata: {
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
imageURI: values.imageUri || "",
|
||||
animationURI: values.animationUri || "",
|
||||
},
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
transaction: setSharedMetadataTx,
|
||||
account: ownerAccount,
|
||||
});
|
||||
},
|
||||
[contract, ownerAccount],
|
||||
);
|
||||
|
||||
return (
|
||||
<OpenEditionMetadataModuleUI
|
||||
{...props}
|
||||
setSharedMetadata={setSharedMetadata}
|
||||
isOwnerAccount={!!props.ownerAccount}
|
||||
contractChainId={props.contract.chain.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenEditionMetadataModuleUI(
|
||||
props: Omit<ModuleCardUIProps, "children" | "updateButton"> & {
|
||||
isOwnerAccount: boolean;
|
||||
setSharedMetadata: (values: SetSharedMetadataFormValues) => Promise<void>;
|
||||
contractChainId: number;
|
||||
},
|
||||
) {
|
||||
return (
|
||||
<ModuleCardUI {...props}>
|
||||
<div className="h-1" />
|
||||
<Accordion type="single" collapsible className="-mx-1">
|
||||
<AccordionItem value="metadata" className="border-none">
|
||||
<AccordionTrigger className="border-border border-t px-1">
|
||||
Set Shared Metadata
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
{props.isOwnerAccount && (
|
||||
<SetSharedMetadataSection
|
||||
setSharedMetadata={props.setSharedMetadata}
|
||||
contractChainId={props.contractChainId}
|
||||
/>
|
||||
)}
|
||||
{!props.isOwnerAccount && (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
You don't have permission to set shared metadata on this
|
||||
contract
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</ModuleCardUI>
|
||||
);
|
||||
}
|
||||
|
||||
function SetSharedMetadataSection(props: {
|
||||
setSharedMetadata: (values: SetSharedMetadataFormValues) => Promise<void>;
|
||||
contractChainId: number;
|
||||
}) {
|
||||
const form = useForm<SetSharedMetadataFormValues>({
|
||||
resolver: zodResolver(setSharedMetadataFormSchema),
|
||||
values: {
|
||||
name: "",
|
||||
description: "",
|
||||
imageUri: "",
|
||||
animationUri: "",
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const setSharedMetadataNotifications = useTxNotifications(
|
||||
"Successfully set shared metadata",
|
||||
"Failed to set shared metadata",
|
||||
);
|
||||
|
||||
const setSharedMetadataMutation = useMutation({
|
||||
mutationFn: props.setSharedMetadata,
|
||||
onSuccess: setSharedMetadataNotifications.onSuccess,
|
||||
onError: setSharedMetadataNotifications.onError,
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
setSharedMetadataMutation.mutateAsync(form.getValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="imageUri"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Image URI</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="animationUri"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Animation URI</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<TransactionButton
|
||||
size="sm"
|
||||
className="min-w-24"
|
||||
disabled={setSharedMetadataMutation.isPending}
|
||||
type="submit"
|
||||
isLoading={setSharedMetadataMutation.isPending}
|
||||
colorScheme="primary"
|
||||
transactionCount={1}
|
||||
txChainID={props.contractChainId}
|
||||
>
|
||||
Update
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpenEditionMetadataModule;
|
||||
+525
@@ -0,0 +1,525 @@
|
||||
"use client";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { sendAndConfirmTransaction } from "thirdweb";
|
||||
import { RoyaltyERC721, RoyaltyERC1155 } from "thirdweb/modules";
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import { z } from "zod";
|
||||
import { addressSchema } from "../zod-schemas";
|
||||
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
|
||||
import type { ModuleInstanceProps } from "./module-instance";
|
||||
|
||||
function RoyaltyModule(props: ModuleInstanceProps) {
|
||||
const { contract, ownerAccount } = props;
|
||||
|
||||
const isErc721 = props.contractInfo.name === "RoyaltyERC721";
|
||||
|
||||
const defaultRoyaltyInfoQuery = useReadContract(
|
||||
isErc721
|
||||
? RoyaltyERC721.getDefaultRoyaltyInfo
|
||||
: RoyaltyERC1155.getDefaultRoyaltyInfo,
|
||||
{
|
||||
contract: contract,
|
||||
},
|
||||
);
|
||||
const transferValidatorQuery = useReadContract(
|
||||
isErc721
|
||||
? RoyaltyERC721.getTransferValidator
|
||||
: RoyaltyERC1155.getTransferValidator,
|
||||
{
|
||||
contract: contract,
|
||||
},
|
||||
);
|
||||
|
||||
const setRoyaltyInfoForToken = useCallback(
|
||||
async (values: RoyaltyInfoFormValues) => {
|
||||
if (!ownerAccount) {
|
||||
throw new Error("Not an owner account");
|
||||
}
|
||||
|
||||
const setRoyaltyInfoForToken = isErc721
|
||||
? RoyaltyERC721.setRoyaltyInfoForToken
|
||||
: RoyaltyERC1155.setRoyaltyInfoForToken;
|
||||
const setRoyaltyForTokenTx = setRoyaltyInfoForToken({
|
||||
contract: contract,
|
||||
recipient: values.recipient,
|
||||
bps: Number(values.bps),
|
||||
tokenId: BigInt(values.tokenId),
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account: ownerAccount,
|
||||
transaction: setRoyaltyForTokenTx,
|
||||
});
|
||||
},
|
||||
[contract, ownerAccount, isErc721],
|
||||
);
|
||||
|
||||
const setTransferValidator = useCallback(
|
||||
async (values: TransferValidatorFormValues) => {
|
||||
if (!ownerAccount) {
|
||||
throw new Error("Not an owner account");
|
||||
}
|
||||
if (
|
||||
values.transferValidator &&
|
||||
values.transferValidator !== transferValidatorQuery.data
|
||||
) {
|
||||
const setTransferValidator = isErc721
|
||||
? RoyaltyERC721.setTransferValidator
|
||||
: RoyaltyERC1155.setTransferValidator;
|
||||
const setTransferValidatorTx = setTransferValidator({
|
||||
contract: contract,
|
||||
validator: values.transferValidator,
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account: ownerAccount,
|
||||
transaction: setTransferValidatorTx,
|
||||
});
|
||||
}
|
||||
},
|
||||
[contract, ownerAccount, transferValidatorQuery.data, isErc721],
|
||||
);
|
||||
|
||||
const setDefaultRoyaltyInfo = useCallback(
|
||||
async (values: DefaultRoyaltyFormValues) => {
|
||||
if (!ownerAccount) {
|
||||
throw new Error("Not an owner account");
|
||||
}
|
||||
const [defaultRoyaltyRecipient, defaultRoyaltyBps] =
|
||||
defaultRoyaltyInfoQuery.data || [];
|
||||
|
||||
if (
|
||||
values.recipient &&
|
||||
values.bps &&
|
||||
(values.recipient !== defaultRoyaltyRecipient ||
|
||||
Number(values.bps) !== defaultRoyaltyBps)
|
||||
) {
|
||||
const setDefaultRoyaltyInfo = isErc721
|
||||
? RoyaltyERC721.setDefaultRoyaltyInfo
|
||||
: RoyaltyERC1155.setDefaultRoyaltyInfo;
|
||||
|
||||
const setSaleConfigTx = setDefaultRoyaltyInfo({
|
||||
contract: contract,
|
||||
royaltyRecipient: values.recipient,
|
||||
royaltyBps: Number(values.bps),
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
account: ownerAccount,
|
||||
transaction: setSaleConfigTx,
|
||||
});
|
||||
}
|
||||
},
|
||||
[contract, ownerAccount, defaultRoyaltyInfoQuery.data, isErc721],
|
||||
);
|
||||
|
||||
return (
|
||||
<RoyaltyModuleUI
|
||||
{...props}
|
||||
isPending={
|
||||
transferValidatorQuery.isPending || defaultRoyaltyInfoQuery.isPending
|
||||
}
|
||||
transferValidator={transferValidatorQuery.data}
|
||||
defaultRoyaltyInfo={defaultRoyaltyInfoQuery.data}
|
||||
setDefaultRoyaltyInfo={setDefaultRoyaltyInfo}
|
||||
setTransferValidator={setTransferValidator}
|
||||
setRoyaltyInfoForToken={setRoyaltyInfoForToken}
|
||||
isOwnerAccount={!!ownerAccount}
|
||||
contractChainId={props.contract.chain.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoyaltyModuleUI(
|
||||
props: Omit<ModuleCardUIProps, "children" | "updateButton"> & {
|
||||
isPending: boolean;
|
||||
isOwnerAccount: boolean;
|
||||
defaultRoyaltyInfo?: readonly [string, number];
|
||||
transferValidator?: string;
|
||||
setDefaultRoyaltyInfo: (values: DefaultRoyaltyFormValues) => Promise<void>;
|
||||
setTransferValidator: (
|
||||
values: TransferValidatorFormValues,
|
||||
) => Promise<void>;
|
||||
setRoyaltyInfoForToken: (values: RoyaltyInfoFormValues) => Promise<void>;
|
||||
contractChainId: number;
|
||||
},
|
||||
) {
|
||||
return (
|
||||
<ModuleCardUI {...props}>
|
||||
<div className="h-1" />
|
||||
{props.isPending && <Skeleton className="h-[74px]" />}
|
||||
|
||||
{!props.isPending && (
|
||||
<Accordion type="single" collapsible className="-mx-1">
|
||||
<AccordionItem value="royaltyPerToken" className="border-none">
|
||||
<AccordionTrigger className="border-border border-t px-1">
|
||||
Royalty Info Per Token
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
{props.isOwnerAccount ? (
|
||||
<RoyaltyInfoPerTokenSection
|
||||
setRoyaltyInfoForToken={props.setRoyaltyInfoForToken}
|
||||
contractChainId={props.contractChainId}
|
||||
/>
|
||||
) : (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
You don't have permission to set royalty info per token
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="default-royalty-info" className="border-none">
|
||||
<AccordionTrigger className="border-border border-t px-1">
|
||||
Default Royalty Info
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
{props.isOwnerAccount ? (
|
||||
<DefaultRoyaltyInfoSection
|
||||
update={props.setDefaultRoyaltyInfo}
|
||||
defaultRoyaltyInfo={props.defaultRoyaltyInfo}
|
||||
contractChainId={props.contractChainId}
|
||||
/>
|
||||
) : (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
You don't have permission to set default royalty info
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="transfer-validator" className="border-none">
|
||||
<AccordionTrigger className="border-border border-t px-1">
|
||||
Transfer Validator
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
{props.isOwnerAccount ? (
|
||||
<TransferValidatorSection
|
||||
update={props.setTransferValidator}
|
||||
transferValidator={props.transferValidator}
|
||||
contractChainId={props.contractChainId}
|
||||
/>
|
||||
) : (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
You don't have permission to set transfer validator
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
</ModuleCardUI>
|
||||
);
|
||||
}
|
||||
|
||||
const royaltyInfoFormSchema = z.object({
|
||||
tokenId: z.string().refine((v) => v.length > 0 && Number(v) >= 0, {
|
||||
message: "Invalid tokenId",
|
||||
}),
|
||||
|
||||
recipient: addressSchema,
|
||||
bps: z
|
||||
.string()
|
||||
.min(1, { message: "Invalid BPS" })
|
||||
.refine((v) => Number(v) >= 0, { message: "Invalid BPS" }),
|
||||
});
|
||||
|
||||
export type RoyaltyInfoFormValues = z.infer<typeof royaltyInfoFormSchema>;
|
||||
|
||||
function RoyaltyInfoPerTokenSection(props: {
|
||||
setRoyaltyInfoForToken: (values: RoyaltyInfoFormValues) => Promise<void>;
|
||||
contractChainId: number;
|
||||
}) {
|
||||
const form = useForm<RoyaltyInfoFormValues>({
|
||||
resolver: zodResolver(royaltyInfoFormSchema),
|
||||
values: {
|
||||
tokenId: "",
|
||||
recipient: "",
|
||||
bps: "",
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const setRoyaltyInfoForTokenNotifications = useTxNotifications(
|
||||
"Successfully set royalty info for token",
|
||||
"Failed to set royalty info for token",
|
||||
);
|
||||
|
||||
const setRoyaltyInfoForTokenMutation = useMutation({
|
||||
mutationFn: props.setRoyaltyInfoForToken,
|
||||
onSuccess: setRoyaltyInfoForTokenNotifications.onSuccess,
|
||||
onError: setRoyaltyInfoForTokenNotifications.onError,
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
setRoyaltyInfoForTokenMutation.mutateAsync(form.getValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tokenId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Token ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipient"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Recipient</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0x..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bps"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>BPS</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<TransactionButton
|
||||
size="sm"
|
||||
className="min-w-24"
|
||||
disabled={setRoyaltyInfoForTokenMutation.isPending}
|
||||
type="submit"
|
||||
isLoading={setRoyaltyInfoForTokenMutation.isPending}
|
||||
colorScheme="primary"
|
||||
transactionCount={1}
|
||||
txChainID={props.contractChainId}
|
||||
>
|
||||
Update
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultRoyaltyFormSchema = z.object({
|
||||
recipient: addressSchema,
|
||||
bps: z.string().refine((v) => v.length === 0 || Number(v) >= 0, {
|
||||
message: "Invalid BPS",
|
||||
}),
|
||||
});
|
||||
|
||||
export type DefaultRoyaltyFormValues = z.infer<typeof defaultRoyaltyFormSchema>;
|
||||
|
||||
function DefaultRoyaltyInfoSection(props: {
|
||||
defaultRoyaltyInfo?: readonly [string, number];
|
||||
update: (values: DefaultRoyaltyFormValues) => Promise<void>;
|
||||
contractChainId: number;
|
||||
}) {
|
||||
const [defaultRoyaltyRecipient, defaultRoyaltyBps] =
|
||||
props.defaultRoyaltyInfo || [];
|
||||
|
||||
const form = useForm<DefaultRoyaltyFormValues>({
|
||||
resolver: zodResolver(defaultRoyaltyFormSchema),
|
||||
values: {
|
||||
recipient: defaultRoyaltyRecipient || "",
|
||||
bps: defaultRoyaltyBps ? String(defaultRoyaltyBps) : "",
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const updateNotifications = useTxNotifications(
|
||||
"Successfully set default royalty info",
|
||||
"Failed to update default royalty info",
|
||||
);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: props.update,
|
||||
onSuccess: updateNotifications.onSuccess,
|
||||
onError: updateNotifications.onError,
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
updateMutation.mutateAsync(form.getValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="recipient"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Default Royalty Recipient</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0x..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bps"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Default Royalty BPS</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<TransactionButton
|
||||
size="sm"
|
||||
className="min-w-24"
|
||||
disabled={updateMutation.isPending}
|
||||
type="submit"
|
||||
colorScheme="primary"
|
||||
transactionCount={1}
|
||||
isLoading={updateMutation.isPending}
|
||||
txChainID={props.contractChainId}
|
||||
>
|
||||
Update
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
const transferValidatorFormSchema = z.object({
|
||||
transferValidator: addressSchema,
|
||||
});
|
||||
|
||||
export type TransferValidatorFormValues = z.infer<
|
||||
typeof transferValidatorFormSchema
|
||||
>;
|
||||
|
||||
function TransferValidatorSection(props: {
|
||||
transferValidator: string | undefined;
|
||||
update: (values: TransferValidatorFormValues) => Promise<void>;
|
||||
contractChainId: number;
|
||||
}) {
|
||||
const form = useForm<TransferValidatorFormValues>({
|
||||
resolver: zodResolver(transferValidatorFormSchema),
|
||||
values: {
|
||||
transferValidator: props.transferValidator || "",
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const updateNotifications = useTxNotifications(
|
||||
"Successfully set transfer validator",
|
||||
"Failed to set transfer validator",
|
||||
);
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: props.update,
|
||||
onSuccess: updateNotifications.onSuccess,
|
||||
onError: updateNotifications.onError,
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
updateMutation.mutateAsync(form.getValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="h-1" />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="transferValidator"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>Transfer Validator</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0x..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="h-2" />
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<TransactionButton
|
||||
size="sm"
|
||||
className="min-w-24 gap-2"
|
||||
disabled={updateMutation.isPending}
|
||||
type="submit"
|
||||
colorScheme="primary"
|
||||
isLoading={updateMutation.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={props.contractChainId}
|
||||
>
|
||||
Update
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoyaltyModule;
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { CircleAlertIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { sendAndConfirmTransaction } from "thirdweb";
|
||||
import { TransferableERC721, TransferableERC1155 } from "thirdweb/modules";
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import { z } from "zod";
|
||||
import { addressSchema } from "../zod-schemas";
|
||||
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
|
||||
import type { ModuleInstanceProps } from "./module-instance";
|
||||
|
||||
const formSchema = z.object({
|
||||
allowList: z.array(
|
||||
z.object({
|
||||
address: addressSchema,
|
||||
}),
|
||||
),
|
||||
isRestricted: z.boolean(),
|
||||
});
|
||||
|
||||
export type TransferableModuleFormValues = z.infer<typeof formSchema>;
|
||||
|
||||
function TransferableModule(props: ModuleInstanceProps) {
|
||||
const { contract, ownerAccount } = props;
|
||||
|
||||
const isErc721 = props.contractInfo.name === "TransferableERC721";
|
||||
|
||||
const isTransferEnabledQuery = useReadContract(
|
||||
isErc721
|
||||
? TransferableERC721.isTransferEnabled
|
||||
: TransferableERC1155.isTransferEnabled,
|
||||
{
|
||||
contract,
|
||||
},
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
async (values: TransferableModuleFormValues) => {
|
||||
if (!ownerAccount) {
|
||||
throw new Error("Not an Owner account");
|
||||
}
|
||||
|
||||
// isRestricted is the opposite of transferEnabled
|
||||
const transferEnabled = !values.isRestricted;
|
||||
|
||||
if (isTransferEnabledQuery.data !== transferEnabled) {
|
||||
const setTransferable = isErc721
|
||||
? TransferableERC721.setTransferable
|
||||
: TransferableERC1155.setTransferable;
|
||||
const setTransferableTx = setTransferable({
|
||||
contract,
|
||||
enableTransfer: transferEnabled,
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
transaction: setTransferableTx,
|
||||
account: ownerAccount,
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
values.allowList.map(async ({ address }) => {
|
||||
const setTransferableFor = isErc721
|
||||
? TransferableERC721.setTransferableFor
|
||||
: TransferableERC1155.setTransferableFor;
|
||||
const setTransferableForTx = setTransferableFor({
|
||||
contract,
|
||||
enableTransfer: true,
|
||||
target: address,
|
||||
});
|
||||
|
||||
await sendAndConfirmTransaction({
|
||||
transaction: setTransferableForTx,
|
||||
account: ownerAccount,
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
[contract, ownerAccount, isTransferEnabledQuery.data, isErc721],
|
||||
);
|
||||
|
||||
return (
|
||||
<TransferableModuleUI
|
||||
{...props}
|
||||
isPending={isTransferEnabledQuery.isPending}
|
||||
isRestricted={!!isTransferEnabledQuery.data}
|
||||
adminAddress={props.ownerAccount?.address || ""}
|
||||
update={update}
|
||||
isOwnerAccount={!!props.ownerAccount}
|
||||
contractChainId={props.contract.chain.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransferableModuleUI(
|
||||
props: Omit<ModuleCardUIProps, "children" | "updateButton"> & {
|
||||
// allowList: string[]; // TODO: add this back in when we have a way to get the list of addresses
|
||||
isRestricted: boolean;
|
||||
isPending: boolean;
|
||||
adminAddress: string;
|
||||
isOwnerAccount: boolean;
|
||||
update: (values: TransferableModuleFormValues) => Promise<void>;
|
||||
contractChainId: number;
|
||||
},
|
||||
) {
|
||||
const form = useForm<TransferableModuleFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: {
|
||||
allowList: [],
|
||||
isRestricted: props.isRestricted,
|
||||
},
|
||||
reValidateMode: "onChange",
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: props.update,
|
||||
});
|
||||
|
||||
const formFields = useFieldArray({
|
||||
control: form.control,
|
||||
name: "allowList",
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
const _values = form.getValues();
|
||||
const values = { ..._values };
|
||||
|
||||
// clear the allowlist if no restrictions
|
||||
if (!_values.isRestricted) {
|
||||
values.allowList = [];
|
||||
}
|
||||
|
||||
const promise = updateMutation.mutateAsync(values);
|
||||
toast.promise(promise, {
|
||||
success: "Successfully updated transfer restrictions",
|
||||
error: (error) => `Failed to update transfer restrictions: ${error}`,
|
||||
});
|
||||
};
|
||||
|
||||
const isRestricted = form.watch("isRestricted");
|
||||
const allowListLength = form.watch("allowList").length;
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<ModuleCardUI
|
||||
{...props}
|
||||
updateButton={() => {
|
||||
return (
|
||||
<TransactionButton
|
||||
size="sm"
|
||||
className="min-w-24 gap-2"
|
||||
type="submit"
|
||||
disabled={
|
||||
props.isPending ||
|
||||
!props.isOwnerAccount ||
|
||||
!form.formState.isDirty
|
||||
}
|
||||
isLoading={updateMutation.isPending}
|
||||
colorScheme="primary"
|
||||
transactionCount={
|
||||
// if already restricted, only need to send the allowlist txs
|
||||
// else - need to send one more
|
||||
(props.isRestricted ? 0 : 1) + allowListLength
|
||||
}
|
||||
txChainID={props.contractChainId}
|
||||
>
|
||||
Update
|
||||
</TransactionButton>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{props.isPending && <Skeleton className="h-[90px]" />}
|
||||
|
||||
{!props.isPending && (
|
||||
<div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Switch */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isRestricted"
|
||||
render={({ field }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { value, ...restField } = field;
|
||||
|
||||
return (
|
||||
<FormItem className="flex items-center gap-3">
|
||||
<FormLabel>Restrict Transfers</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
{...restField}
|
||||
checked={field.value}
|
||||
disabled={!props.isOwnerAccount}
|
||||
className="!m-0"
|
||||
onCheckedChange={(v) => {
|
||||
field.onChange(v);
|
||||
|
||||
// if enabling restrictions and allowlist is empty, add the admin address by default
|
||||
if (
|
||||
v === true &&
|
||||
formFields.fields.length === 0
|
||||
) {
|
||||
formFields.append({
|
||||
address: props.adminAddress || "",
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-3" />
|
||||
|
||||
{isRestricted && (
|
||||
<div className="w-full">
|
||||
{/* Warning - TODO add later */}
|
||||
{/* {formFields.fields.length === 0 && (
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon className="size-5 max-sm:hidden" />
|
||||
<AlertTitle className="max-sm:!pl-0">
|
||||
Nobody has permission to transfer tokens on this
|
||||
contract
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)} */}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Addresses */}
|
||||
{formFields.fields.map((fieldItem, index) => (
|
||||
<div
|
||||
className="flex items-start gap-3"
|
||||
key={fieldItem.id}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`allowList.${index}.address`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="0x..."
|
||||
{...field}
|
||||
disabled={!props.isOwnerAccount}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<ToolTipLabel label="Remove address">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!text-destructive-text bg-background"
|
||||
onClick={() => {
|
||||
formFields.remove(index);
|
||||
}}
|
||||
disabled={!props.isOwnerAccount}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-3" />
|
||||
|
||||
{/* Add Addresses Actions */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// add admin by default if adding the first input
|
||||
formFields.append({
|
||||
address:
|
||||
formFields.fields.length === 0
|
||||
? props.adminAddress
|
||||
: "",
|
||||
});
|
||||
}}
|
||||
className="gap-2"
|
||||
disabled={!props.isOwnerAccount}
|
||||
>
|
||||
<PlusIcon className="size-3" />
|
||||
Add Address
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRestricted && (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5 max-sm:hidden" />
|
||||
<AlertTitle className="max-sm:!pl-0">
|
||||
Transferring tokens in this contract is not restricted.
|
||||
Everyone is free to transfer their tokens.
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ModuleCardUI>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default TransferableModule;
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { CustomConnectWallet } from "../../../../../../../@3rdweb-sdk/react/components/connect-wallet";
|
||||
import {
|
||||
ErrorProvider,
|
||||
type TransactionError,
|
||||
} from "../../../../../../../contexts/error-handler";
|
||||
import {
|
||||
BatchMetadataModuleUI,
|
||||
type UploadMetadataFormValues,
|
||||
} from "./BatchMetadata";
|
||||
|
||||
const meta = {
|
||||
title: "Modules/BatchMetadata",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Component() {
|
||||
const [isOwner, setIsOwner] = useState(true);
|
||||
|
||||
async function uploadMetadataStub(values: UploadMetadataFormValues) {
|
||||
console.log("submitting", values);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const error = new Error("Upload failed");
|
||||
(error as TransactionError).reason = "This is a test reason error";
|
||||
(error as TransactionError).info = {
|
||||
from: ZERO_ADDRESS,
|
||||
to: ZERO_ADDRESS,
|
||||
network: {
|
||||
name: "test",
|
||||
chainId: 1,
|
||||
},
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
onSuccess() {
|
||||
toast.success("Module removed successfully");
|
||||
},
|
||||
});
|
||||
|
||||
const contractInfo = {
|
||||
name: "Module Name",
|
||||
description:
|
||||
"lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore ",
|
||||
publisher: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
|
||||
version: "1.0.0",
|
||||
};
|
||||
|
||||
// TODO - remove ChakraProviderSetup after converting the TransactionButton to tailwind+shadcn
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<ChakraProviderSetup>
|
||||
<ErrorProvider>
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<div>
|
||||
<CustomConnectWallet loginRequired={false} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<CheckboxWithLabel
|
||||
value={isOwner}
|
||||
onChange={setIsOwner}
|
||||
id="isOwner"
|
||||
label="Is Owner"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BadgeContainer label="Default">
|
||||
<BatchMetadataModuleUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
uploadMetadata={uploadMetadataStub}
|
||||
uninstallButton={{
|
||||
onClick: async () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
</ErrorProvider>
|
||||
</ChakraProviderSetup>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxWithLabel(props: {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
id: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="items-top flex space-x-2">
|
||||
<Checkbox
|
||||
id={props.id}
|
||||
checked={props.value}
|
||||
onCheckedChange={(v) => props.onChange(!!v)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor={props.id}
|
||||
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{props.label}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { subDays } from "date-fns";
|
||||
import { useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { mobileViewport } from "stories/utils";
|
||||
import { NATIVE_TOKEN_ADDRESS, ZERO_ADDRESS } from "thirdweb";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { checksumAddress } from "thirdweb/utils";
|
||||
import { CustomConnectWallet } from "../../../../../../../@3rdweb-sdk/react/components/connect-wallet";
|
||||
import {
|
||||
type ClaimConditionFormValues,
|
||||
type ClaimConditionValue,
|
||||
ClaimableModuleUI,
|
||||
type MintFormValues,
|
||||
type PrimarySaleRecipientFormValues,
|
||||
} from "./Claimable";
|
||||
|
||||
const meta = {
|
||||
title: "Modules/Claimable",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const testAddress1 = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37";
|
||||
|
||||
const claimCondition = {
|
||||
availableSupply: BigInt(100),
|
||||
maxMintPerWallet: BigInt(10),
|
||||
pricePerUnit: 10n,
|
||||
// we get checksummed NATIVE_TOKEN_ADDRESS from claim condition query for native token
|
||||
currency: checksumAddress(NATIVE_TOKEN_ADDRESS),
|
||||
// last week
|
||||
startTimestamp: subDays(new Date(), 7).getTime() / 1000,
|
||||
endTimestamp: new Date().getTime() / 1000,
|
||||
allowlistMerkleRoot: ZERO_ADDRESS,
|
||||
auxData: "0x",
|
||||
} as ClaimConditionValue;
|
||||
|
||||
function Component() {
|
||||
const [isOwner, setIsOwner] = useState(true);
|
||||
const [isErc721, setIsErc721] = useState(false);
|
||||
const [isClaimConditionLoading, setIsClaimConditionLoading] = useState(false);
|
||||
const [isPrimarySaleRecipientLoading, setIsPrimarySaleRecipientLoading] =
|
||||
useState(false);
|
||||
|
||||
async function updatePrimarySaleRecipientStub(
|
||||
values: PrimarySaleRecipientFormValues,
|
||||
) {
|
||||
console.log("submitting", values);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
async function updateClaimConditionStub(values: ClaimConditionFormValues) {
|
||||
console.log("submitting", values);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
async function mintStub(values: MintFormValues) {
|
||||
console.log("submitting", values);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
async function getClaimConditionErc1155Stub() {
|
||||
console.log("claim condition in stub: ", claimCondition);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
return claimCondition;
|
||||
}
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
onSuccess() {
|
||||
toast.success("Module removed successfully");
|
||||
},
|
||||
});
|
||||
|
||||
const contractInfo = {
|
||||
name: "Module Name",
|
||||
description:
|
||||
"lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore ",
|
||||
publisher: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
|
||||
version: "1.0.0",
|
||||
};
|
||||
|
||||
// Todo - remove chakra provider after converting Transaction Button
|
||||
return (
|
||||
<ChakraProviderSetup>
|
||||
<ThirdwebProvider>
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<div>
|
||||
<CustomConnectWallet loginRequired={false} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<CheckboxWithLabel
|
||||
value={isOwner}
|
||||
onChange={setIsOwner}
|
||||
id="isOwner"
|
||||
label="Is Owner"
|
||||
/>
|
||||
|
||||
<CheckboxWithLabel
|
||||
value={isErc721}
|
||||
onChange={setIsErc721}
|
||||
id="isErc721"
|
||||
label="isErc721"
|
||||
/>
|
||||
|
||||
<CheckboxWithLabel
|
||||
value={isClaimConditionLoading}
|
||||
onChange={setIsClaimConditionLoading}
|
||||
id="isClaimConditionLoading"
|
||||
label="Claim Condition Section Loading"
|
||||
/>
|
||||
|
||||
<CheckboxWithLabel
|
||||
value={isPrimarySaleRecipientLoading}
|
||||
onChange={setIsPrimarySaleRecipientLoading}
|
||||
id="isPrimarySaleRecipientLoading"
|
||||
label="Primary Sale Recipient Section Loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ClaimableModuleUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
primarySaleRecipientSection={{
|
||||
data: isPrimarySaleRecipientLoading
|
||||
? undefined
|
||||
: {
|
||||
primarySaleRecipient: testAddress1,
|
||||
},
|
||||
setPrimarySaleRecipient: updatePrimarySaleRecipientStub,
|
||||
}}
|
||||
claimConditionSection={{
|
||||
data: isClaimConditionLoading
|
||||
? undefined
|
||||
: {
|
||||
claimCondition,
|
||||
tokenDecimals: 18,
|
||||
},
|
||||
setClaimCondition: updateClaimConditionStub,
|
||||
getClaimConditionErc1155: getClaimConditionErc1155Stub,
|
||||
}}
|
||||
mintSection={{
|
||||
mint: mintStub,
|
||||
}}
|
||||
uninstallButton={{
|
||||
onClick: async () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
isErc721={isErc721}
|
||||
contractChainId={1}
|
||||
/>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxWithLabel(props: {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
id: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="items-top flex space-x-2">
|
||||
<Checkbox
|
||||
id={props.id}
|
||||
checked={props.value}
|
||||
onCheckedChange={(v) => props.onChange(!!v)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor={props.id}
|
||||
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{props.label}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { CustomConnectWallet } from "../../../../../../../@3rdweb-sdk/react/components/connect-wallet";
|
||||
import {
|
||||
type MintFormValues,
|
||||
MintableModuleUI,
|
||||
type UpdateFormValues,
|
||||
} from "./Mintable";
|
||||
|
||||
const meta = {
|
||||
title: "Modules/Mintable",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const testAddress1 = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37";
|
||||
|
||||
function Component() {
|
||||
const [isOwner, setIsOwner] = useState(true);
|
||||
const [isErc721, setIsErc721] = useState(false);
|
||||
const [isBatchMetadataInstalled, setIsBatchMetadataInstalled] =
|
||||
useState(false);
|
||||
async function updatePrimaryRecipientStub(values: UpdateFormValues) {
|
||||
console.log("submitting", values);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
async function mintStub(values: MintFormValues) {
|
||||
console.log("submitting", values);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
onSuccess() {
|
||||
toast.success("Module removed successfully");
|
||||
},
|
||||
});
|
||||
|
||||
const contractInfo = {
|
||||
name: "Module Name",
|
||||
description:
|
||||
"lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore ",
|
||||
publisher: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
|
||||
version: "1.0.0",
|
||||
};
|
||||
|
||||
// TODO - remove ChakraProviderSetup after converting the TransactionButton to tailwind+shadcn
|
||||
|
||||
return (
|
||||
<ChakraProviderSetup>
|
||||
<ThirdwebProvider>
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<div>
|
||||
<CustomConnectWallet loginRequired={false} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<CheckboxWithLabel
|
||||
value={isOwner}
|
||||
onChange={setIsOwner}
|
||||
id="isOwner"
|
||||
label="Is Owner"
|
||||
/>
|
||||
|
||||
<CheckboxWithLabel
|
||||
value={isErc721}
|
||||
onChange={setIsErc721}
|
||||
id="isErc721"
|
||||
label="isErc721"
|
||||
/>
|
||||
|
||||
<CheckboxWithLabel
|
||||
value={isBatchMetadataInstalled}
|
||||
onChange={setIsBatchMetadataInstalled}
|
||||
id="isBatchMetadataInstalled"
|
||||
label="isBatchMetadataInstalled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BadgeContainer label="Empty Primary Sale Recipient">
|
||||
<MintableModuleUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
isPending={false}
|
||||
primarySaleRecipient={""}
|
||||
updatePrimaryRecipient={updatePrimaryRecipientStub}
|
||||
mint={mintStub}
|
||||
uninstallButton={{
|
||||
onClick: async () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
isErc721={isErc721}
|
||||
isBatchMetadataInstalled={isBatchMetadataInstalled}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Filled Primary Sale Recipient">
|
||||
<MintableModuleUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
isPending={false}
|
||||
primarySaleRecipient={testAddress1}
|
||||
updatePrimaryRecipient={updatePrimaryRecipientStub}
|
||||
mint={mintStub}
|
||||
uninstallButton={{
|
||||
onClick: () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
isErc721={isErc721}
|
||||
isBatchMetadataInstalled={isBatchMetadataInstalled}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Pending">
|
||||
<MintableModuleUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
isPending={true}
|
||||
primarySaleRecipient={testAddress1}
|
||||
updatePrimaryRecipient={updatePrimaryRecipientStub}
|
||||
mint={mintStub}
|
||||
uninstallButton={{
|
||||
onClick: () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
isErc721={isErc721}
|
||||
isBatchMetadataInstalled={isBatchMetadataInstalled}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxWithLabel(props: {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
id: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="items-top flex space-x-2">
|
||||
<Checkbox
|
||||
id={props.id}
|
||||
checked={props.value}
|
||||
onCheckedChange={(v) => props.onChange(!!v)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor={props.id}
|
||||
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{props.label}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+25
-17
@@ -1,5 +1,7 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
@@ -28,6 +30,8 @@ export const Mobile: Story = {
|
||||
};
|
||||
|
||||
function Component() {
|
||||
const [isOwner, setIsOwner] = useState(true);
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
@@ -51,7 +55,23 @@ function Component() {
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<BadgeContainer label="No Update, No Children, Owner Account">
|
||||
<div className="flex gap-2">
|
||||
<Checkbox
|
||||
id="terms1"
|
||||
checked={isOwner}
|
||||
onCheckedChange={(v) => setIsOwner(!!v)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="terms1"
|
||||
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Is Owner
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BadgeContainer label="No Update, No Children">
|
||||
<ModuleCardUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
@@ -59,23 +79,11 @@ function Component() {
|
||||
onClick: () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={true}
|
||||
isOwnerAccount={isOwner}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="No Update, No Children, Not Owner Account">
|
||||
<ModuleCardUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
uninstallButton={{
|
||||
onClick: () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Update Button (disabled), No Children, Owner">
|
||||
<BadgeContainer label="Update Button (disabled), No Children">
|
||||
<ModuleCardUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
@@ -88,7 +96,7 @@ function Component() {
|
||||
isPending: updateMutation.isPending,
|
||||
onClick: () => updateMutation.mutateAsync(),
|
||||
}}
|
||||
isOwnerAccount={true}
|
||||
isOwnerAccount={isOwner}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -105,7 +113,7 @@ function Component() {
|
||||
isPending: updateMutation.isPending,
|
||||
onClick: () => updateMutation.mutateAsync(),
|
||||
}}
|
||||
isOwnerAccount={true}
|
||||
isOwnerAccount={isOwner}
|
||||
>
|
||||
<div className="flex h-36 items-center justify-center rounded-lg bg-muted text-muted-foreground text-sm">
|
||||
CHILDREN
|
||||
|
||||
+91
-36
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
@@ -12,10 +14,12 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { ErrorBoundary, type FallbackProps } from "react-error-boundary";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type ContractOptions,
|
||||
@@ -25,16 +29,23 @@ import {
|
||||
} from "thirdweb";
|
||||
import { uninstallModuleByProxy } from "thirdweb/modules";
|
||||
import type { Account } from "thirdweb/wallets";
|
||||
import { ModuleInstance } from "./module-instance";
|
||||
import { useModuleContractInfo } from "./moduleContractInfo";
|
||||
|
||||
type ModuleProps = {
|
||||
export type ModuleCardProps = {
|
||||
moduleAddress: string;
|
||||
contract: ContractOptions;
|
||||
onRemoveModule: () => void;
|
||||
ownerAccount: Account | undefined;
|
||||
allModuleContractInfo: {
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
publisher?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function ModuleCard(props: ModuleProps) {
|
||||
export function ModuleCard(props: ModuleCardProps) {
|
||||
const { contract, moduleAddress, ownerAccount } = props;
|
||||
const [isUninstallModalOpen, setIsUninstallModalOpen] = useState(false);
|
||||
|
||||
@@ -83,27 +94,37 @@ export function ModuleCard(props: ModuleProps) {
|
||||
};
|
||||
|
||||
if (!contractInfo) {
|
||||
return <Skeleton className="h-[190px] w-full border border-border" />;
|
||||
return <GenericModuleLoadingSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModuleCardUI
|
||||
contractInfo={{
|
||||
name: contractInfo.name,
|
||||
description: contractInfo.description,
|
||||
publisher: contractInfo.publisher,
|
||||
version: contractInfo.version,
|
||||
}}
|
||||
isOwnerAccount={!!ownerAccount}
|
||||
uninstallButton={{
|
||||
onClick: () => {
|
||||
setIsUninstallModalOpen(true);
|
||||
},
|
||||
isPending: uninstallMutation.isPending,
|
||||
}}
|
||||
moduleAddress={moduleAddress}
|
||||
/>
|
||||
<Suspense fallback={<GenericModuleLoadingSkeleton />}>
|
||||
<ErrorBoundary
|
||||
FallbackComponent={(p) => (
|
||||
<ModuleErrorBoundary moduleName={contractInfo.name} {...p} />
|
||||
)}
|
||||
>
|
||||
<ModuleInstance
|
||||
contract={contract}
|
||||
contractInfo={{
|
||||
name: contractInfo.name,
|
||||
description: contractInfo.description,
|
||||
publisher: contractInfo.publisher,
|
||||
version: contractInfo.version,
|
||||
}}
|
||||
ownerAccount={ownerAccount}
|
||||
uninstallButton={{
|
||||
onClick: () => {
|
||||
setIsUninstallModalOpen(true);
|
||||
},
|
||||
isPending: uninstallMutation.isPending,
|
||||
}}
|
||||
moduleAddress={moduleAddress}
|
||||
allModuleContractInfo={props.allModuleContractInfo}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
|
||||
<Dialog
|
||||
open={isUninstallModalOpen}
|
||||
@@ -168,11 +189,13 @@ export type ModuleCardUIProps = {
|
||||
onClick: () => void;
|
||||
isPending: boolean;
|
||||
};
|
||||
updateButton?: {
|
||||
onClick: () => void;
|
||||
isPending: boolean;
|
||||
isDisabled: boolean;
|
||||
};
|
||||
updateButton?:
|
||||
| {
|
||||
onClick?: () => void;
|
||||
isPending: boolean;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
| React.FC;
|
||||
};
|
||||
|
||||
export function ModuleCardUI(props: ModuleCardUIProps) {
|
||||
@@ -270,19 +293,51 @@ export function ModuleCardUI(props: ModuleCardUIProps) {
|
||||
Uninstall
|
||||
</Button>
|
||||
|
||||
{props.isOwnerAccount && props.updateButton && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-w-24 gap-2"
|
||||
type="submit"
|
||||
onClick={props.updateButton.onClick}
|
||||
disabled={props.updateButton.isPending || !props.isOwnerAccount}
|
||||
>
|
||||
{props.updateButton.isPending && <Spinner className="size-4" />}
|
||||
Update
|
||||
</Button>
|
||||
{props.isOwnerAccount &&
|
||||
props.updateButton &&
|
||||
typeof props.updateButton !== "function" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="min-w-24 gap-2"
|
||||
type="submit"
|
||||
onClick={props.updateButton.onClick}
|
||||
disabled={props.updateButton.isPending || !props.isOwnerAccount}
|
||||
>
|
||||
{props.updateButton.isPending && <Spinner className="size-4" />}
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{props.isOwnerAccount && typeof props.updateButton === "function" && (
|
||||
<props.updateButton />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericModuleLoadingSkeleton() {
|
||||
return <Skeleton className="h-[190px] w-full border border-border" />;
|
||||
}
|
||||
|
||||
function ModuleErrorBoundary(
|
||||
props: FallbackProps & {
|
||||
moduleName: string;
|
||||
},
|
||||
) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag("component-crashed", "true");
|
||||
scope.setTag("component-crashed-boundary", "ModuleErrorBoundary");
|
||||
scope.setLevel("fatal");
|
||||
Sentry.captureException(props.error);
|
||||
});
|
||||
}, [props.error]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[190px] w-full items-center justify-center border border-border">
|
||||
Failed to render {props.moduleName} module
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { lazy } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { Account } from "thirdweb/wallets";
|
||||
import { ModuleCardUI, type ModuleCardUIProps } from "./module-card";
|
||||
|
||||
const MintableModule = lazy(() => import("./Mintable"));
|
||||
const TransferableModule = lazy(() => import("./Transferable"));
|
||||
const RoyaltyModule = lazy(() => import("./Royalty"));
|
||||
const BatchMetadataModule = lazy(() => import("./BatchMetadata"));
|
||||
const ClaimableModule = lazy(() => import("./Claimable"));
|
||||
const OpenEditionMetadataModule = lazy(() => import("./OpenEditionMetadata"));
|
||||
|
||||
export type ModuleInstanceProps = Omit<
|
||||
ModuleCardUIProps,
|
||||
"children" | "updateButton" | "isOwnerAccount"
|
||||
> & {
|
||||
contract: ThirdwebContract;
|
||||
ownerAccount: Account | undefined;
|
||||
allModuleContractInfo: {
|
||||
name: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
publisher?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function ModuleInstance(props: ModuleInstanceProps) {
|
||||
if (props.contractInfo.name.includes("Transferable")) {
|
||||
return <TransferableModule {...props} />;
|
||||
}
|
||||
|
||||
if (props.contractInfo.name.includes("Mintable-ignore")) {
|
||||
return <MintableModule {...props} />;
|
||||
}
|
||||
|
||||
if (props.contractInfo.name.includes("Royalty")) {
|
||||
return <RoyaltyModule {...props} />;
|
||||
}
|
||||
|
||||
if (props.contractInfo.name.includes("Claimable-ignore")) {
|
||||
return <ClaimableModule {...props} />;
|
||||
}
|
||||
|
||||
if (props.contractInfo.name.includes("BatchMetadata")) {
|
||||
return <BatchMetadataModule {...props} />;
|
||||
}
|
||||
|
||||
if (props.contractInfo.name.includes("OpenEditionMetadata")) {
|
||||
return <OpenEditionMetadataModule {...props} />;
|
||||
}
|
||||
|
||||
return <ModuleCardUI {...props} isOwnerAccount={!!props.ownerAccount} />;
|
||||
}
|
||||
+32
-1
@@ -1,9 +1,40 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { usePublishedContractsFromDeploy } from "components/contract-components/hooks";
|
||||
import { THIRDWEB_DEPLOYER_ADDRESS } from "constants/addresses";
|
||||
import { useMemo } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { type ThirdwebContract, getContract } from "thirdweb";
|
||||
import { getCompilerMetadata } from "thirdweb/contract";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
|
||||
async function getAllModuleContractInfo(
|
||||
installedModules: string[],
|
||||
contract: ThirdwebContract,
|
||||
) {
|
||||
return Promise.all(
|
||||
installedModules.map(async (moduleAddress) => {
|
||||
const moduleContract = getContract({
|
||||
address: moduleAddress,
|
||||
chain: contract.chain,
|
||||
client: contract.client,
|
||||
});
|
||||
const moduleMetadata = await getCompilerMetadata(moduleContract);
|
||||
|
||||
return moduleMetadata;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function useAllModuleContractInfo(
|
||||
installedModules: string[],
|
||||
contract: ThirdwebContract,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["all-module-contract-info", installedModules, contract],
|
||||
queryFn: () => getAllModuleContractInfo(installedModules || [], contract),
|
||||
enabled: !!contract,
|
||||
});
|
||||
}
|
||||
|
||||
export function useModuleContractInfo(contract: ThirdwebContract) {
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import type { NFTInput } from "thirdweb/utils";
|
||||
|
||||
type AdvancedNFTMetadataFormGroupValues = {
|
||||
customImage?: string;
|
||||
customAnimationUrl?: string;
|
||||
background_color?: NFTInput["background_color"];
|
||||
external_url?: NFTInput["external_url"];
|
||||
};
|
||||
|
||||
export function AdvancedNFTMetadataFormGroup<
|
||||
T extends AdvancedNFTMetadataFormGroupValues,
|
||||
>(props: {
|
||||
form: UseFormReturn<T>;
|
||||
}) {
|
||||
// T contains all properties of AdvancedNFTMetadataFormGroupValues, so this is a-ok
|
||||
const form =
|
||||
props.form as unknown as UseFormReturn<AdvancedNFTMetadataFormGroupValues>;
|
||||
|
||||
const externalUrl = form.watch("external_url");
|
||||
const externalIsTextFile =
|
||||
externalUrl instanceof File &&
|
||||
(externalUrl.type.includes("text") || externalUrl.type.includes("pdf"));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="background_color"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Background Color</FormLabel>
|
||||
<FormControl>
|
||||
{/* # + 6 */}
|
||||
<Input maxLength={7} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Must be a six-character hexadecimal with a pre-pended #.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customImage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Image URI</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If you already have your NFT image pre-uploaded, you can set the
|
||||
URL or URI here.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customAnimationUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Animation URI</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If you already have your NFT Animation URL preuploaded, you can
|
||||
set the URL or URI here.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{!externalIsTextFile && (
|
||||
<FormFieldSetup
|
||||
errorMessage={form.formState.errors.external_url?.message}
|
||||
htmlFor="external-url"
|
||||
isRequired={false}
|
||||
label="External URL"
|
||||
helperText="This is the URL that will appear below the asset's image on OpenSea and will allow users to leave OpenSea and view the item on your site."
|
||||
>
|
||||
<Input
|
||||
placeholder="https://"
|
||||
{...form.register("external_url")}
|
||||
type="url"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import type { NFTInput } from "thirdweb/utils";
|
||||
|
||||
type NFTMediaFormGroupValues = {
|
||||
image?: NFTInput["image"];
|
||||
animation_url?: NFTInput["animation_url"];
|
||||
external_url?: NFTInput["external_url"];
|
||||
};
|
||||
export function NFTMediaFormGroup<T extends NFTMediaFormGroupValues>(props: {
|
||||
form: UseFormReturn<T>;
|
||||
previewMaxWidth?: string;
|
||||
}) {
|
||||
// T contains all properties of NFTMediaFormGroupValues, so this is a-ok
|
||||
const form = props.form as unknown as UseFormReturn<NFTMediaFormGroupValues>;
|
||||
|
||||
const setFile = (file: File) => {
|
||||
const external_url = form.watch("external_url");
|
||||
const animation_url = form.watch("animation_url");
|
||||
|
||||
if (file.type.includes("image")) {
|
||||
form.setValue("image", file);
|
||||
if (external_url instanceof File) {
|
||||
form.setValue("external_url", undefined);
|
||||
}
|
||||
if (animation_url instanceof File) {
|
||||
form.setValue("animation_url", undefined);
|
||||
}
|
||||
} else if (
|
||||
["audio", "video", "text/html", "model/*"].some((type: string) =>
|
||||
file.type.includes(type),
|
||||
) ||
|
||||
file.name?.endsWith(".glb") ||
|
||||
file.name?.endsWith(".usdz") ||
|
||||
file.name?.endsWith(".gltf") ||
|
||||
file.name.endsWith(".obj")
|
||||
) {
|
||||
// audio, video, html, and glb (3d) files
|
||||
form.setValue("animation_url", file);
|
||||
if (external_url instanceof File) {
|
||||
form.setValue("external_url", undefined);
|
||||
}
|
||||
} else if (
|
||||
["text", "application/pdf"].some((type: string) =>
|
||||
file.type?.includes(type),
|
||||
)
|
||||
) {
|
||||
// text and pdf files
|
||||
form.setValue("external_url", file);
|
||||
if (animation_url instanceof File) {
|
||||
form.setValue("animation_url", undefined);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const external_url = form.watch("external_url");
|
||||
const animation_url = form.watch("animation_url");
|
||||
const image = form.watch("image");
|
||||
const errors = form.formState.errors;
|
||||
|
||||
const imageUrl = useImageFileOrUrl(image as File | string);
|
||||
const showCoverImageUpload =
|
||||
animation_url instanceof File || external_url instanceof File;
|
||||
|
||||
const mediaFileUrl =
|
||||
animation_url instanceof File
|
||||
? animation_url
|
||||
: external_url instanceof File
|
||||
? external_url
|
||||
: image instanceof File
|
||||
? imageUrl
|
||||
: undefined;
|
||||
|
||||
const mediaFileError =
|
||||
animation_url instanceof File
|
||||
? errors?.animation_url
|
||||
: external_url instanceof File
|
||||
? errors?.external_url
|
||||
: image instanceof File
|
||||
? errors?.image
|
||||
: undefined;
|
||||
|
||||
const previewMaxWidth = props.previewMaxWidth ?? "200px";
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<FormFieldSetup
|
||||
errorMessage={mediaFileError?.message}
|
||||
htmlFor="media"
|
||||
label="Media"
|
||||
isRequired={false}
|
||||
>
|
||||
<p className="mb-2 text-muted-foreground text-sm">
|
||||
You can upload image, audio, video, html, text, pdf, or 3d model file
|
||||
</p>
|
||||
<FileInput
|
||||
previewMaxWidth={previewMaxWidth}
|
||||
value={mediaFileUrl as File | string}
|
||||
showPreview={true}
|
||||
setValue={setFile}
|
||||
className="max-sm:!max-w-full rounded border border-border transition-all duration-200"
|
||||
selectOrUpload="Upload"
|
||||
helperText="Media"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
{showCoverImageUpload && (
|
||||
<FormFieldSetup
|
||||
errorMessage={form.formState.errors?.image?.message}
|
||||
htmlFor="cover-image"
|
||||
label="Cover Image"
|
||||
tooltip="You can optionally upload an image as the cover of your NFT."
|
||||
isRequired
|
||||
>
|
||||
<FileInput
|
||||
previewMaxWidth={previewMaxWidth}
|
||||
accept={{ "image/*": [] }}
|
||||
value={imageUrl}
|
||||
showUploadButton
|
||||
setValue={(file) => {
|
||||
form.setValue("image", file);
|
||||
}}
|
||||
className="rounded border border-border transition-all"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { type UseFormReturn, useFieldArray } from "react-hook-form";
|
||||
|
||||
type PropertiesFormValues = {
|
||||
attributes?: {
|
||||
trait_type: string;
|
||||
value: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function PropertiesFormControl<T extends PropertiesFormValues>(props: {
|
||||
form: UseFormReturn<T>;
|
||||
}) {
|
||||
// T contains all properties of PropertiesFormValues, so this correct
|
||||
const form = props.form as unknown as UseFormReturn<PropertiesFormValues>;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "attributes",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4>Attributes</h4>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Addresses */}
|
||||
{fields.map((fieldItem, index) => (
|
||||
<div className="flex items-start gap-3" key={fieldItem.id}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`attributes.${index}.trait_type`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Trait Type" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`attributes.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Value" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!text-destructive-text bg-background"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
onClick={() => append({ trait_type: "", value: "" })}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Add Attribute
|
||||
</Button>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => form.setValue("attributes", [])}
|
||||
>
|
||||
Reset
|
||||
<RotateCcwIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { CustomConnectWallet } from "../../../../../../../@3rdweb-sdk/react/components/connect-wallet";
|
||||
import {
|
||||
OpenEditionMetadataModuleUI,
|
||||
type SetSharedMetadataFormValues,
|
||||
} from "./OpenEditionMetadata";
|
||||
|
||||
const meta = {
|
||||
title: "Modules/OpenEditionMetadata",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Component() {
|
||||
const [isOwner, setIsOwner] = useState(true);
|
||||
async function setSharedMetadataStub(values: SetSharedMetadataFormValues) {
|
||||
console.log("submitting", values);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
onSuccess() {
|
||||
toast.success("Module removed successfully");
|
||||
},
|
||||
});
|
||||
|
||||
const contractInfo = {
|
||||
name: "Module Name",
|
||||
description:
|
||||
"lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore ",
|
||||
publisher: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
|
||||
version: "1.0.0",
|
||||
};
|
||||
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<ChakraProviderSetup>
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<div>
|
||||
<CustomConnectWallet loginRequired={false} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Checkbox
|
||||
id="terms1"
|
||||
checked={isOwner}
|
||||
onCheckedChange={(v) => setIsOwner(!!v)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="terms1"
|
||||
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Is Owner
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BadgeContainer label="Default">
|
||||
<OpenEditionMetadataModuleUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
setSharedMetadata={setSharedMetadataStub}
|
||||
uninstallButton={{
|
||||
onClick: async () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
</ChakraProviderSetup>
|
||||
</ThirdwebProvider>
|
||||
);
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import { ChakraProviderSetup } from "@/components/ChakraProviderSetup";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { CustomConnectWallet } from "../../../../../../../@3rdweb-sdk/react/components/connect-wallet";
|
||||
import {
|
||||
type DefaultRoyaltyFormValues,
|
||||
type RoyaltyInfoFormValues,
|
||||
RoyaltyModuleUI,
|
||||
type TransferValidatorFormValues,
|
||||
} from "./Royalty";
|
||||
|
||||
const meta = {
|
||||
title: "Modules/Royalty",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const _testAddress1 = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37";
|
||||
|
||||
function Component() {
|
||||
const [isOwner, setIsOwner] = useState(true);
|
||||
async function setRoyaltyInfoForToken(values: RoyaltyInfoFormValues) {
|
||||
console.log("submitting", values);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
async function setDefaultRoyaltyInfoStub(values: DefaultRoyaltyFormValues) {
|
||||
console.log("submitting", values);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
async function setTransferValidatorStub(values: TransferValidatorFormValues) {
|
||||
console.log("submitting", values);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
onSuccess() {
|
||||
toast.success("Module removed successfully");
|
||||
},
|
||||
});
|
||||
|
||||
const contractInfo = {
|
||||
name: "Module Name",
|
||||
description:
|
||||
"lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore ",
|
||||
publisher: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
|
||||
version: "1.0.0",
|
||||
};
|
||||
|
||||
// TODO - remove ChakraProviderSetup after converting the TransactionButton to tailwind+shadcn
|
||||
|
||||
return (
|
||||
<ChakraProviderSetup>
|
||||
<ThirdwebProvider>
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<div>
|
||||
<CustomConnectWallet loginRequired={false} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<CheckboxWithLabel
|
||||
value={isOwner}
|
||||
onChange={setIsOwner}
|
||||
id="isOwner"
|
||||
label="Is Owner"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BadgeContainer label="Empty Transfer Validator & Default Royalty Info">
|
||||
<RoyaltyModuleUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
isPending={false}
|
||||
setRoyaltyInfoForToken={setRoyaltyInfoForToken}
|
||||
setDefaultRoyaltyInfo={setDefaultRoyaltyInfoStub}
|
||||
setTransferValidator={setTransferValidatorStub}
|
||||
uninstallButton={{
|
||||
onClick: async () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Empty Transfer Validator & Non-Empty Default Royalty Info">
|
||||
<RoyaltyModuleUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
isPending={false}
|
||||
setRoyaltyInfoForToken={setRoyaltyInfoForToken}
|
||||
setDefaultRoyaltyInfo={setDefaultRoyaltyInfoStub}
|
||||
setTransferValidator={setTransferValidatorStub}
|
||||
defaultRoyaltyInfo={[_testAddress1, 100]}
|
||||
uninstallButton={{
|
||||
onClick: async () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Non-Empty Transfer Validator & Empty Default Royalty Info">
|
||||
<RoyaltyModuleUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
isPending={false}
|
||||
setRoyaltyInfoForToken={setRoyaltyInfoForToken}
|
||||
setDefaultRoyaltyInfo={setDefaultRoyaltyInfoStub}
|
||||
setTransferValidator={setTransferValidatorStub}
|
||||
transferValidator={"0x0000000000000000000000000000000000000000"}
|
||||
uninstallButton={{
|
||||
onClick: async () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Non-Empty Transfer Validator & Default Royalty Info">
|
||||
<RoyaltyModuleUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
isPending={false}
|
||||
setRoyaltyInfoForToken={setRoyaltyInfoForToken}
|
||||
setDefaultRoyaltyInfo={setDefaultRoyaltyInfoStub}
|
||||
setTransferValidator={setTransferValidatorStub}
|
||||
defaultRoyaltyInfo={[_testAddress1, 100]}
|
||||
transferValidator={"0x0000000000000000000000000000000000000000"}
|
||||
uninstallButton={{
|
||||
onClick: async () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxWithLabel(props: {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
id: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="items-top flex space-x-2">
|
||||
<Checkbox
|
||||
id={props.id}
|
||||
checked={props.value}
|
||||
onCheckedChange={(v) => props.onChange(!!v)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor={props.id}
|
||||
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{props.label}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { BadgeContainer, mobileViewport } from "stories/utils";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import { ChakraProviderSetup } from "../../../../../../../@/components/ChakraProviderSetup";
|
||||
import {
|
||||
type TransferableModuleFormValues,
|
||||
TransferableModuleUI,
|
||||
} from "./Transferable";
|
||||
|
||||
const meta = {
|
||||
title: "Modules/Transferable",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
const testAddress1 = "0x1F846F6DAE38E1C88D71EAA191760B15f38B7A37";
|
||||
|
||||
function Component() {
|
||||
const [isOwner, setIsOwner] = useState(true);
|
||||
async function updateStub(values: TransferableModuleFormValues) {
|
||||
console.log("submitting", values);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
},
|
||||
onSuccess() {
|
||||
toast.success("Module removed successfully");
|
||||
},
|
||||
});
|
||||
|
||||
const contractInfo = {
|
||||
name: "Module Name",
|
||||
description:
|
||||
"lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore ",
|
||||
publisher: "0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
|
||||
version: "1.0.0",
|
||||
};
|
||||
|
||||
return (
|
||||
<ChakraProviderSetup>
|
||||
<ThirdwebProvider>
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<div className="flex gap-2">
|
||||
<Checkbox
|
||||
id="terms1"
|
||||
checked={isOwner}
|
||||
onCheckedChange={(v) => setIsOwner(!!v)}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<label
|
||||
htmlFor="terms1"
|
||||
className="font-medium text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Is Owner
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BadgeContainer label="Empty AllowList, Not Restricted">
|
||||
<TransferableModuleUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
isPending={false}
|
||||
isRestricted={false}
|
||||
adminAddress={testAddress1}
|
||||
update={updateStub}
|
||||
uninstallButton={{
|
||||
onClick: async () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Empty AllowList, Restricted">
|
||||
<TransferableModuleUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
isPending={false}
|
||||
isRestricted={true}
|
||||
adminAddress={testAddress1}
|
||||
update={updateStub}
|
||||
uninstallButton={{
|
||||
onClick: () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Pending">
|
||||
<TransferableModuleUI
|
||||
contractInfo={contractInfo}
|
||||
moduleAddress="0x0000000000000000000000000000000000000000"
|
||||
isPending={true}
|
||||
adminAddress={testAddress1}
|
||||
isRestricted={false}
|
||||
update={updateStub}
|
||||
uninstallButton={{
|
||||
onClick: () => removeMutation.mutateAsync(),
|
||||
isPending: removeMutation.isPending,
|
||||
}}
|
||||
isOwnerAccount={isOwner}
|
||||
contractChainId={1}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
}
|
||||
+6
-4
@@ -6,18 +6,20 @@ import { ContractEditModulesPage } from "./ContractEditModulesPage";
|
||||
import { ContractEditModulesPageClient } from "./ContractEditModulesPage.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;
|
||||
|
||||
if (contract.chain.id === localhost.id) {
|
||||
return <ContractEditModulesPageClient contract={contract} />;
|
||||
}
|
||||
@@ -25,7 +27,7 @@ export default async function Page(props: {
|
||||
const { isModularCore } = await getContractPageMetadata(contract);
|
||||
|
||||
if (!isModularCore) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <ContractEditModulesPage contract={contract} />;
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { isAddress } from "thirdweb";
|
||||
import { z } from "zod";
|
||||
|
||||
export const addressSchema = z.string().refine(
|
||||
(v) => {
|
||||
// not returning the return value of isAddress directly on purpose to prevent type narrowing to `0x${string}`
|
||||
if (isAddress(v)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ message: "Invalid Address" },
|
||||
);
|
||||
|
||||
export const fileBufferOrStringSchema = z.union([
|
||||
z.string(),
|
||||
z.instanceof(File),
|
||||
z.instanceof(Uint8Array),
|
||||
z.object({
|
||||
data: z.union([z.string(), z.instanceof(Uint8Array)]),
|
||||
name: z.string(),
|
||||
}),
|
||||
]);
|
||||
+9
-11
@@ -6,41 +6,39 @@ import { TokenIdPageClient } from "./TokenIdPage.client";
|
||||
import { TokenIdPage } from "./token-id";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: {
|
||||
params: Promise<{
|
||||
contractAddress: string;
|
||||
chain_id: string;
|
||||
tokenId: string;
|
||||
};
|
||||
}>;
|
||||
}) {
|
||||
const info = await getContractPageParamsInfo(props.params);
|
||||
|
||||
const params = await props.params;
|
||||
const info = await getContractPageParamsInfo(params);
|
||||
if (!info) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
if (!isOnlyNumbers(props.params.tokenId)) {
|
||||
if (!isOnlyNumbers(params.tokenId)) {
|
||||
// redirect to nfts index page
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}/nfts`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}/nfts`);
|
||||
}
|
||||
|
||||
const { contract } = info;
|
||||
if (contract.chain.id === localhost.id) {
|
||||
return (
|
||||
<TokenIdPageClient contract={contract} tokenId={props.params.tokenId} />
|
||||
);
|
||||
return <TokenIdPageClient contract={contract} tokenId={params.tokenId} />;
|
||||
}
|
||||
|
||||
const { supportedERCs } = await getContractPageMetadata(contract);
|
||||
|
||||
if (!supportedERCs.isERC721 && !supportedERCs.isERC1155) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<TokenIdPage
|
||||
contract={contract}
|
||||
isErc721={supportedERCs.isERC721}
|
||||
tokenId={props.params.tokenId}
|
||||
tokenId={params.tokenId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
-1
@@ -144,7 +144,6 @@ export const TokenIdPage: React.FC<TokenIdPageProps> = ({
|
||||
height={isMobile ? "100%" : "300px"}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Flex flexDir="column" gap={6} w="full" px={2}>
|
||||
<Flex flexDir="column" gap={1.5}>
|
||||
<NFTName value={nft.metadata.name} />
|
||||
|
||||
+27
-21
@@ -1,10 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { MinterOnly } from "@3rdweb-sdk/react/components/roles/minter-only";
|
||||
import { useDisclosure } from "@chakra-ui/react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { Button, Drawer } from "tw-components";
|
||||
import { Button } from "tw-components";
|
||||
import { SharedMetadataForm } from "./shared-metadata-form";
|
||||
|
||||
interface NFTSharedMetadataButtonProps {
|
||||
@@ -14,26 +20,26 @@ interface NFTSharedMetadataButtonProps {
|
||||
export const NFTSharedMetadataButton: React.FC<
|
||||
NFTSharedMetadataButtonProps
|
||||
> = ({ contract, ...restButtonProps }) => {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<MinterOnly contract={contract}>
|
||||
<Drawer
|
||||
allowPinchZoom
|
||||
preserveScrollBarGap
|
||||
size="lg"
|
||||
onClose={onClose}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<SharedMetadataForm contract={contract} />
|
||||
</Drawer>
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
leftIcon={<PlusIcon className="size-5" />}
|
||||
{...restButtonProps}
|
||||
onClick={onOpen}
|
||||
>
|
||||
Set NFT Metadata
|
||||
</Button>
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
leftIcon={<PlusIcon className="size-5" />}
|
||||
{...restButtonProps}
|
||||
>
|
||||
Set NFT Metadata
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[700px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">Set NFT Metadata</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SharedMetadataForm contract={contract} setOpen={setOpen} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</MinterOnly>
|
||||
);
|
||||
};
|
||||
|
||||
+130
-135
@@ -7,20 +7,17 @@ import {
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Divider,
|
||||
DrawerBody,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
FormControl,
|
||||
Input,
|
||||
Textarea,
|
||||
useModalContext,
|
||||
} from "@chakra-ui/react";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useImageFileOrUrl } from "hooks/useImageFileOrUrl";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { setSharedMetadata } from "thirdweb/extensions/erc721";
|
||||
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
@@ -38,10 +35,11 @@ const SHARED_METADATA_FORM_ID = "shared-metadata-form";
|
||||
|
||||
export const SharedMetadataForm: React.FC<{
|
||||
contract: ThirdwebContract;
|
||||
}> = ({ contract }) => {
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}> = ({ contract, setOpen }) => {
|
||||
const trackEvent = useTrack();
|
||||
const address = useActiveAccount()?.address;
|
||||
const { mutate, isPending } = useSendAndConfirmTransaction();
|
||||
const sendAndConfirmTx = useSendAndConfirmTransaction();
|
||||
const {
|
||||
setValue,
|
||||
register,
|
||||
@@ -56,13 +54,6 @@ export const SharedMetadataForm: React.FC<{
|
||||
}
|
||||
>();
|
||||
|
||||
const modalContext = useModalContext();
|
||||
const { onSuccess, onError } = useTxNotifications(
|
||||
"NFT Metadata set successfully",
|
||||
"Failed to set NFT Metadata",
|
||||
contract,
|
||||
);
|
||||
|
||||
const setFile = (file: File) => {
|
||||
if (file.type.includes("image")) {
|
||||
// image files
|
||||
@@ -125,43 +116,39 @@ export const SharedMetadataForm: React.FC<{
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerHeader>
|
||||
<Heading>Set NFT Metadata</Heading>
|
||||
</DrawerHeader>
|
||||
<DrawerBody>
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
id={SHARED_METADATA_FORM_ID}
|
||||
onSubmit={handleSubmit((data) => {
|
||||
if (!address) {
|
||||
onError("Please connect your wallet.");
|
||||
return;
|
||||
}
|
||||
<form
|
||||
className="mt-6 flex flex-col gap-6"
|
||||
id={SHARED_METADATA_FORM_ID}
|
||||
onSubmit={handleSubmit((data) => {
|
||||
if (!address) {
|
||||
toast.error("Please connect your wallet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const dataWithCustom = {
|
||||
...data,
|
||||
image: data.image || data.customImage,
|
||||
animation_url: data.animation_url || data.customAnimationUrl,
|
||||
};
|
||||
const dataWithCustom = {
|
||||
...data,
|
||||
image: data.image || data.customImage,
|
||||
animation_url: data.animation_url || data.customAnimationUrl,
|
||||
};
|
||||
|
||||
trackEvent({
|
||||
category: "nft",
|
||||
action: "set-shared-metadata",
|
||||
label: "attempt",
|
||||
});
|
||||
trackEvent({
|
||||
category: "nft",
|
||||
action: "set-shared-metadata",
|
||||
label: "attempt",
|
||||
});
|
||||
try {
|
||||
const transaction = setSharedMetadata({
|
||||
contract,
|
||||
nft: parseAttributes(dataWithCustom),
|
||||
});
|
||||
mutate(transaction, {
|
||||
const promise = sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onSuccess: () => {
|
||||
trackEvent({
|
||||
category: "nft",
|
||||
action: "set-shared-metadata",
|
||||
label: "success",
|
||||
});
|
||||
onSuccess();
|
||||
modalContext.onClose();
|
||||
setOpen(false);
|
||||
},
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
onError: (error: any) => {
|
||||
@@ -171,118 +158,126 @@ export const SharedMetadataForm: React.FC<{
|
||||
label: "error",
|
||||
error,
|
||||
});
|
||||
onError(error);
|
||||
},
|
||||
});
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Heading size="subtitle.md">Metadata</Heading>
|
||||
<Divider />
|
||||
|
||||
toast.promise(promise, {
|
||||
loading: "Setting shared metadata",
|
||||
error: "Error setting NFT metadata",
|
||||
success: "Shared metadata updated successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Failed to set shared metadata");
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Heading size="subtitle.md">Metadata</Heading>
|
||||
<Divider />
|
||||
</div>
|
||||
<FormControl isRequired isInvalid={!!errors.name}>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input autoFocus {...register("name")} />
|
||||
<FormErrorMessage>{errors?.name?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!mediaFileError}>
|
||||
<FormLabel>Media</FormLabel>
|
||||
<div>
|
||||
<FileInput
|
||||
previewMaxWidth="200px"
|
||||
value={mediaFileUrl as File | string}
|
||||
showUploadButton
|
||||
showPreview={true}
|
||||
setValue={setFile}
|
||||
className="rounded border border-border transition-all duration-200"
|
||||
selectOrUpload="Upload"
|
||||
helperText="Media"
|
||||
/>
|
||||
</div>
|
||||
<FormControl isRequired isInvalid={!!errors.name}>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input autoFocus {...register("name")} />
|
||||
<FormErrorMessage>{errors?.name?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!mediaFileError}>
|
||||
<FormLabel>Media</FormLabel>
|
||||
<div>
|
||||
<FileInput
|
||||
previewMaxWidth="200px"
|
||||
value={mediaFileUrl as File | string}
|
||||
showUploadButton
|
||||
showPreview={true}
|
||||
setValue={setFile}
|
||||
className="rounded border border-border transition-all duration-200"
|
||||
selectOrUpload="Upload"
|
||||
helperText="Media"
|
||||
/>
|
||||
</div>
|
||||
<FormHelperText>
|
||||
You can upload image, audio, video, html, text, pdf, and 3d model
|
||||
files here.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{mediaFileError?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
{showCoverImageUpload && (
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Cover Image</FormLabel>
|
||||
<FileInput
|
||||
previewMaxWidth="200px"
|
||||
accept={{ "image/*": [] }}
|
||||
value={imageUrl}
|
||||
showUploadButton
|
||||
setValue={(file) => setValue("image", file)}
|
||||
className="rounded border border-border transition-all"
|
||||
/>
|
||||
<FormHelperText>
|
||||
You can upload image, audio, video, html, text, pdf, and 3d model
|
||||
files here.
|
||||
You can optionally upload an image as the cover of your NFT.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{mediaFileError?.message as unknown as string}
|
||||
{errors?.image?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
{showCoverImageUpload && (
|
||||
<FormControl isInvalid={!!errors.image}>
|
||||
<FormLabel>Cover Image</FormLabel>
|
||||
<FileInput
|
||||
previewMaxWidth="200px"
|
||||
accept={{ "image/*": [] }}
|
||||
value={imageUrl}
|
||||
showUploadButton
|
||||
setValue={(file) => setValue("image", file)}
|
||||
className="rounded border border-border transition-all"
|
||||
/>
|
||||
<FormHelperText>
|
||||
You can optionally upload an image as the cover of your NFT.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.image?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
<FormControl isInvalid={!!errors.description}>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea {...register("description")} />
|
||||
<FormErrorMessage>{errors?.description?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<Accordion
|
||||
allowToggle={!(errors.background_color || errors.external_url)}
|
||||
index={
|
||||
errors.background_color || errors.external_url ? [0] : undefined
|
||||
}
|
||||
>
|
||||
<AccordionItem>
|
||||
<AccordionButton px={0} justifyContent="space-between">
|
||||
<Heading size="subtitle.md">Advanced Options</Heading>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel className="flex flex-col gap-6 px-0">
|
||||
<FormControl isInvalid={!!errors.customImage}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input max="6" {...register("customImage")} />
|
||||
<FormHelperText>
|
||||
If you already have your NFT image preuploaded, you can set
|
||||
the URL or URI here.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.customImage?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!errors.customAnimationUrl}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input max="6" {...register("customAnimationUrl")} />
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL preuploaded, you
|
||||
can set the URL or URI here.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.customAnimationUrl?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</form>
|
||||
</DrawerBody>
|
||||
<DrawerFooter>
|
||||
)}
|
||||
<FormControl isInvalid={!!errors.description}>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea {...register("description")} />
|
||||
<FormErrorMessage>{errors?.description?.message}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<Accordion
|
||||
allowToggle={!(errors.background_color || errors.external_url)}
|
||||
index={
|
||||
errors.background_color || errors.external_url ? [0] : undefined
|
||||
}
|
||||
>
|
||||
<AccordionItem>
|
||||
<AccordionButton px={0} justifyContent="space-between">
|
||||
<Heading size="subtitle.md">Advanced Options</Heading>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel className="flex flex-col gap-6 px-0">
|
||||
<FormControl isInvalid={!!errors.customImage}>
|
||||
<FormLabel>Image URL</FormLabel>
|
||||
<Input max="6" {...register("customImage")} />
|
||||
<FormHelperText>
|
||||
If you already have your NFT image preuploaded, you can set
|
||||
the URL or URI here.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.customImage?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!errors.customAnimationUrl}>
|
||||
<FormLabel>Animation URL</FormLabel>
|
||||
<Input max="6" {...register("customAnimationUrl")} />
|
||||
<FormHelperText>
|
||||
If you already have your NFT Animation URL preuploaded, you
|
||||
can set the URL or URI here.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{errors?.customAnimationUrl?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</form>
|
||||
<div className="mt-8 flex flex-row justify-end gap-3">
|
||||
<Button
|
||||
isDisabled={isPending}
|
||||
isDisabled={sendAndConfirmTx.isPending}
|
||||
variant="outline"
|
||||
mr={3}
|
||||
onClick={modalContext.onClose}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TransactionButton
|
||||
txChainID={contract.chain.id}
|
||||
transactionCount={1}
|
||||
isLoading={isPending}
|
||||
isLoading={sendAndConfirmTx.isPending}
|
||||
form={SHARED_METADATA_FORM_ID}
|
||||
type="submit"
|
||||
colorScheme="primary"
|
||||
@@ -290,7 +285,7 @@ export const SharedMetadataForm: React.FC<{
|
||||
>
|
||||
Set NFT Metadata
|
||||
</TransactionButton>
|
||||
</DrawerFooter>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+29
-15
@@ -1,8 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Skeleton, Stat, StatLabel, StatNumber } from "@chakra-ui/react";
|
||||
import { useMemo } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { nextTokenIdToMint, totalSupply } from "thirdweb/extensions/erc721";
|
||||
import {
|
||||
nextTokenIdToMint,
|
||||
startTokenId,
|
||||
totalSupply,
|
||||
} from "thirdweb/extensions/erc721";
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import { Card } from "tw-components";
|
||||
|
||||
@@ -11,35 +16,44 @@ interface SupplyCardsProps {
|
||||
}
|
||||
|
||||
export const SupplyCards: React.FC<SupplyCardsProps> = ({ contract }) => {
|
||||
const claimedSupplyQuery = useReadContract(totalSupply, {
|
||||
contract,
|
||||
});
|
||||
const totalSupplyQuery = useReadContract(nextTokenIdToMint, {
|
||||
const nextTokenIdQuery = useReadContract(nextTokenIdToMint, {
|
||||
contract,
|
||||
});
|
||||
|
||||
const unclaimedSupply = (
|
||||
(totalSupplyQuery?.data || 0n) - (claimedSupplyQuery?.data || 0n)
|
||||
).toString();
|
||||
const totalSupplyQuery = useReadContract(totalSupply, {
|
||||
contract,
|
||||
});
|
||||
|
||||
const startTokenIdQuery = useReadContract(startTokenId, { contract });
|
||||
|
||||
const realTotalSupply = useMemo(
|
||||
() => (nextTokenIdQuery.data || 0n) - (startTokenIdQuery.data || 0n),
|
||||
[nextTokenIdQuery.data, startTokenIdQuery.data],
|
||||
);
|
||||
|
||||
const unclaimedSupply = useMemo(
|
||||
() => (realTotalSupply - (totalSupplyQuery?.data || 0n)).toString(),
|
||||
[realTotalSupply, totalSupplyQuery.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:gap-6">
|
||||
<Card as={Stat}>
|
||||
<StatLabel mb={{ base: 1, md: 0 }}>Total Supply</StatLabel>
|
||||
<Skeleton isLoaded={nextTokenIdQuery.isSuccess}>
|
||||
<StatNumber>{realTotalSupply.toString()}</StatNumber>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
<Card as={Stat}>
|
||||
<StatLabel mb={{ base: 1, md: 0 }}>Claimed Supply</StatLabel>
|
||||
<Skeleton isLoaded={totalSupplyQuery.isSuccess}>
|
||||
<StatNumber>{totalSupplyQuery?.data?.toString()}</StatNumber>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
<Card as={Stat}>
|
||||
<StatLabel mb={{ base: 1, md: 0 }}>Claimed Supply</StatLabel>
|
||||
<Skeleton isLoaded={claimedSupplyQuery.isSuccess}>
|
||||
<StatNumber>{claimedSupplyQuery?.data?.toString()}</StatNumber>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
<Card as={Stat}>
|
||||
<StatLabel mb={{ base: 1, md: 0 }}>Unclaimed Supply</StatLabel>
|
||||
<Skeleton
|
||||
isLoaded={totalSupplyQuery.isSuccess && claimedSupplyQuery.isSuccess}
|
||||
isLoaded={totalSupplyQuery.isSuccess && nextTokenIdQuery.isSuccess}
|
||||
>
|
||||
<StatNumber>{unclaimedSupply}</StatNumber>
|
||||
</Skeleton>
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ import { ContractNFTPage } from "./ContractNFTPage";
|
||||
import { ContractNFTPageClient } from "./ContractNFTPage.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();
|
||||
@@ -26,7 +27,7 @@ export default async function Page(props: {
|
||||
await getContractPageMetadata(contract);
|
||||
|
||||
if (!supportedERCs.isERC721 && !supportedERCs.isERC1155) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ interface PublishedByProps {
|
||||
|
||||
export const PublishedBy: React.FC<PublishedByProps> = async ({ contract }) => {
|
||||
const client = getThirdwebClient();
|
||||
const address = getAuthTokenWalletAddress();
|
||||
const address = await getAuthTokenWalletAddress();
|
||||
const props = await getPublishedByCardProps({
|
||||
address,
|
||||
contract,
|
||||
|
||||
@@ -8,12 +8,13 @@ import { PublishedBy } from "./overview/components/published-by.server";
|
||||
import { ContractOverviewPageClient } from "./overview/contract-overview-page.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();
|
||||
|
||||
+4
-3
@@ -6,12 +6,13 @@ import { ContractPermissionsPage } from "./ContractPermissionsPage";
|
||||
import { ContractPermissionsPageClient } from "./ContractPermissionsPage.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();
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ import { ContractProposalsPage } from "./ContractProposalsPage";
|
||||
import { ContractProposalsPageClient } from "./ContractProposalsPage.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();
|
||||
@@ -25,7 +26,7 @@ export default async function Page(props: {
|
||||
const { isVoteContract } = await getContractPageMetadata(contract);
|
||||
|
||||
if (!isVoteContract) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <ContractProposalsPage contract={contract} />;
|
||||
|
||||
+3
-12
@@ -10,8 +10,7 @@ import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import {
|
||||
getPlatformFeeInfo,
|
||||
setPlatformFeeInfo,
|
||||
@@ -36,11 +35,11 @@ const CommonPlatformFeeSchema = z.object({
|
||||
/**
|
||||
* platform fee basis points
|
||||
*/
|
||||
platform_fee_basis_points: BasisPointsSchema.default(0),
|
||||
platform_fee_basis_points: BasisPointsSchema,
|
||||
/**
|
||||
* platform fee recipient address
|
||||
*/
|
||||
platform_fee_recipient: AddressOrEnsSchema.default(ZERO_ADDRESS),
|
||||
platform_fee_recipient: AddressOrEnsSchema,
|
||||
});
|
||||
|
||||
export const SettingsPlatformFees = ({
|
||||
@@ -84,14 +83,6 @@ export const SettingsPlatformFees = ({
|
||||
action: "set-platform-fees",
|
||||
label: "attempt",
|
||||
});
|
||||
// In the v4 hook we defaulted recipient to address_zero and bps to `0`
|
||||
// but let's actually throw an error here for better transparency
|
||||
if (!data.platform_fee_basis_points) {
|
||||
return toast.error("Please enter valid basis points.");
|
||||
}
|
||||
if (!data.platform_fee_recipient) {
|
||||
return toast.error("Please enter a valid platform fee recipient.");
|
||||
}
|
||||
const transaction = setPlatformFeeInfo({
|
||||
contract,
|
||||
platformFeeRecipient: data.platform_fee_recipient,
|
||||
|
||||
+3
-12
@@ -10,8 +10,7 @@ import { SolidityInput } from "contract-ui/components/solidity-inputs";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useTxNotifications } from "hooks/useTxNotifications";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import {
|
||||
getDefaultRoyaltyInfo,
|
||||
setDefaultRoyaltyInfo,
|
||||
@@ -46,7 +45,7 @@ const CommonRoyaltySchema = z.object({
|
||||
* @internal
|
||||
* @remarks used by OpenSea "seller_fee_basis_points"
|
||||
*/
|
||||
seller_fee_basis_points: BasisPointsSchema.default(0),
|
||||
seller_fee_basis_points: BasisPointsSchema,
|
||||
|
||||
/**
|
||||
* The address of the royalty recipient. All royalties will be sent
|
||||
@@ -54,7 +53,7 @@ const CommonRoyaltySchema = z.object({
|
||||
* @internal
|
||||
* @remarks used by OpenSea "fee_recipient"
|
||||
*/
|
||||
fee_recipient: AddressOrEnsSchema.default(ZERO_ADDRESS),
|
||||
fee_recipient: AddressOrEnsSchema,
|
||||
});
|
||||
|
||||
export const SettingsRoyalties = ({
|
||||
@@ -97,14 +96,6 @@ export const SettingsRoyalties = ({
|
||||
action: "set-royalty",
|
||||
label: "attempt",
|
||||
});
|
||||
// In the v4 hook we defaulted recipient to address_zero and bps to `0`
|
||||
// but let's actually throw an error here for better transparency
|
||||
if (!d.seller_fee_basis_points) {
|
||||
return toast.error("Please enter valid basis points.");
|
||||
}
|
||||
if (!d.fee_recipient) {
|
||||
return toast.error("Please enter a valid royalty fee recipient.");
|
||||
}
|
||||
const transaction = setDefaultRoyaltyInfo({
|
||||
contract,
|
||||
royaltyRecipient: d.fee_recipient,
|
||||
|
||||
+4
-3
@@ -6,12 +6,13 @@ import { ContractSettingsPage } from "./ContractSettingsPage";
|
||||
import { ContractSettingsPageClient } from "./ContractSettingsPage.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();
|
||||
|
||||
+4
-3
@@ -3,12 +3,13 @@ import { getContractPageParamsInfo } from "../_utils/getContractFromParams";
|
||||
import { ContractSourcesPage } from "./ContractSourcesPage";
|
||||
|
||||
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();
|
||||
|
||||
+5
-4
@@ -6,12 +6,13 @@ import { ContractSplitPage } from "./ContractSplitPage";
|
||||
import { ContractSplitPageClient } from "./ContractSplitPage.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();
|
||||
@@ -24,7 +25,7 @@ export default async function Page(props: {
|
||||
const { isSplitSupported } = await getContractPageMetadata(contract);
|
||||
|
||||
if (!isSplitSupported) {
|
||||
redirect(`/${props.params.chain_id}/${props.params.contractAddress}`);
|
||||
redirect(`/${params.chain_id}/${params.contractAddress}`);
|
||||
}
|
||||
|
||||
return <ContractSplitPage contract={contract} />;
|
||||
|
||||
+70
-318
@@ -1,37 +1,14 @@
|
||||
import { UnorderedList } from "@/components/ui/List/List";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
IconButton,
|
||||
Link,
|
||||
Portal,
|
||||
Select,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronFirstIcon,
|
||||
ChevronLastIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CircleAlertIcon,
|
||||
UploadIcon,
|
||||
} from "lucide-react";
|
||||
import Papa from "papaparse";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { type DropzoneOptions, useDropzone } from "react-dropzone";
|
||||
import { type Column, usePagination, useTable } from "react-table";
|
||||
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
|
||||
import { resolveAddress } from "thirdweb/extensions/ens";
|
||||
import { isAddress } from "thirdweb/utils";
|
||||
import { Link } from "@chakra-ui/react";
|
||||
import { useCsvUpload } from "hooks/useCsvUpload";
|
||||
import { CircleAlertIcon, UploadIcon } from "lucide-react";
|
||||
import { useMemo, useRef } from "react";
|
||||
import type { Column } from "react-table";
|
||||
import { ZERO_ADDRESS } from "thirdweb";
|
||||
import { Button, Heading, Text } from "tw-components";
|
||||
import { csvMimeTypes } from "utils/batch";
|
||||
import { CsvDataTable } from "../../_components/csv-data-table";
|
||||
|
||||
export interface AirdropAddressInput {
|
||||
address: string;
|
||||
@@ -42,145 +19,89 @@ interface AirdropUploadProps {
|
||||
setAirdrop: (airdrop: AirdropAddressInput[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
async function checkIsAddress(
|
||||
item: AirdropAddressInput,
|
||||
thirdwebClient: ThirdwebClient,
|
||||
) {
|
||||
const { address, ...rest } = item;
|
||||
// Sending tokens to the zero address will result in an error
|
||||
// if user wants to burn, they should use the Burn tab
|
||||
if (address === ZERO_ADDRESS) {
|
||||
return {
|
||||
address,
|
||||
resolvedAddress: address,
|
||||
isValid: false,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
let isValid = true;
|
||||
let resolvedAddress = address;
|
||||
try {
|
||||
resolvedAddress = isAddress(address)
|
||||
? address
|
||||
: await resolveAddress({ name: address, client: thirdwebClient });
|
||||
isValid = !!resolvedAddress;
|
||||
} catch {
|
||||
isValid = false;
|
||||
}
|
||||
return {
|
||||
address,
|
||||
resolvedAddress,
|
||||
isValid,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
function processAirdropData(
|
||||
data: ((AirdropAddressInput & { resolvedAddress: string }) | undefined)[],
|
||||
) {
|
||||
const seen = new Set();
|
||||
const filteredData = data
|
||||
.filter((o) => o !== undefined)
|
||||
.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);
|
||||
// Make sure the invalid records are at the top so that users can see
|
||||
const ordered = [...invalid, ...valid];
|
||||
return ordered;
|
||||
}
|
||||
|
||||
const csvParser = (items: AirdropAddressInput[]): AirdropAddressInput[] => {
|
||||
return items
|
||||
.map(({ address, quantity }) => ({
|
||||
address: (address || "").trim(),
|
||||
quantity: (quantity || "1").trim(),
|
||||
}))
|
||||
.filter(({ address }) => address !== "");
|
||||
};
|
||||
|
||||
export const AirdropUpload: React.FC<AirdropUploadProps> = ({
|
||||
setAirdrop,
|
||||
onClose,
|
||||
}) => {
|
||||
const thirdwebClient = useThirdwebClient();
|
||||
const [validAirdrop, setValidAirdrop] = useState<AirdropAddressInput[]>([]);
|
||||
const [noCsv, setNoCsv] = useState(false);
|
||||
const reset = useCallback(() => {
|
||||
setValidAirdrop([]);
|
||||
setNoCsv(false);
|
||||
}, []);
|
||||
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: AirdropAddressInput[] = (
|
||||
results.data as AirdropAddressInput[]
|
||||
)
|
||||
.map(({ address, quantity }) => ({
|
||||
address: (address || "").trim(),
|
||||
quantity: (quantity || "1").trim(),
|
||||
}))
|
||||
.filter(({ address }) => address !== "");
|
||||
if (!data[0]?.address) {
|
||||
setNoCsv(true);
|
||||
return;
|
||||
}
|
||||
setValidAirdrop(data);
|
||||
},
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const normalizeQuery = useQueries({
|
||||
queries: validAirdrop.map((o) => ({
|
||||
queryKey: ["snapshot-check-isAddress", o.address],
|
||||
queryFn: () => checkIsAddress(o, thirdwebClient),
|
||||
})),
|
||||
combine: (results) => {
|
||||
return {
|
||||
data: {
|
||||
result: processAirdropData(results.map((result) => result.data)),
|
||||
invalidFound: !!results.find((o) => !o.data?.isValid),
|
||||
},
|
||||
pending: results.some((result) => result.isPending),
|
||||
};
|
||||
},
|
||||
});
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
});
|
||||
const {
|
||||
normalizeQuery,
|
||||
getInputProps,
|
||||
getRootProps,
|
||||
isDragActive,
|
||||
rawData,
|
||||
noCsv,
|
||||
reset,
|
||||
removeInvalid,
|
||||
} = useCsvUpload<AirdropAddressInput>({ csvParser });
|
||||
const paginationPortalRef = useRef<HTMLDivElement>(null);
|
||||
const onSave = () => {
|
||||
setAirdrop(normalizeQuery.data.result);
|
||||
onClose();
|
||||
};
|
||||
const removeInvalid = useCallback(() => {
|
||||
const filteredData = normalizeQuery.data?.result.filter(
|
||||
({ isValid }) => isValid,
|
||||
);
|
||||
setValidAirdrop(filteredData);
|
||||
}, [normalizeQuery.data.result]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
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: "Quantity",
|
||||
accessor: ({ quantity }: { quantity: string }) => {
|
||||
return quantity || "1";
|
||||
},
|
||||
},
|
||||
] as Column<AirdropAddressInput>[];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{normalizeQuery.pending && <div>Loading... </div>}
|
||||
{normalizeQuery.data?.result.length && validAirdrop.length > 0 ? (
|
||||
{normalizeQuery.data?.result.length && rawData.length > 0 ? (
|
||||
<>
|
||||
<AirdropTable
|
||||
<CsvDataTable<AirdropAddressInput>
|
||||
portalRef={paginationPortalRef}
|
||||
data={normalizeQuery.data.result}
|
||||
columns={columns}
|
||||
/>
|
||||
<div className="mt-4 flex flex-col justify-between md:mt-0">
|
||||
<div ref={paginationPortalRef} />
|
||||
<div className="mt-3 ml-auto flex w-full flex-row gap-2 md:w-auto">
|
||||
<Button
|
||||
borderRadius="md"
|
||||
disabled={validAirdrop.length === 0}
|
||||
disabled={rawData.length === 0}
|
||||
onClick={() => {
|
||||
reset();
|
||||
}}
|
||||
@@ -192,7 +113,7 @@ export const AirdropUpload: React.FC<AirdropUploadProps> = ({
|
||||
<Button
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
disabled={validAirdrop.length === 0}
|
||||
disabled={rawData.length === 0}
|
||||
onClick={() => {
|
||||
removeInvalid();
|
||||
}}
|
||||
@@ -206,7 +127,7 @@ export const AirdropUpload: React.FC<AirdropUploadProps> = ({
|
||||
colorScheme="primary"
|
||||
onClick={onSave}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
isDisabled={validAirdrop.length === 0}
|
||||
isDisabled={rawData.length === 0}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
@@ -271,172 +192,3 @@ export const AirdropUpload: React.FC<AirdropUploadProps> = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AirdropTableProps {
|
||||
data: AirdropAddressInput[];
|
||||
portalRef: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
const AirdropTable: React.FC<AirdropTableProps> = ({ data, portalRef }) => {
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
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: "Quantity",
|
||||
accessor: ({ quantity }: { quantity: string }) => {
|
||||
return quantity || "1";
|
||||
},
|
||||
},
|
||||
] as Column<AirdropAddressInput>[];
|
||||
}, []);
|
||||
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,
|
||||
);
|
||||
// Render the UI for your table
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
{/* 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-3
@@ -10,12 +10,13 @@ import { ContractTokensPage } from "./ContractTokensPage";
|
||||
import { ContractTokensPageClient } from "./ContractTokensPage.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();
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Page(props: { params: { chain_id: string } }) {
|
||||
redirect(`/${props.params.chain_id}`);
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ chain_id: string }>;
|
||||
}) {
|
||||
redirect(`/${(await props.params).chain_id}`);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@ import { hexToNumber, shortenAddress, toEther } from "thirdweb/utils";
|
||||
import { getChain } from "../../../utils";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: { chain_id: string; txHash: `0x${string}` };
|
||||
params: Promise<{ chain_id: string; txHash: `0x${string}` }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
// consider if we want to pass the JWT here, likely no need to do it but we could?
|
||||
const client = getThirdwebClient();
|
||||
const chain = await getChain(props.params.chain_id);
|
||||
const chain = await getChain(params.chain_id);
|
||||
|
||||
const rpcRequest = getRpcClient({
|
||||
client,
|
||||
@@ -28,10 +29,10 @@ export default async function Page(props: {
|
||||
|
||||
const [transaction, receipt] = await Promise.all([
|
||||
eth_getTransactionByHash(rpcRequest, {
|
||||
hash: props.params.txHash,
|
||||
hash: params.txHash,
|
||||
}),
|
||||
eth_getTransactionReceipt(rpcRequest, {
|
||||
hash: props.params.txHash,
|
||||
hash: params.txHash,
|
||||
}),
|
||||
]);
|
||||
if (!transaction.blockHash) {
|
||||
|
||||
@@ -27,17 +27,18 @@ import {
|
||||
type SearchParams,
|
||||
} from "../components/server/chain-table";
|
||||
|
||||
export function generateMetadata(props: {
|
||||
params: { chain_type: "mainnets" | "testnets" };
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ chain_type: "mainnets" | "testnets" }>;
|
||||
}) {
|
||||
if (props.params.chain_type === "mainnets") {
|
||||
const params = await props.params;
|
||||
if (params.chain_type === "mainnets") {
|
||||
return {
|
||||
title: "List of Mainnets | Explorers, Popular Contracts & Chain IDs",
|
||||
description:
|
||||
"A list of EVM mainnets with RPCs, smart contracts, block explorers & faucets. Deploy smart contracts to all EVM chains with thirdweb.",
|
||||
};
|
||||
}
|
||||
if (props.params.chain_type === "testnets") {
|
||||
if (params.chain_type === "testnets") {
|
||||
return {
|
||||
title: "List of Testnets | Explorers, Popular Contracts & Chain IDs",
|
||||
description:
|
||||
@@ -51,27 +52,32 @@ export function generateMetadata(props: {
|
||||
};
|
||||
}
|
||||
|
||||
// we use headers() to determine if we should default to table or grid view by checking viewport
|
||||
// so this page needs to be forced as dynamic
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ChainListLayout(props: {
|
||||
params: { chain_type: "mainnets" | "testnets" };
|
||||
searchParams: SearchParams;
|
||||
params: Promise<{ chain_type: "mainnets" | "testnets" }>;
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const headersList = headers();
|
||||
const headersList = await headers();
|
||||
const viewportWithHint = Number(
|
||||
headersList.get("Sec-Ch-Viewport-Width") || 0,
|
||||
);
|
||||
|
||||
const [params, sParams] = await Promise.all([
|
||||
props.params,
|
||||
props.searchParams,
|
||||
]);
|
||||
|
||||
const searchParams = {
|
||||
type: params.chain_type,
|
||||
...sParams,
|
||||
};
|
||||
|
||||
// default is driven by viewport hint
|
||||
const activeView = props.searchParams.view
|
||||
? props.searchParams.view
|
||||
const activeView = searchParams.view
|
||||
? searchParams.view
|
||||
: viewportWithHint > 1000
|
||||
? "table"
|
||||
: "grid";
|
||||
|
||||
const searchParams = { type: props.params.chain_type, ...props.searchParams };
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-14 border-border border-b pl-7">
|
||||
@@ -85,14 +91,12 @@ export default async function ChainListLayout(props: {
|
||||
<DropdownMenu>
|
||||
<Link
|
||||
href={
|
||||
props.params.chain_type === "testnets"
|
||||
params.chain_type === "testnets"
|
||||
? "/chainlist/testnets"
|
||||
: "/chainlist/mainnets"
|
||||
}
|
||||
>
|
||||
{props.params.chain_type === "testnets"
|
||||
? "Testnets"
|
||||
: "Mainnets"}
|
||||
{params.chain_type === "testnets" ? "Testnets" : "Mainnets"}
|
||||
</Link>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1">
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
@@ -116,8 +120,8 @@ export default async function ChainListLayout(props: {
|
||||
<div className="flex flex-row items-center justify-between gap-4 lg:flex-col lg:justify-start">
|
||||
<h1 className="font-semibold text-4xl tracking-tighter lg:text-5xl">
|
||||
List of{" "}
|
||||
{props.params.chain_type.charAt(0).toUpperCase() +
|
||||
props.params.chain_type.slice(1)}
|
||||
{params.chain_type.charAt(0).toUpperCase() +
|
||||
params.chain_type.slice(1)}
|
||||
</h1>
|
||||
<AddYourChainButton className="lg:hidden" />
|
||||
</div>
|
||||
|
||||
+2
@@ -6,6 +6,8 @@ import { ChainIcon } from "../../../components/server/chain-icon";
|
||||
import type { ChainSupportedService } from "../../../types/chain";
|
||||
import { getChainMetadata } from "../../../utils";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
type ChainListCardProps = {
|
||||
favoriteButton: JSX.Element;
|
||||
chainId: number;
|
||||
|
||||
+2
@@ -14,6 +14,8 @@ import { products } from "../../../components/server/products";
|
||||
import type { ChainSupportedService } from "../../../types/chain";
|
||||
import { getChainMetadata } from "../../../utils";
|
||||
|
||||
import type { JSX } from "react";
|
||||
|
||||
type ChainListRowProps = {
|
||||
favoriteButton: JSX.Element;
|
||||
chainId: number;
|
||||
|
||||
@@ -17,19 +17,18 @@ export const metadata: Metadata = {
|
||||
"A list of EVM networks with RPCs, smart contracts, block explorers & faucets. Deploy smart contracts to all EVM chains with thirdweb.",
|
||||
};
|
||||
|
||||
// we use headers() to determine if we should default to table or grid view by checking viewport
|
||||
// so this page needs to be forced as dynamic
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function ChainListPage(props: { searchParams: SearchParams }) {
|
||||
const headersList = headers();
|
||||
export default async function ChainListPage(props: {
|
||||
searchParams: Promise<SearchParams>;
|
||||
}) {
|
||||
const headersList = await headers();
|
||||
const viewportWithHint = Number(
|
||||
headersList.get("Sec-Ch-Viewport-Width") || 0,
|
||||
);
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
// default is driven by viewport hint
|
||||
const activeView = props.searchParams.view
|
||||
? props.searchParams.view
|
||||
const activeView = searchParams.view
|
||||
? searchParams.view
|
||||
: viewportWithHint > 1000
|
||||
? "table"
|
||||
: "grid";
|
||||
@@ -64,7 +63,7 @@ export default function ChainListPage(props: { searchParams: SearchParams }) {
|
||||
</header>
|
||||
<div className="h-10" />
|
||||
{/* we used to have suspense + spinner here, that feels more jarring than the page loading _minutely_ slower */}
|
||||
<ChainsData searchParams={props.searchParams} activeView={activeView} />
|
||||
<ChainsData searchParams={searchParams} activeView={activeView} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import { DeployContractInfo } from "../../../published-contract/components/contr
|
||||
import { DeployFormForUri } from "../../../published-contract/components/uri-based-deploy";
|
||||
|
||||
type DirectDeployPageProps = {
|
||||
params: {
|
||||
params: Promise<{
|
||||
compiler_uri: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function DirectDeployPage(props: DirectDeployPageProps) {
|
||||
const parsedUri = decodeURIComponent(props.params.compiler_uri);
|
||||
const params = await props.params;
|
||||
const parsedUri = decodeURIComponent(params.compiler_uri);
|
||||
const metadata = await fetchDeployMetadata({
|
||||
client: getThirdwebClient(),
|
||||
// force `ipfs://` prefix
|
||||
@@ -27,7 +28,7 @@ export default async function DirectDeployPage(props: DirectDeployPageProps) {
|
||||
<DeployFormForUri
|
||||
contractMetadata={metadata}
|
||||
modules={null}
|
||||
pathname={`/contracts/deploy/${props.params.compiler_uri}`}
|
||||
pathname={`/contracts/deploy/${params.compiler_uri}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,15 +8,16 @@ import { fetchDeployMetadata } from "thirdweb/contract";
|
||||
import { getPublishedContractsWithPublisherMapping } from "../../../published-contract/[publisher]/[contract_id]/utils/getPublishedContractsWithPublisherMapping";
|
||||
|
||||
type DirectDeployPageProps = {
|
||||
params: {
|
||||
params: Promise<{
|
||||
publish_uri: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function PublishContractPage(
|
||||
props: DirectDeployPageProps,
|
||||
) {
|
||||
const decodedPublishUri = decodeURIComponent(props.params.publish_uri);
|
||||
const params = await props.params;
|
||||
const decodedPublishUri = decodeURIComponent(params.publish_uri);
|
||||
const publishUri = decodedPublishUri.startsWith("ipfs://")
|
||||
? decodedPublishUri
|
||||
: `ipfs://${decodedPublishUri}`;
|
||||
@@ -28,9 +29,9 @@ export default async function PublishContractPage(
|
||||
|
||||
let publishMetadata = publishMetadataFromUri;
|
||||
|
||||
const pathname = `/contracts/publish/${props.params.publish_uri}`;
|
||||
const pathname = `/contracts/publish/${params.publish_uri}`;
|
||||
|
||||
const address = getActiveAccountCookie();
|
||||
const address = await getActiveAccountCookie();
|
||||
if (!address) {
|
||||
redirect(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
}
|
||||
@@ -62,7 +63,7 @@ export default async function PublishContractPage(
|
||||
}
|
||||
}
|
||||
|
||||
const token = getJWTCookie(address);
|
||||
const token = await getJWTCookie(address);
|
||||
if (!token) {
|
||||
redirect(`/login?next=${encodeURIComponent(pathname)}`);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user