Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfea3b39ee | ||
|
|
d35b6fc1fe | ||
|
|
bcb3b92397 | ||
|
|
be803185aa | ||
|
|
ea247dbd4c | ||
|
|
e8470fcce1 | ||
|
|
a6483f4a80 | ||
|
|
2e901123c4 | ||
|
|
5c5b910570 | ||
|
|
6b9f29d43c | ||
|
|
fad7cb92b7 | ||
|
|
03617047e2 | ||
|
|
04c7243994 | ||
|
|
66cba857dc | ||
|
|
0426fe5c6e | ||
|
|
542360cc69 | ||
|
|
5e883ac550 | ||
|
|
c717eaadae | ||
|
|
691b7e1454 | ||
|
|
243749b08c | ||
|
|
4f38198ae1 | ||
|
|
b371bf9630 | ||
|
|
cf401bee84 | ||
|
|
c178d3ab2b | ||
|
|
e92d8f921a | ||
|
|
1cbbbc3c12 | ||
|
|
58dbe90b93 | ||
|
|
0c6d75dfc6 | ||
|
|
11d9f3e513 | ||
|
|
c7fab9f5dd | ||
|
|
882ed8348b | ||
|
|
0386638cbd | ||
|
|
a00b7fa425 | ||
|
|
b33073875f | ||
|
|
8ddf37861c | ||
|
|
34067c05d0 | ||
|
|
67abd0db5b | ||
|
|
6e2f535bfa | ||
|
|
430115cf2b | ||
|
|
80c6b6a793 | ||
|
|
eb2d49c4e6 | ||
|
|
2996121187 | ||
|
|
cd6fd9a75d | ||
|
|
12c42c0311 | ||
|
|
5e11d63e11 | ||
|
|
1d6e98dafd | ||
|
|
c077f78e3a | ||
|
|
6b7983f47c | ||
|
|
859290f6c7 | ||
|
|
adec6be844 | ||
|
|
c80eed3552 | ||
|
|
0c7051b999 | ||
|
|
3c8e444fe6 | ||
|
|
367a25ca5a | ||
|
|
c0ffa1c871 | ||
|
|
36731096b9 | ||
|
|
1607229b21 | ||
|
|
75bf09126f | ||
|
|
9d8c7f1f7d | ||
|
|
9dc8f5a637 | ||
|
|
48361f92b7 | ||
|
|
1659ae3754 | ||
|
|
88d44aff50 | ||
|
|
c63b3497bd | ||
|
|
74a21635e4 | ||
|
|
66b9bf3c6b | ||
|
|
cf22c23b45 | ||
|
|
170b020e17 | ||
|
|
8260af3a92 | ||
|
|
b9154cc9e7 | ||
|
|
08955f1588 | ||
|
|
756295241a | ||
|
|
249679481e | ||
|
|
a491de4d7c | ||
|
|
898efc56a9 | ||
|
|
c0e7ae95de | ||
|
|
1e8190a1f9 |
@@ -15,37 +15,150 @@
|
||||
|
||||
<p align="center"><strong>All-in-one web3 SDK for Browser, Node and Mobile apps</strong></p>
|
||||
|
||||
## Features
|
||||
## Core Package
|
||||
|
||||
- Support for React & React-Native
|
||||
- First party support for [Embedded Wallets](https://portal.thirdweb.com/connect/embedded-wallet/overview) (social/email login)
|
||||
- First party support for [Account Abstraction](https://portal.thirdweb.com/connect/account-abstraction/overview)
|
||||
- Instant connection to any chain with RPC Edge integration
|
||||
- Integrated IPFS upload/download
|
||||
- UI Components for connection, transactions, nft rendering
|
||||
- High level contract extensions for interacting with common standards
|
||||
#### [`thirdweb`](./packages/thirdweb/README.md)
|
||||
|
||||
The main SDK package providing all-in-one web3 functionality for Browser, Node, and Mobile applications.
|
||||
|
||||
```bash
|
||||
npm install thirdweb
|
||||
```
|
||||
|
||||
**Features:**
|
||||
|
||||
- Type-safe contract and transaction APIs
|
||||
- In-app wallets with social/email login
|
||||
- Account abstraction (ERC4337/EIP7702) support
|
||||
- 500+ external wallets supported
|
||||
- Built in infra (RPC, bundler, paymaster, indexer)
|
||||
- React hooks and UI components
|
||||
- Automatic ABI resolution
|
||||
- IPFS upload/download
|
||||
- Cross-platform support (Web, React Native)
|
||||
|
||||
## Library Comparison
|
||||
### Documentation
|
||||
|
||||
| | thirdweb | Wagmi + Viem | Ethers@6 |
|
||||
| ----------------------------------------- | -------- | ------------------ | -------- |
|
||||
| Type safe contract API | ✅ | ✅ | ✅ |
|
||||
| Type safe wallet API | ✅ | ✅ | ✅ |
|
||||
| EVM utils | ✅ | ✅ | ✅ |
|
||||
| RPC for any EVM | ✅ | ⚠️ public RPC only | ❌ |
|
||||
| Automatic ABI Resolution | ✅ | ❌ | ❌ |
|
||||
| IPFS Upload/Download | ✅ | ❌ | ❌ |
|
||||
| Embedded wallet (email/ social login) | ✅ | ⚠️ via 3rd party | ❌ |
|
||||
| Account abstraction (ERC4337) support | ✅ | ⚠️ via 3rd party | ❌ |
|
||||
| Web3 wallet connectors | ✅ | ✅ | ❌ |
|
||||
| Local wallet creation | ✅ | ✅ | ✅ |
|
||||
| Auth (SIWE) | ✅ | ✅ | ❌ |
|
||||
| Extensions functions for common standards | ✅ | ✅ | ❌ |
|
||||
| React Hooks | ✅ | ✅ | ❌ |
|
||||
| React UI components | ✅ | ❌ | ❌ |
|
||||
| React Native Hooks | ✅ | ✅ | ❌ |
|
||||
| React Native UI Components | ✅ | ❌ | ❌ |
|
||||
Visit the [developer portal](https://portal.thirdweb.com) for full documentation.
|
||||
|
||||
### 🚀 Quick Start
|
||||
|
||||
#### For React Applications
|
||||
|
||||
```bash
|
||||
npm install thirdweb
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import { ConnectButton, useActiveAccount } from "thirdweb/react";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
clientId: "YOUR_CLIENT_ID",
|
||||
});
|
||||
|
||||
function App() {
|
||||
const account = useActiveAccount();
|
||||
console.log("Connected as", account?.address);
|
||||
|
||||
return <ConnectButton client={client} />;
|
||||
}
|
||||
```
|
||||
|
||||
For React Native Applications, you'll also need to install the `@thirdweb-dev/react-native-adapter` package and import it at app startup for polyfills.
|
||||
|
||||
#### For Backend Applications
|
||||
|
||||
```bash
|
||||
npm install thirdweb
|
||||
```
|
||||
|
||||
```typescript
|
||||
import { createThirdwebClient, Engine } from "thirdweb";
|
||||
|
||||
const client = createThirdwebClient({
|
||||
secretKey: "YOUR_SECRET_KEY",
|
||||
});
|
||||
|
||||
const wallet = Engine.serverWallet({
|
||||
client,
|
||||
address: "0x...",
|
||||
});
|
||||
|
||||
const transaction = transfer({
|
||||
contract: getContract({
|
||||
client,
|
||||
address: "0x...", // token contract
|
||||
chain: defineChain(1),
|
||||
}),
|
||||
to: "0x...", // recipient
|
||||
amount: "0.01", // amount in tokens
|
||||
});
|
||||
|
||||
await wallet.enqueueTransaction({
|
||||
transaction,
|
||||
});
|
||||
```
|
||||
|
||||
## Adapters
|
||||
|
||||
#### [`@thirdweb-dev/react-native-adapter`](./packages/react-native-adapter/README.md)
|
||||
|
||||
Required polyfills and configuration for running the thirdweb SDK in React Native applications.
|
||||
|
||||
```bash
|
||||
npm install @thirdweb-dev/react-native-adapter
|
||||
```
|
||||
|
||||
#### [`@thirdweb-dev/wagmi-adapter`](./packages/wagmi-adapter/README.md)
|
||||
|
||||
Integration layer for using thirdweb's in-app wallets with wagmi.
|
||||
|
||||
```bash
|
||||
npm install @thirdweb-dev/wagmi-adapter
|
||||
```
|
||||
|
||||
## Type safe API wrappers
|
||||
|
||||
#### [`@thirdweb-dev/api`](./packages/api/README.md)
|
||||
|
||||
TypeScript SDK for thirdweb's API, combining all of thirdweb products.
|
||||
|
||||
```bash
|
||||
npm install @thirdweb-dev/api
|
||||
```
|
||||
|
||||
#### [`@thirdweb-dev/engine`](./packages/engine/README.md)
|
||||
|
||||
TypeScript SDK for Engine, thirdweb's backend onchain executor service.
|
||||
|
||||
```bash
|
||||
npm install @thirdweb-dev/engine
|
||||
```
|
||||
|
||||
#### [`@thirdweb-dev/insight`](./packages/insight/README.md)
|
||||
|
||||
TypeScript SDK for Insight, thirdweb's multichain indexer service.
|
||||
|
||||
```bash
|
||||
npm install @thirdweb-dev/insight
|
||||
```
|
||||
|
||||
#### [`@thirdweb-dev/vault-sdk`](./packages/vault-sdk/README.md)
|
||||
|
||||
SDK for interacting with Vault, thirdweb's secure key management service.
|
||||
|
||||
```bash
|
||||
npm install @thirdweb-dev/vault-sdk
|
||||
```
|
||||
|
||||
#### [`@thirdweb-dev/nebula`](./packages/nebula/README.md)
|
||||
|
||||
TypeScript SDK for Nebula, thirdweb's AI agent service.
|
||||
|
||||
```bash
|
||||
npm install @thirdweb-dev/nebula
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -55,7 +168,8 @@ See our [open source page](https://thirdweb.com/open-source) for more informatio
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Documentation](https://portal.thirdweb.com/typescript/v5)
|
||||
- [Dashboard](https://thirdweb.com/login)
|
||||
- [Documentation](https://portal.thirdweb.com/)
|
||||
- [Templates](https://thirdweb.com/templates)
|
||||
- [YouTube](https://www.youtube.com/c/thirdweb)
|
||||
|
||||
|
||||
@@ -10,13 +10,6 @@ module.exports = {
|
||||
"plugin:storybook/recommended",
|
||||
],
|
||||
overrides: [
|
||||
// disable restricted imports in chakra
|
||||
{
|
||||
files: "src/chakra/**/*",
|
||||
rules: {
|
||||
"no-restricted-imports": ["off"],
|
||||
},
|
||||
},
|
||||
// allow direct PostHog imports inside analytics helpers
|
||||
{
|
||||
files: "src/@/analytics/**/*",
|
||||
@@ -91,60 +84,6 @@ module.exports = {
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
// these are provided by chakra folder, so we don't want to import them from chakra directly
|
||||
importNames: [
|
||||
"Card",
|
||||
"Button",
|
||||
"Checkbox",
|
||||
"Badge",
|
||||
"Drawer",
|
||||
"Heading",
|
||||
"Text",
|
||||
"FormLabel",
|
||||
"FormHelperText",
|
||||
"FormErrorMessage",
|
||||
"MenuGroup",
|
||||
"VStack",
|
||||
"HStack",
|
||||
"AspectRatio",
|
||||
"useToast",
|
||||
"useClipboard",
|
||||
"Badge",
|
||||
"Stack",
|
||||
// also the types
|
||||
"ButtonProps",
|
||||
"BadgeProps",
|
||||
"DrawerProps",
|
||||
"HeadingProps",
|
||||
"TextProps",
|
||||
"FormLabelProps",
|
||||
"HelpTextProps",
|
||||
"MenuGroupProps",
|
||||
"MenuItemProps",
|
||||
"AspectRatioProps",
|
||||
"BadgeProps",
|
||||
"StackProps",
|
||||
],
|
||||
message:
|
||||
'import component from "chakra" folder instead if you have to use chakra component, But use shadcn component otherwise',
|
||||
name: "@chakra-ui/react",
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Import from `@chakra-ui/react` instead of `@chakra-ui/layout`.",
|
||||
name: "@chakra-ui/layout",
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Import from `@chakra-ui/react` instead of `@chakra-ui/button`.",
|
||||
name: "@chakra-ui/button",
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Import from `@chakra-ui/react` instead of `@chakra-ui/menu`.",
|
||||
name: "@chakra-ui/menu",
|
||||
},
|
||||
{
|
||||
importNames: ["useRouter"],
|
||||
message:
|
||||
|
||||
@@ -20,11 +20,6 @@ const config: StorybookConfig = {
|
||||
name: getAbsolutePath("@storybook/nextjs"),
|
||||
options: {},
|
||||
},
|
||||
refs: {
|
||||
"@chakra-ui/react": {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
staticDirs: ["../public"],
|
||||
features: {
|
||||
experimentalRSC: true,
|
||||
|
||||
@@ -115,9 +115,6 @@ const SENTRY_OPTIONS: SentryBuildOptions = {
|
||||
const FRAMER_ADDITIONAL_LANGUAGES = ["es"];
|
||||
|
||||
const baseNextConfig: NextConfig = {
|
||||
compiler: {
|
||||
emotion: true,
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@chakra-ui/styled-system": "^2.9.2",
|
||||
"@chakra-ui/theme-tools": "^2.1.2",
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/styled": "11.14.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
@@ -34,14 +29,11 @@
|
||||
"@vercel/functions": "2.2.2",
|
||||
"@vercel/og": "^0.6.8",
|
||||
"abitype": "1.0.8",
|
||||
"chakra-react-select": "^4.7.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"color": "5.0.0",
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"framer-motion": "12.23.0",
|
||||
"fuse.js": "7.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.6.1",
|
||||
@@ -57,7 +49,6 @@
|
||||
"posthog-js": "1.256.1",
|
||||
"posthog-node": "^5.4.0",
|
||||
"prettier": "3.6.2",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
@@ -66,6 +57,7 @@
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-qrcode-logo": "^3.0.0",
|
||||
"react-table": "^7.8.0",
|
||||
"recharts": "2.15.3",
|
||||
"remark-gfm": "4.0.1",
|
||||
@@ -86,7 +78,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "4.0.1",
|
||||
"@next/bundle-analyzer": "15.3.5",
|
||||
"@next/eslint-plugin-next": "15.3.5",
|
||||
@@ -99,7 +90,6 @@
|
||||
"@types/node": "22.14.1",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/react-table": "^7.7.20",
|
||||
@@ -129,12 +119,10 @@
|
||||
"dev": "next dev --turbopack",
|
||||
"fix": "biome check ./src --fix && eslint ./src --fix",
|
||||
"format": "biome format ./src --write",
|
||||
"gen:theme-typings": "chakra-cli tokens src/chakra/theme/index.ts",
|
||||
"knip": "knip",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"playwright": "playwright test",
|
||||
"postbuild": "next-sitemap",
|
||||
"postinstall": "pnpm run gen:theme-typings",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"start": "next start",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
@@ -142,4 +130,4 @@
|
||||
"update-checkly": "npx checkly deploy"
|
||||
},
|
||||
"version": "3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
name,description,image,animation_url,external_url,background_color,youtube_url,additional,properties,can be,added
|
||||
Token 0 Name,Token 0 Description,ipfs://ipfsHash/0,ipfs://ipfsHash/0,https://thirdweb.com,#0098EE,,every,row,is a ,property
|
||||
|
@@ -1,4 +0,0 @@
|
||||
name,description,image,animation_url,external_url,background_color,youtube_url,additional,properties,can be,added
|
||||
Token 0 Name,Token 0 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property
|
||||
Token 1 Name,Token 1 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property
|
||||
Token 2 Name,Token 2 Description,0.png,0.mp4,https://thirdweb.com,#0098EE,,every,row,is a ,property
|
||||
|
@@ -1,2 +0,0 @@
|
||||
name,description,external_url,background_color,youtube_url,additional,properties,can be,added
|
||||
Token 0 Name,Token 0 Description,https://thirdweb.com,#0098EE,,every,row,is a ,property
|
||||
|
@@ -1,3 +0,0 @@
|
||||
address,maxClaimable
|
||||
0x0000000000000000000000000000000000000000,2
|
||||
0x000000000000000000000000000000000000dEaD,5
|
||||
|
@@ -1,3 +0,0 @@
|
||||
address,maxClaimable,price,currencyAddress
|
||||
0x0000000000000000000000000000000000000000,2,0.1,0x0000000000000000000000000000000000000000
|
||||
0x000000000000000000000000000000000000dEaD,5,2.5,0x0000000000000000000000000000000000000000
|
||||
|
@@ -1,3 +0,0 @@
|
||||
address
|
||||
0x0000000000000000000000000000000000000000
|
||||
0x000000000000000000000000000000000000dEaD
|
||||
|
@@ -66,15 +66,20 @@ const projectRoute = "/team/:team_slug/:project_slug";
|
||||
|
||||
const projectPageRedirects = [
|
||||
{
|
||||
destination: `${projectRoute}/universal-bridge/:path*`,
|
||||
destination: `${projectRoute}/payments/:path*`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/connect/pay/:path*`,
|
||||
},
|
||||
{
|
||||
destination: `${projectRoute}/universal-bridge/:path*`,
|
||||
destination: `${projectRoute}/payments/:path*`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/connect/universal-bridge/:path*`,
|
||||
},
|
||||
{
|
||||
destination: `${projectRoute}/payments/:path*`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/universal-bridge/:path*`,
|
||||
},
|
||||
{
|
||||
destination: `${projectRoute}/account-abstraction/:path*`,
|
||||
permanent: false,
|
||||
@@ -375,12 +380,22 @@ async function redirects() {
|
||||
permanent: false,
|
||||
source: "/template/:slug",
|
||||
},
|
||||
// redirect /connect/pay to /universal-bridge
|
||||
// redirect /connect/pay to /payments
|
||||
{
|
||||
destination: "/universal-bridge",
|
||||
permanent: false,
|
||||
destination: "/payments",
|
||||
permanent: true,
|
||||
source: "/connect/pay",
|
||||
},
|
||||
{
|
||||
destination: "/payments",
|
||||
permanent: true,
|
||||
source: "/universal-bridge",
|
||||
},
|
||||
{
|
||||
destination: "/payments/:slug",
|
||||
permanent: true,
|
||||
source: "/universal-bridge/:slug",
|
||||
},
|
||||
// PREVIOUS CAMPAIGNS
|
||||
{
|
||||
destination: "/",
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function getWalletNFTs(params: {
|
||||
type OwnedNFTInsightResponse = {
|
||||
name: string;
|
||||
description: string;
|
||||
image_url: string;
|
||||
image_url?: string;
|
||||
background_color: string;
|
||||
external_url: string;
|
||||
metadata_url: string;
|
||||
@@ -186,7 +186,7 @@ async function getWalletNFTsFromInsight(params: {
|
||||
description: nft.description,
|
||||
external_url: nft.external_url,
|
||||
image: isDev
|
||||
? nft.image_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
|
||||
? nft.image_url?.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
|
||||
: nft.image_url,
|
||||
name: nft.name,
|
||||
uri: isDev
|
||||
|
||||
@@ -413,3 +413,18 @@ export function reportUpsellShown(properties: UpsellParams) {
|
||||
export function reportUpsellClicked(properties: UpsellParams) {
|
||||
posthog.capture("upsell clicked", properties);
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// PAYMENTS
|
||||
// ----------------------------
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track conversions on payment overview page
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @samina
|
||||
*/
|
||||
export function reportPaymentCardClick(properties: { id: string }) {
|
||||
posthog.capture("payment card clicked", properties);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function createSupportTicket(params: {
|
||||
try {
|
||||
const siwaUrl = process.env.NEXT_PUBLIC_SIWA_URL;
|
||||
if (siwaUrl) {
|
||||
await fetch(`${siwaUrl}/v1/chat/feedback`, {
|
||||
const res = await fetch(`${siwaUrl}/v1/chat/feedback`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -73,6 +73,12 @@ export async function createSupportTicket(params: {
|
||||
feedbackRating: ESCALATION_FEEDBACK_RATING,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Log error but don't fail the ticket creation
|
||||
const errorMessage = await res.text();
|
||||
console.error("Failed to escalate to SIWA feedback:", errorMessage);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't fail the ticket creation
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use server";
|
||||
import type { Address } from "thirdweb";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
|
||||
|
||||
@@ -96,6 +97,139 @@ export async function deleteWebhook(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
type PaymentLink = {
|
||||
id: string;
|
||||
link: string;
|
||||
title: string;
|
||||
imageUrl: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
destinationToken: {
|
||||
chainId: number;
|
||||
address: Address;
|
||||
symbol: string;
|
||||
name: string;
|
||||
decimals: number;
|
||||
iconUri: string;
|
||||
};
|
||||
receiver: Address;
|
||||
amount: bigint;
|
||||
};
|
||||
|
||||
export async function getPaymentLinks(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
}): Promise<Array<PaymentLink>> {
|
||||
const authToken = await getAuthToken();
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
data: Array<PaymentLink & { amount: string }>;
|
||||
};
|
||||
return json.data.map((link) => ({
|
||||
id: link.id,
|
||||
link: link.link,
|
||||
title: link.title,
|
||||
imageUrl: link.imageUrl,
|
||||
createdAt: link.createdAt,
|
||||
updatedAt: link.updatedAt,
|
||||
destinationToken: {
|
||||
chainId: link.destinationToken.chainId,
|
||||
address: link.destinationToken.address,
|
||||
symbol: link.destinationToken.symbol,
|
||||
name: link.destinationToken.name,
|
||||
decimals: link.destinationToken.decimals,
|
||||
iconUri: link.destinationToken.iconUri,
|
||||
},
|
||||
receiver: link.receiver,
|
||||
amount: BigInt(link.amount),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createPaymentLink(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
title: string;
|
||||
imageUrl?: string;
|
||||
intent: {
|
||||
destinationChainId: number;
|
||||
destinationTokenAddress: Address;
|
||||
receiver: Address;
|
||||
amount: bigint;
|
||||
purchaseData?: unknown;
|
||||
};
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/links`, {
|
||||
body: JSON.stringify({
|
||||
title: props.title,
|
||||
imageUrl: props.imageUrl,
|
||||
intent: {
|
||||
destinationChainId: props.intent.destinationChainId,
|
||||
destinationTokenAddress: props.intent.destinationTokenAddress,
|
||||
receiver: props.intent.receiver,
|
||||
amount: props.intent.amount.toString(),
|
||||
purchaseData: props.intent.purchaseData,
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
export async function deletePaymentLink(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
paymentLinkId: string;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
const res = await fetch(
|
||||
`${UB_BASE_URL}/v1/developer/links/${props.paymentLinkId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
},
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
export type Fee = {
|
||||
feeRecipient: string;
|
||||
feeBps: number;
|
||||
@@ -195,30 +329,28 @@ export type Payment = {
|
||||
export async function getPayments(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
paymentLinkId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
// Build URL with query parameters if provided
|
||||
let url = `${UB_BASE_URL}/v1/developer/payments`;
|
||||
const queryParams = new URLSearchParams();
|
||||
const url = new URL(`${UB_BASE_URL}/v1/developer/payments`);
|
||||
|
||||
if (props.limit) {
|
||||
queryParams.append("limit", props.limit.toString());
|
||||
url.searchParams.append("limit", props.limit.toString());
|
||||
}
|
||||
|
||||
if (props.offset) {
|
||||
queryParams.append("offset", props.offset.toString());
|
||||
url.searchParams.append("offset", props.offset.toString());
|
||||
}
|
||||
|
||||
// Append query params to URL if any exist
|
||||
const queryString = queryParams.toString();
|
||||
if (queryString) {
|
||||
url = `${url}?${queryString}`;
|
||||
if (props.paymentLinkId) {
|
||||
url.searchParams.append("paymentLinkId", props.paymentLinkId);
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -13,12 +13,18 @@ export type TokenMetadata = {
|
||||
iconUri?: string;
|
||||
};
|
||||
|
||||
export async function getUniversalBridgeTokens(props: { chainId?: number }) {
|
||||
export async function getUniversalBridgeTokens(props: {
|
||||
chainId?: number;
|
||||
address?: string;
|
||||
}) {
|
||||
const url = new URL(`${UB_BASE_URL}/v1/tokens`);
|
||||
|
||||
if (props.chainId) {
|
||||
url.searchParams.append("chainId", String(props.chainId));
|
||||
}
|
||||
if (props.address) {
|
||||
url.searchParams.append("tokenAddress", props.address);
|
||||
}
|
||||
url.searchParams.append("limit", "1000");
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
Textarea,
|
||||
} from "@chakra-ui/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Button } from "chakra/button";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
|
||||
import { Heading } from "chakra/heading";
|
||||
import { Text } from "chakra/text";
|
||||
import { ChevronLeftIcon, EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
HelpCircleIcon,
|
||||
LockKeyholeIcon,
|
||||
RefreshCcwIcon,
|
||||
ShuffleIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { CreateDelayedRevealBatchParams } from "thirdweb/extensions/erc721";
|
||||
@@ -25,12 +20,14 @@ import type { NFTInput } from "thirdweb/utils";
|
||||
import { z } from "zod";
|
||||
import { FileInput } from "@/components/blocks/FileInput";
|
||||
import { TransactionButton } from "@/components/tx-button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import type { ComponentWithChildren } from "@/types/component-with-children";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { processInputData, shuffleData } from "@/utils/batch";
|
||||
import { TabButtons } from "../ui/tabs";
|
||||
import { BatchTable } from "./batch-table";
|
||||
import { SelectOption } from "./lazy-mint-form/select-option";
|
||||
import { UploadStep } from "./upload-step";
|
||||
|
||||
type DelayedSubmit = {
|
||||
@@ -44,16 +41,6 @@ type InstantSubmit = {
|
||||
|
||||
type SubmitType = DelayedSubmit | InstantSubmit;
|
||||
|
||||
interface BatchLazyMintEVMProps {
|
||||
nextTokenIdToMint: bigint;
|
||||
canCreateDelayedRevealBatch: boolean;
|
||||
onSubmit: (formData: SubmitType) => Promise<unknown>;
|
||||
chainId: number;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
type BatchLazyMintProps = BatchLazyMintEVMProps;
|
||||
|
||||
const BatchLazyMintFormSchema = z
|
||||
.object({
|
||||
confirmPassword: z
|
||||
@@ -73,7 +60,7 @@ const BatchLazyMintFormSchema = z
|
||||
name: z.string().min(1, "A name is required"),
|
||||
})
|
||||
.optional(),
|
||||
revealType: z.literal("instant").or(z.literal("delayed")).optional(),
|
||||
revealType: z.literal("instant").or(z.literal("delayed")),
|
||||
|
||||
// shared logic
|
||||
shuffle: z.boolean().default(false),
|
||||
@@ -91,18 +78,21 @@ function useBatchLazyMintForm() {
|
||||
return useForm<BatchLazyMintFormType>({
|
||||
defaultValues: {
|
||||
metadatas: [],
|
||||
revealType: undefined,
|
||||
revealType: "instant",
|
||||
shuffle: false,
|
||||
},
|
||||
resolver: zodResolver(BatchLazyMintFormSchema),
|
||||
});
|
||||
}
|
||||
|
||||
export const BatchLazyMint: ComponentWithChildren<
|
||||
BatchLazyMintProps & {
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
> = (props) => {
|
||||
export function BatchLazyMint(props: {
|
||||
nextTokenIdToMint: bigint;
|
||||
canCreateDelayedRevealBatch: boolean;
|
||||
onSubmit: (formData: SubmitType) => Promise<unknown>;
|
||||
chainId: number;
|
||||
client: ThirdwebClient;
|
||||
isLoggedIn: boolean;
|
||||
}) {
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const form = useBatchLazyMintForm();
|
||||
@@ -110,369 +100,415 @@ export const BatchLazyMint: ComponentWithChildren<
|
||||
const nftMetadatas = form.watch("metadatas");
|
||||
const hasError = !!form.getFieldState("metadatas", form.formState).error;
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop: async (acceptedFiles) => {
|
||||
try {
|
||||
await processInputData(acceptedFiles, (data) =>
|
||||
form.setValue("metadatas", data),
|
||||
);
|
||||
} catch {
|
||||
form.setError("metadatas", {
|
||||
message: "Invalid metadata files",
|
||||
type: "validate",
|
||||
});
|
||||
}
|
||||
|
||||
if (nftMetadatas.length === 0) {
|
||||
form.setError("metadatas", {
|
||||
message: "Invalid metadata files",
|
||||
type: "validate",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const paginationPortalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mt-4 flex w-full flex-col"
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
// first shuffle
|
||||
const shuffledMetadatas = data.shuffle
|
||||
? shuffleData(data.metadatas)
|
||||
: data.metadatas;
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="mt-4 flex w-full flex-col"
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
// first shuffle
|
||||
const shuffledMetadatas = data.shuffle
|
||||
? shuffleData(data.metadatas)
|
||||
: data.metadatas;
|
||||
|
||||
// check submit is instant
|
||||
if (data.revealType === "instant") {
|
||||
return props.onSubmit({
|
||||
data: { metadatas: shuffledMetadatas },
|
||||
revealType: "instant",
|
||||
});
|
||||
}
|
||||
// validate password
|
||||
if (!data.password) {
|
||||
form.setError("password", {
|
||||
message: "A password is required for delayed reveal.",
|
||||
type: "validate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// validate placeholder
|
||||
if (!data.placeHolder?.name) {
|
||||
form.setError("placeHolder.name", {
|
||||
message: "A name is required for delayed reveal.",
|
||||
type: "validate",
|
||||
});
|
||||
}
|
||||
// submit
|
||||
return props.onSubmit({
|
||||
data: {
|
||||
metadata: shuffledMetadatas,
|
||||
password: data.password,
|
||||
placeholderMetadata: {
|
||||
description: data.placeHolder?.description,
|
||||
image: data.placeHolder?.image,
|
||||
name: data.placeHolder?.name,
|
||||
// check submit is instant
|
||||
if (data.revealType === "instant") {
|
||||
return props.onSubmit({
|
||||
data: { metadatas: shuffledMetadatas },
|
||||
revealType: "instant",
|
||||
});
|
||||
}
|
||||
// validate password
|
||||
if (!data.password) {
|
||||
form.setError("password", {
|
||||
message: "A password is required for delayed reveal.",
|
||||
type: "validate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// validate placeholder
|
||||
if (!data.placeHolder?.name) {
|
||||
form.setError("placeHolder.name", {
|
||||
message: "A name is required for delayed reveal.",
|
||||
type: "validate",
|
||||
});
|
||||
}
|
||||
// submit
|
||||
await props.onSubmit({
|
||||
data: {
|
||||
metadata: shuffledMetadatas,
|
||||
password: data.password,
|
||||
placeholderMetadata: {
|
||||
description: data.placeHolder?.description,
|
||||
image: data.placeHolder?.image,
|
||||
name: data.placeHolder?.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
revealType: "delayed",
|
||||
});
|
||||
})}
|
||||
>
|
||||
{step === 0 ? (
|
||||
<div className="flex flex-col gap-6">
|
||||
{nftMetadatas.length > 0 ? (
|
||||
<>
|
||||
<BatchTable
|
||||
client={props.client}
|
||||
data={nftMetadatas}
|
||||
nextTokenIdToMint={props.nextTokenIdToMint}
|
||||
portalRef={paginationPortalRef}
|
||||
/>
|
||||
<div className="border-border border-t">
|
||||
<div className="flex flex-col items-center justify-between p-0 md:flex-row md:p-4">
|
||||
<div ref={paginationPortalRef} />
|
||||
<div className="mt-4 flex w-full flex-row items-center gap-2 md:mt-0 md:w-auto">
|
||||
<Button
|
||||
borderRadius="md"
|
||||
isDisabled={!hasError}
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
}}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
onClick={() => setStep(1)}
|
||||
w={{ base: "100%", md: "auto" }}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
revealType: "delayed",
|
||||
});
|
||||
})}
|
||||
>
|
||||
{step === 0 ? (
|
||||
<div>
|
||||
{nftMetadatas.length > 0 ? (
|
||||
<div className="pb-10">
|
||||
<h2 className="text-2xl font-semibold tracking-tight mb-4">
|
||||
Upload NFTs
|
||||
</h2>
|
||||
|
||||
<BatchTable
|
||||
client={props.client}
|
||||
data={nftMetadatas}
|
||||
nextTokenIdToMint={props.nextTokenIdToMint}
|
||||
/>
|
||||
<div className="mt-5 flex justify-end gap-4">
|
||||
<Button
|
||||
className="w-full md:w-auto gap-2"
|
||||
disabled={!hasError}
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full md:w-auto gap-2"
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
Next <ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<UploadStep
|
||||
getInputProps={getInputProps}
|
||||
getRootProps={getRootProps}
|
||||
hasFailed={hasError}
|
||||
isDragActive={isDragActive}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Flex align="center" justify="space-between" mb={2} py={4} w="100%">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
) : (
|
||||
<UploadStep
|
||||
hasFailed={hasError}
|
||||
reset={() => {
|
||||
form.reset();
|
||||
}}
|
||||
onDrop={async (acceptedFiles) => {
|
||||
try {
|
||||
await processInputData(acceptedFiles, (data) =>
|
||||
form.setValue("metadatas", data),
|
||||
);
|
||||
} catch {
|
||||
form.setError("metadatas", {
|
||||
message: "Invalid metadata files",
|
||||
type: "validate",
|
||||
});
|
||||
}
|
||||
|
||||
if (nftMetadatas.length === 0) {
|
||||
form.setError("metadatas", {
|
||||
message: "Invalid metadata files",
|
||||
type: "validate",
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="container px-0 max-w-4xl pb-14">
|
||||
<div className="flex mb-3">
|
||||
<Button
|
||||
className="text-muted-foreground"
|
||||
className="gap-2 -translate-x-3 text-muted-foreground"
|
||||
size="sm"
|
||||
onClick={() => setStep(0)}
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5 cursor-pointer" />
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Heading className="my-auto" size="title.md">
|
||||
When will you reveal your NFTs?
|
||||
</Heading>
|
||||
</div>
|
||||
</Flex>
|
||||
<SelectReveal
|
||||
canCreateDelayedRevealBatch={props.canCreateDelayedRevealBatch}
|
||||
client={props.client}
|
||||
form={form}
|
||||
/>
|
||||
{form.watch("revealType") && (
|
||||
<>
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
checked={form.watch("shuffle")}
|
||||
className="mt-3"
|
||||
onCheckedChange={(val) => form.setValue("shuffle", !!val)}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-1 md:flex-row">
|
||||
<p>Shuffle the order of the NFTs before uploading.</p>
|
||||
<em>This is an off-chain operation and is not provable.</em>
|
||||
</div>
|
||||
</CheckboxWithLabel>
|
||||
<div className="w-full md:w-[61%]">
|
||||
<TransactionButton
|
||||
className="mt-4 w-full"
|
||||
client={props.client}
|
||||
disabled={!nftMetadatas.length}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
isPending={form.formState.isSubmitting}
|
||||
transactionCount={1}
|
||||
txChainID={props.chainId}
|
||||
type="submit"
|
||||
>
|
||||
{form.formState.isSubmitting
|
||||
? `Uploading ${nftMetadatas.length} NFTs`
|
||||
: `Upload ${nftMetadatas.length} NFTs`}
|
||||
</TransactionButton>
|
||||
{props.children}
|
||||
</div>
|
||||
<Text mt={2} size="body.sm">
|
||||
<UnderlineLink
|
||||
className="text-primary-500"
|
||||
href="https://support.thirdweb.com/dashboard/n5evQ4EfEjEifczEQaZ1hL/batch-upload-troubleshooting/5WMQFqfaUTU1C8NM8FtJ2X"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Experiencing issues uploading your files?
|
||||
</UnderlineLink>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
interface SelectRevealProps {
|
||||
<h2 className="text-2xl font-semibold tracking-tight mb-4">
|
||||
Upload NFTs
|
||||
</h2>
|
||||
|
||||
<SelectReveal
|
||||
canCreateDelayedRevealBatch={props.canCreateDelayedRevealBatch}
|
||||
client={props.client}
|
||||
form={form}
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{form.watch("revealType") === "delayed" && (
|
||||
<DelayedRevealConfiguration client={props.client} form={form} />
|
||||
)}
|
||||
|
||||
{/* shuffle */}
|
||||
<ShuffleNFTsCard
|
||||
isShuffleEnabled={form.watch("shuffle")}
|
||||
onShuffleChange={(val) => form.setValue("shuffle", val)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<TransactionButton
|
||||
client={props.client}
|
||||
disabled={!nftMetadatas.length}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
isPending={form.formState.isSubmitting}
|
||||
transactionCount={1}
|
||||
txChainID={props.chainId}
|
||||
type="submit"
|
||||
>
|
||||
{form.formState.isSubmitting
|
||||
? `Uploading ${nftMetadatas.length} NFTs`
|
||||
: `Upload ${nftMetadatas.length} NFTs`}
|
||||
</TransactionButton>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
className="text-sm flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
href="https://portal.thirdweb.com/knowledge-base/troubleshoot/contracts/batch-upload"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<HelpCircleIcon className="size-4" />
|
||||
Experiencing issues uploading your files?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function ShuffleNFTsCard(props: {
|
||||
isShuffleEnabled: boolean;
|
||||
onShuffleChange: (val: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 lg:px-6 py-8 border rounded-lg bg-card relative">
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full border bg-card">
|
||||
<ShuffleIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-lg mb-0.5 tracking-tight">
|
||||
Shuffle NFTs
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Shuffle the order of the NFTs before uploading. This is an off-chain
|
||||
operation and is not provable
|
||||
</p>
|
||||
|
||||
<Switch
|
||||
checked={props.isShuffleEnabled}
|
||||
onCheckedChange={(val) => props.onShuffleChange(!!val)}
|
||||
className="absolute right-4 top-4 lg:right-6 lg:top-8"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectReveal(props: {
|
||||
form: ReturnType<typeof useBatchLazyMintForm>;
|
||||
canCreateDelayedRevealBatch: boolean;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { form, canCreateDelayedRevealBatch } = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canCreateDelayedRevealBatch && (
|
||||
<TabButtons
|
||||
containerClassName="mb-8"
|
||||
tabClassName="!text-sm"
|
||||
tabs={[
|
||||
{
|
||||
name: "Reveal upon mint",
|
||||
onClick: () => {
|
||||
form.setValue("revealType", "instant");
|
||||
// reset all fields related to delayed reveal
|
||||
form.resetField("password");
|
||||
form.resetField("confirmPassword");
|
||||
form.resetField("placeHolder");
|
||||
},
|
||||
isActive: form.watch("revealType") === "instant",
|
||||
},
|
||||
{
|
||||
name: "Delayed Reveal",
|
||||
onClick: () => form.setValue("revealType", "delayed"),
|
||||
isActive: form.watch("revealType") === "delayed",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.watch("revealType") === "instant" && (
|
||||
<div>
|
||||
<h2 className="font-semibold text-xl mb-1 tracking-tight leading-none">
|
||||
Reveal upon mint
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Collectors will immediately see the final NFT when they complete the
|
||||
minting
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.watch("revealType") === "delayed" && (
|
||||
<div>
|
||||
<h2 className="font-semibold text-xl mb-1 tracking-tight leading-none">
|
||||
Delayed Reveal
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Collectors will mint your placeholder image, then you reveal at a
|
||||
later time
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SelectReveal: React.FC<SelectRevealProps> = ({
|
||||
form,
|
||||
client,
|
||||
canCreateDelayedRevealBatch,
|
||||
}) => {
|
||||
function DelayedRevealConfiguration(props: {
|
||||
form: ReturnType<typeof useBatchLazyMintForm>;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { form, client } = props;
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const imageUrl = form.watch("placeHolder.image");
|
||||
|
||||
return (
|
||||
<Flex flexDir="column">
|
||||
<Flex
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
gap={{ base: 3, md: 6 }}
|
||||
mb={6}
|
||||
>
|
||||
<SelectOption
|
||||
description="Collectors will immediately see the final NFT when they complete the minting"
|
||||
isActive={form.watch("revealType") === "instant"}
|
||||
name="Reveal upon mint"
|
||||
onClick={() => form.setValue("revealType", "instant")}
|
||||
/>
|
||||
<SelectOption
|
||||
description="Collectors will mint your placeholder image, then you reveal at a later time"
|
||||
disabled={!canCreateDelayedRevealBatch}
|
||||
disabledText="This contract doesn't implement Delayed Reveal"
|
||||
isActive={form.watch("revealType") === "delayed"}
|
||||
name="Delayed Reveal"
|
||||
onClick={() => form.setValue("revealType", "delayed")}
|
||||
/>
|
||||
</Flex>
|
||||
<div className="flex flex-col gap-3">
|
||||
{form.watch("revealType") === "delayed" && (
|
||||
<>
|
||||
<Heading size="title.sm">Let's set a password</Heading>
|
||||
<Alert borderRadius="lg" status="warning">
|
||||
<AlertIcon />
|
||||
You'll need this password to reveal your NFTs. Please save it
|
||||
somewhere safe.
|
||||
</Alert>
|
||||
<div className="space-y-6">
|
||||
{/* password */}
|
||||
<div className="border px-4 lg:px-6 py-8 rounded-lg bg-card">
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full border bg-card">
|
||||
<LockKeyholeIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-0.5 tracking-tight">
|
||||
Set password
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
You'll need this password to reveal your NFTs. Please save it
|
||||
somewhere safe
|
||||
</p>
|
||||
|
||||
<Flex
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
gap={{ base: 4, md: 0 }}
|
||||
>
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState("password", form.formState).error
|
||||
}
|
||||
isRequired
|
||||
mr={4}
|
||||
<div className="space-y-5">
|
||||
{/* select password */}
|
||||
<FormItem className="space-y-1">
|
||||
<FormLabel>Password</FormLabel>
|
||||
<div className="relative max-w-md">
|
||||
<Input
|
||||
{...form.register("password")}
|
||||
className="pr-10"
|
||||
type={show ? "text" : "password"}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 h-auto text-muted-foreground"
|
||||
onClick={() => setShow(!show)}
|
||||
>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
{...form.register("password")}
|
||||
placeholder="Choose password"
|
||||
type={show ? "text" : "password"}
|
||||
/>
|
||||
<InputRightElement cursor="pointer">
|
||||
{show ? (
|
||||
<EyeIcon
|
||||
className="size-3"
|
||||
onClick={() => setShow(!show)}
|
||||
/>
|
||||
) : (
|
||||
<EyeOffIcon
|
||||
className="size-3"
|
||||
onClick={() => setShow(!show)}
|
||||
/>
|
||||
)}
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState("password", form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState("confirmPassword", form.formState).error
|
||||
}
|
||||
isRequired
|
||||
>
|
||||
<FormLabel>Confirm password</FormLabel>
|
||||
<Input
|
||||
{...form.register("confirmPassword")}
|
||||
placeholder="Confirm password"
|
||||
type="password"
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState("confirmPassword", form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
<div className="flex flex-col gap-5">
|
||||
<Heading size="title.sm">Placeholder</Heading>
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState("placeHolder.image", form.formState)
|
||||
.error
|
||||
}
|
||||
>
|
||||
<FormLabel>Image</FormLabel>
|
||||
<div className="w-auto md:w-[350px]">
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
className="rounded border border-border transition-all duration-200"
|
||||
client={client}
|
||||
setValue={(file) =>
|
||||
form.setValue("placeHolder.image", file)
|
||||
}
|
||||
showUploadButton
|
||||
value={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
<FormHelperText>
|
||||
You can optionally upload an image as the placeholder.
|
||||
</FormHelperText>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState("placeHolder.image", form.formState)
|
||||
.error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState("placeHolder.name", form.formState).error
|
||||
}
|
||||
isRequired
|
||||
>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
{...form.register("placeHolder.name")}
|
||||
placeholder="eg. My NFT (Coming soon)"
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState("placeHolder.name", form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState("placeHolder.name", form.formState).error
|
||||
}
|
||||
>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea
|
||||
{...form.register("placeHolder.description")}
|
||||
placeholder="eg. Reveal on July 15th!"
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState(
|
||||
"placeHolder.description",
|
||||
form.formState,
|
||||
).error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
{show ? (
|
||||
<EyeIcon className="size-4" />
|
||||
) : (
|
||||
<EyeOffIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<FormMessage>
|
||||
{form.getFieldState("password", form.formState).error?.message}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
|
||||
{/* confirm password */}
|
||||
<FormItem className="space-y-1">
|
||||
<FormLabel>Confirm password</FormLabel>
|
||||
<div className="relative max-w-md">
|
||||
<Input
|
||||
{...form.register("confirmPassword")}
|
||||
className="pr-10"
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
<FormMessage>
|
||||
{
|
||||
form.getFieldState("confirmPassword", form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
{/* placeholder */}
|
||||
<div className="border px-4 lg:px-6 py-8 rounded-lg bg-card">
|
||||
<div className="flex mb-3">
|
||||
<div className="p-2 rounded-full border bg-card">
|
||||
<EyeIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg tracking-tight mb-0.5">
|
||||
Set placeholder metadata
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The placeholder metadata will be displayed for all NFTs until you
|
||||
reveal your NFTs
|
||||
</p>
|
||||
<div className="flex flex-col lg:flex-row gap-5">
|
||||
<FormItem className="w-[200px]">
|
||||
<FormLabel>Image</FormLabel>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
className="rounded border bg-background transition-all duration-200 "
|
||||
client={client}
|
||||
setValue={(file) => form.setValue("placeHolder.image", file)}
|
||||
value={imageUrl}
|
||||
/>
|
||||
|
||||
<FormMessage>
|
||||
{
|
||||
form.getFieldState("placeHolder.image", form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
<div className="grow space-y-5 flex flex-col">
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
className="max-w-sm"
|
||||
{...form.register("placeHolder.name")}
|
||||
placeholder="My NFT (Coming soon)"
|
||||
/>
|
||||
<FormMessage>
|
||||
{
|
||||
form.getFieldState("placeHolder.name", form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
<FormItem className="grow flex flex-col">
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea
|
||||
{...form.register("placeHolder.description")}
|
||||
className="grow"
|
||||
placeholder="Reveal on July 15th!"
|
||||
/>
|
||||
<FormMessage>
|
||||
{
|
||||
form.getFieldState("placeHolder.description", form.formState)
|
||||
.error?.message
|
||||
}
|
||||
</FormMessage>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,237 +1,183 @@
|
||||
/** biome-ignore-all lint/nursery/noNestedComponentDefinitions: FIXME */
|
||||
|
||||
import { FilePreview } from "@app/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/file-preview";
|
||||
import {
|
||||
Flex,
|
||||
IconButton,
|
||||
Portal,
|
||||
Select,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
ChevronFirstIcon,
|
||||
ChevronLastIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { type Column, usePagination, useTable } from "react-table";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { NFTInput } from "thirdweb/utils";
|
||||
import { FilePreview } from "@/components/blocks/file-preview";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
|
||||
interface BatchTableProps {
|
||||
type BatchTableProps = {
|
||||
data: NFTInput[];
|
||||
portalRef: React.RefObject<HTMLDivElement | null>;
|
||||
nextTokenIdToMint?: bigint;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
};
|
||||
|
||||
export const BatchTable: React.FC<BatchTableProps> = ({
|
||||
export function BatchTable({
|
||||
data,
|
||||
portalRef,
|
||||
nextTokenIdToMint,
|
||||
client,
|
||||
}) => {
|
||||
const columns = useMemo(() => {
|
||||
let cols: Column<NFTInput>[] = [];
|
||||
if (nextTokenIdToMint !== undefined) {
|
||||
cols = cols.concat({
|
||||
accessor: (_row, index) => String(nextTokenIdToMint + BigInt(index)),
|
||||
Header: "Token ID",
|
||||
});
|
||||
}
|
||||
}: BatchTableProps) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
cols = cols.concat([
|
||||
{
|
||||
accessor: (row) => row.image,
|
||||
Cell: ({ cell: { value } }: { cell: { value?: string } }) => (
|
||||
<FilePreview
|
||||
className="size-24 shrink-0 rounded-lg object-contain"
|
||||
client={client}
|
||||
srcOrFile={value}
|
||||
/>
|
||||
),
|
||||
Header: "Image",
|
||||
},
|
||||
{
|
||||
accessor: (row) => row.animation_url,
|
||||
Cell: ({ cell: { value } }: { cell: { value?: string } }) => (
|
||||
<FilePreview
|
||||
className="size-24 shrink-0 rounded-lg"
|
||||
client={client}
|
||||
srcOrFile={value}
|
||||
/>
|
||||
),
|
||||
Header: "Animation Url",
|
||||
},
|
||||
{ accessor: (row) => row.name, Header: "Name" },
|
||||
{
|
||||
accessor: (row) => (
|
||||
<ToolTipLabel label={row.description}>
|
||||
<p className="line-clamp-6 whitespace-pre-wrap">
|
||||
{row.description}
|
||||
</p>
|
||||
</ToolTipLabel>
|
||||
),
|
||||
Header: "Description",
|
||||
},
|
||||
{
|
||||
accessor: (row) => row.attributes || row.properties,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
Cell: ({ cell }: { cell: any }) =>
|
||||
cell.value ? (
|
||||
<CodeClient
|
||||
code={JSON.stringify(cell.value || {}, null, 2)}
|
||||
lang="json"
|
||||
scrollableClassName="max-w-[300px]"
|
||||
/>
|
||||
) : null,
|
||||
Header: "Attributes",
|
||||
},
|
||||
{ accessor: (row) => row.external_url, Header: "External URL" },
|
||||
{ accessor: (row) => row.background_color, Header: "Background Color" },
|
||||
]);
|
||||
return cols;
|
||||
}, [nextTokenIdToMint, client]);
|
||||
const totalPages = Math.ceil(data.length / pageSize);
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const currentData = data.slice(startIndex, endIndex);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
prepareRow,
|
||||
// Instead of using 'rows', we'll use page,
|
||||
page,
|
||||
// which has only the rows for the active page
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(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: {
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
},
|
||||
},
|
||||
// will be fixed with @tanstack/react-table v8
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
usePagination,
|
||||
);
|
||||
const showTokenId = nextTokenIdToMint !== undefined;
|
||||
const showExternalUrl = data.some((row) => row.external_url);
|
||||
const showBackgroundColor = data.some((row) => row.background_color);
|
||||
const showAnimationUrl = data.some((row) => row.animation_url);
|
||||
const showDescription = data.some((row) => row.description);
|
||||
const showAttributes = data.some((row) => row.attributes || row.properties);
|
||||
|
||||
const showPagination = totalPages > 1;
|
||||
|
||||
// Render the UI for your table
|
||||
return (
|
||||
<Flex flexGrow={1} overflow="auto">
|
||||
<TableContainer className="w-full" maxW="100%">
|
||||
<Table {...getTableProps()}>
|
||||
<Thead>
|
||||
{headerGroups.map((headerGroup, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Tr {...headerGroup.getHeaderGroupProps()} key={index}>
|
||||
{headerGroup.headers.map((column, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
<Th {...column.getHeaderProps()} border="none" key={i}>
|
||||
<p className="text-muted-foreground">
|
||||
{column.render("Header")}
|
||||
</p>
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody {...getTableBodyProps()}>
|
||||
{page.map((row, rowIndex) => {
|
||||
prepareRow(row);
|
||||
<div className="rounded-lg border bg-card">
|
||||
<TableContainer className="border-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{showTokenId && <TableHead>Token ID</TableHead>}
|
||||
<TableHead>Image</TableHead>
|
||||
{showAnimationUrl && <TableHead>Animation Url</TableHead>}
|
||||
<TableHead>Name</TableHead>
|
||||
{showDescription && (
|
||||
<TableHead className="min-w-[300px]">Description</TableHead>
|
||||
)}
|
||||
{showAttributes && <TableHead>Attributes</TableHead>}
|
||||
{showExternalUrl && <TableHead>External URL</TableHead>}
|
||||
{showBackgroundColor && <TableHead>Background Color</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentData.map((row, rowIndex) => {
|
||||
const actualIndex = startIndex + rowIndex;
|
||||
return (
|
||||
<Tr
|
||||
{...row.getRowProps()}
|
||||
_last={{ borderBottomWidth: 0 }}
|
||||
borderBottomWidth={1}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={rowIndex}
|
||||
<TableRow
|
||||
className="border-b last:border-b-0"
|
||||
key={actualIndex}
|
||||
>
|
||||
{row.cells.map((cell, cellIndex) => {
|
||||
return (
|
||||
<Td
|
||||
{...cell.getCellProps()}
|
||||
borderBottomWidth="inherit"
|
||||
borderColor="borderColor"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={cellIndex}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
{/* Token ID */}
|
||||
{showTokenId && (
|
||||
<TableCell>
|
||||
{String(nextTokenIdToMint + BigInt(actualIndex))}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
<TableCell className="min-w-36">
|
||||
<FilePreview
|
||||
className="size-36 shrink-0 rounded-lg object-contain"
|
||||
client={client}
|
||||
srcOrFile={
|
||||
typeof row.image === "string" ||
|
||||
row.image instanceof File
|
||||
? row.image
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
{/* Animation Url */}
|
||||
{showAnimationUrl && (
|
||||
<TableCell className="min-w-36">
|
||||
<FilePreview
|
||||
className="size-36 shrink-0 rounded-lg"
|
||||
client={client}
|
||||
srcOrFile={
|
||||
typeof row.animation_url === "string" ||
|
||||
row.animation_url instanceof File
|
||||
? row.animation_url
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<TableCell>{row.name}</TableCell>
|
||||
|
||||
{/* Description */}
|
||||
{showDescription && (
|
||||
<TableCell className="max-w-xs">
|
||||
<p className="whitespace-pre-wrap">{row.description}</p>
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Attributes */}
|
||||
{showAttributes && (
|
||||
<TableCell>
|
||||
{row.attributes || row.properties ? (
|
||||
<CodeClient
|
||||
code={JSON.stringify(
|
||||
row.attributes || row.properties || {},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
lang="json"
|
||||
scrollableClassName="max-w-[300px] max-h-[400px]"
|
||||
className="bg-background [&>*]:text-xs"
|
||||
/>
|
||||
) : null}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* External URL */}
|
||||
{showExternalUrl && (
|
||||
<TableCell>
|
||||
{typeof row.external_url === "string" ? (
|
||||
<ToolTipLabel label={row.external_url}>
|
||||
<span>
|
||||
{row.external_url.slice(0, 20) +
|
||||
(row.external_url.length > 20 ? "..." : "")}
|
||||
</span>
|
||||
</ToolTipLabel>
|
||||
) : row.external_url instanceof File ? (
|
||||
<FilePreview
|
||||
client={client}
|
||||
srcOrFile={row.external_url}
|
||||
/>
|
||||
) : null}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* Background Color */}
|
||||
{showBackgroundColor && (
|
||||
<TableCell>{row.background_color}</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Portal containerRef={portalRef}>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IconButton
|
||||
aria-label="first page"
|
||||
icon={<ChevronFirstIcon className="size-4" />}
|
||||
isDisabled={!canPreviousPage}
|
||||
onClick={() => gotoPage(0)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="previous page"
|
||||
icon={<ChevronLeftIcon className="size-4" />}
|
||||
isDisabled={!canPreviousPage}
|
||||
onClick={() => previousPage()}
|
||||
/>
|
||||
<p className="whitespace-nowrap">
|
||||
Page <strong>{pageIndex + 1}</strong> of{" "}
|
||||
<strong>{pageOptions.length}</strong>
|
||||
</p>
|
||||
<IconButton
|
||||
aria-label="next page"
|
||||
icon={<ChevronRightIcon className="size-4" />}
|
||||
isDisabled={!canNextPage}
|
||||
onClick={() => nextPage()}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="last page"
|
||||
icon={<ChevronLastIcon className="size-4" />}
|
||||
isDisabled={!canNextPage}
|
||||
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>
|
||||
{showPagination && (
|
||||
<div className="border-t py-5">
|
||||
<PaginationButtons
|
||||
activePage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageClick={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
</Portal>
|
||||
</Flex>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,18 +42,18 @@ export const SelectOption: React.FC<SelectOptionProps> = ({
|
||||
disabled
|
||||
? "pointer-events-none cursor-not-allowed bg-muted"
|
||||
: "cursor-pointer",
|
||||
isActive && "border-primary",
|
||||
isActive && "bg-muted/50 border-active-border",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
{...divProps}
|
||||
>
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="flex flex-row items-start">
|
||||
<div className="mt-0.5 mr-2.5 flex h-4 w-4 items-center justify-center rounded-full border border-inverted text-inverted">
|
||||
<div className="flex flex-row items-start gap-4">
|
||||
<div className="flex size-5 shrink-0 items-center justify-center rounded-full border border-inverted text-inverted">
|
||||
{isActive && <div className="h-2 w-2 rounded-full bg-inverted" />}
|
||||
</div>
|
||||
<div className="ml-4 flex flex-col gap-2 self-start">
|
||||
<div>
|
||||
<h4
|
||||
className={cn(
|
||||
"font-semibold text-sm",
|
||||
@@ -62,7 +62,11 @@ export const SelectOption: React.FC<SelectOptionProps> = ({
|
||||
>
|
||||
{name}
|
||||
</h4>
|
||||
{description && <p className="mt-1 text-sm">{description}</p>}
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{infoText && (
|
||||
|
||||
@@ -1,137 +1,299 @@
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import { UnorderedList } from "@/components/ui/List/List";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { DownloadableCode } from "../blocks/code/downloadable-code";
|
||||
import { DropZone } from "../blocks/drop-zone/drop-zone";
|
||||
|
||||
interface UploadStepProps {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
getRootProps: any;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
getInputProps: any;
|
||||
hasFailed: boolean;
|
||||
isDragActive: boolean;
|
||||
onDrop: (acceptedFiles: File[]) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const UploadStep: React.FC<UploadStepProps> = ({
|
||||
getRootProps,
|
||||
hasFailed,
|
||||
isDragActive,
|
||||
getInputProps,
|
||||
}) => {
|
||||
export function UploadStep(props: UploadStepProps) {
|
||||
const [tab, setTab] = useState<"csv" | "json">("csv");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8 md:flex-row">
|
||||
<div className="relative aspect-square w-full md:w-1/2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full cursor-pointer rounded-md border border-border hover:border-blue-500",
|
||||
hasFailed ? "bg-red-200" : "bg-background",
|
||||
)}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<div className="m-auto flex flex-col p-6">
|
||||
<UploadIcon
|
||||
className={cn(
|
||||
"mx-auto mb-2 size-8",
|
||||
hasFailed ? "text-red-500" : "text-gray-600",
|
||||
)}
|
||||
<div className="container px-0 max-w-3xl">
|
||||
<h2 className="text-2xl font-semibold tracking-tight mb-4">
|
||||
Upload NFTs
|
||||
</h2>
|
||||
|
||||
<DropZone
|
||||
className="w-full py-24"
|
||||
onDrop={props.onDrop}
|
||||
isError={props.hasFailed}
|
||||
title={props.hasFailed ? "Invalid files" : "Upload files"}
|
||||
description={
|
||||
props.hasFailed
|
||||
? "Make sure the uploaded files follow the requirements mentioned below"
|
||||
: "Drag and drop the files or folder"
|
||||
}
|
||||
resetButton={{
|
||||
label: "Reset",
|
||||
onClick: props.reset,
|
||||
}}
|
||||
accept={undefined}
|
||||
/>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
<div className="w-full">
|
||||
<TabButtons
|
||||
tabs={[
|
||||
{
|
||||
isActive: tab === "csv",
|
||||
name: "CSV",
|
||||
onClick: () => setTab("csv"),
|
||||
},
|
||||
{
|
||||
isActive: tab === "json",
|
||||
name: "JSON",
|
||||
onClick: () => setTab("json"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="space-y-8">
|
||||
<Section title="Uploading Files">
|
||||
<p>
|
||||
Make sure to drag and drop the {tab === "csv" ? "CSV" : "JSON"}{" "}
|
||||
and the assets at the same time or upload a folder with all the
|
||||
files in it.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Asset names <em>must</em> be sequential 0,1,2,3...n.[extension].
|
||||
It doesn't matter at what number you begin. Example: 131.png,
|
||||
132.png etc.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Images and other file types can be used in combination. They both
|
||||
have to follow the asset naming convention above. Example: 0.png
|
||||
and 0.mp4, 1.png and 1.glb etc.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title={`${tab === "csv" ? "CSV" : "JSON"} File Format`}>
|
||||
<p>
|
||||
The {tab === "csv" ? "CSV" : "JSON"} file must have a{" "}
|
||||
<InlineCode code="name" /> {tab === "csv" ? "column" : "property"}
|
||||
, which defines the name of the NFT
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Other optional {tab === "csv" ? "columns" : "properties"} are{" "}
|
||||
<InlineCode code="description" />,{" "}
|
||||
<InlineCode code="background_color" />,{" "}
|
||||
<InlineCode code="external_url" />,{" "}
|
||||
<InlineCode code="animation_url" />,{" "}
|
||||
{tab === "json" && (
|
||||
<>
|
||||
,
|
||||
<InlineCode code="attributes" />
|
||||
</>
|
||||
)}{" "}
|
||||
and <InlineCode code="supply" />
|
||||
{". "}
|
||||
{tab === "csv" &&
|
||||
"All other columns will be treated as Attributes. For example: See 'eyes', 'nose' below."}
|
||||
</p>
|
||||
|
||||
<DownloadableCode
|
||||
code={tab === "csv" ? csv_example_basic : json_example_basic}
|
||||
fileNameWithExtension={
|
||||
tab === "csv" ? "example.csv" : "example.json"
|
||||
}
|
||||
lang={tab === "csv" ? "csv" : "json"}
|
||||
/>
|
||||
{isDragActive ? (
|
||||
<p className="text-center text-muted-foreground">
|
||||
Drop the files here
|
||||
</p>
|
||||
) : (
|
||||
<p
|
||||
className={cn(
|
||||
"text-center leading-[1.2]",
|
||||
hasFailed ? "text-red-500" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hasFailed
|
||||
? `No valid CSV or JSON file found. Please make sure your NFT metadata includes at least a "name" field and try again.`
|
||||
: "Drag & Drop files or folders here, or click to select files"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Map your assets to NFTs">
|
||||
<p>
|
||||
If you want to map your media files to your NFTs, you can add the
|
||||
name of your files to the <InlineCode code="image" /> and{" "}
|
||||
<InlineCode code="animation_url" />{" "}
|
||||
{tab === "csv" ? "columns" : "properties"}.{" "}
|
||||
<DownloadableCode
|
||||
code={
|
||||
tab === "csv"
|
||||
? csv_with_image_number_example
|
||||
: json_with_image_number_example
|
||||
}
|
||||
fileNameWithExtension={
|
||||
tab === "csv"
|
||||
? "example-with-maps.csv"
|
||||
: "example-with-maps.json"
|
||||
}
|
||||
lang={tab === "csv" ? "csv" : "json"}
|
||||
/>
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Using asset links instead of uploading assets">
|
||||
<p>
|
||||
When uploading files, assets will be uploaded and pinned to IPFS
|
||||
automatically. If you already have the files uploaded elsewhere,
|
||||
you can specify the IPFS or HTTP(s) links in the{" "}
|
||||
<InlineCode code="image" /> and/or{" "}
|
||||
<InlineCode code="animation_url" />{" "}
|
||||
{tab === "csv" ? "columns" : "properties"}. instead of uploading
|
||||
the assets and just upload a single{" "}
|
||||
{tab === "csv" ? "CSV" : "JSON"} file.
|
||||
<DownloadableCode
|
||||
code={
|
||||
tab === "csv"
|
||||
? csv_with_image_link_example
|
||||
: json_with_image_link_example
|
||||
}
|
||||
fileNameWithExtension={
|
||||
tab === "csv"
|
||||
? "example-with-ipfs.csv"
|
||||
: "example-with-ipfs.json"
|
||||
}
|
||||
lang={tab === "csv" ? "csv" : "json"}
|
||||
/>
|
||||
</p>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 md:w-1/2">
|
||||
<p className="text-lg">Requirements</p>
|
||||
<UnorderedList>
|
||||
<li>
|
||||
Files <em>must</em> contain one .csv or .json file with metadata. -{" "}
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/example.csv"
|
||||
>
|
||||
Download example.csv
|
||||
</a>
|
||||
.{" "}
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/example.json"
|
||||
>
|
||||
Download example.json
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
The csv <em>must</em> have a <InlineCode code="name" /> column,
|
||||
which defines the name of the NFT.
|
||||
</li>
|
||||
<li>
|
||||
Asset names <em>must</em> be sequential 0,1,2,3...n.[extension]. It
|
||||
doesn't matter at what number you begin. (Example:{" "}
|
||||
<InlineCode code="131.png" />, <InlineCode code="132.png" />
|
||||
).
|
||||
</li>
|
||||
<li>
|
||||
Make sure to drag and drop the CSV/JSON and the images{" "}
|
||||
<strong>at the same time</strong>.
|
||||
</li>
|
||||
</UnorderedList>
|
||||
<p className="mt-4 text-lg">Options</p>
|
||||
<UnorderedList>
|
||||
<li>
|
||||
Images and other file types can be used in combination.
|
||||
<br />
|
||||
<small>
|
||||
They both have to follow the asset naming convention above.
|
||||
(Example: <InlineCode code="0.png" /> and{" "}
|
||||
<InlineCode code="0.mp4" />,<InlineCode code="1.png" /> and{" "}
|
||||
<InlineCode code="1.glb" />, etc.)
|
||||
</small>
|
||||
</li>
|
||||
<li>
|
||||
When uploading files, we will upload them and pin them to IPFS
|
||||
automatically for you. If you already have the files uploaded, you
|
||||
can add an <InlineCode code="image" /> and/or
|
||||
<InlineCode code="animation_url" /> column and add the IPFS hashes
|
||||
there.{" "}
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/example-with-ipfs.csv"
|
||||
>
|
||||
Download example.csv
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
If you want to make your media files map to your NFTs, you can add
|
||||
the name of your files to the <InlineCode code="image" /> and
|
||||
<InlineCode code="animation_url" /> column.{" "}
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/example-with-maps.csv"
|
||||
>
|
||||
Download example.csv
|
||||
</a>
|
||||
</li>
|
||||
</UnorderedList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function Section(props: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-1 font-medium text-base text-foreground">
|
||||
{props.title}
|
||||
</h2>
|
||||
<div className="space-y-1.5 text-muted-foreground leading-relaxed">
|
||||
{props.children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const csv_example_basic = `\
|
||||
name,description,background_color,eyes,nose
|
||||
Token 0 Name,Token 0 Description,#0098EE,red,green
|
||||
Token 1 Name,Token 1 Description,#0098EE,blue,yellow`;
|
||||
|
||||
const json_example_basic = `\
|
||||
[
|
||||
{
|
||||
"name": "Token 0 Name",
|
||||
"description": "Token 0 Description",
|
||||
"background_color": "#0098EE",
|
||||
"attributes": [
|
||||
{
|
||||
"trait_type": "eyes",
|
||||
"value": "red"
|
||||
},
|
||||
{
|
||||
"trait_type": "nose",
|
||||
"value": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Token 1 Name",
|
||||
"description": "Token 1 Description",
|
||||
"background_color": "#0098EE",
|
||||
"attributes": [
|
||||
{
|
||||
"trait_type": "eyes",
|
||||
"value": "blue"
|
||||
},
|
||||
{
|
||||
"trait_type": "nose",
|
||||
"value": "yellow"
|
||||
}
|
||||
]
|
||||
}
|
||||
]`;
|
||||
|
||||
const csv_with_image_link_example = `\
|
||||
name,description,image,animation_url,background_color,eyes,nose
|
||||
Token 0 Name,Token 0 Description,ipfs://ipfsHash/0,ipfs://ipfsHash/0,#0098EE,red,green
|
||||
Token 1 Name,Token 1 Description,ipfs://ipfsHash/1,ipfs://ipfsHash/1,#0098EE,blue,yellow`;
|
||||
|
||||
const json_with_image_link_example = `\
|
||||
[
|
||||
{
|
||||
"name": "Token 0 Name",
|
||||
"description": "Token 0 Description",
|
||||
"image": "ipfs://ipfsHash/0",
|
||||
"animation_url": "ipfs://ipfsHash/0",
|
||||
"attributes": [
|
||||
{
|
||||
"trait_type": "eyes",
|
||||
"value": "red"
|
||||
},
|
||||
{
|
||||
"trait_type": "nose",
|
||||
"value": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Token 1 Name",
|
||||
"description": "Token 1 Description",
|
||||
"image": "ipfs://ipfsHash/1",
|
||||
"animation_url": "ipfs://ipfsHash/1",
|
||||
"attributes": [
|
||||
{
|
||||
"trait_type": "eyes",
|
||||
"value": "blue"
|
||||
},
|
||||
{
|
||||
"trait_type": "nose",
|
||||
"value": "yellow"
|
||||
}
|
||||
]
|
||||
}
|
||||
]`;
|
||||
|
||||
const csv_with_image_number_example = `\
|
||||
name,description,image,animation_url,background_color,eyes,nose
|
||||
Token 0 Name,Token 0 Description,0.png,0.mp4,#0098EE,red,green
|
||||
Token 1 Name,Token 1 Description,1.png,1.mp4,#0098EE,blue,yellow`;
|
||||
|
||||
const json_with_image_number_example = `\
|
||||
[
|
||||
{
|
||||
"name": "Token 0 Name",
|
||||
"description": "Token 0 Description",
|
||||
"image": "0.png",
|
||||
"animation_url": "0.mp4",
|
||||
"attributes": [
|
||||
{
|
||||
"trait_type": "eyes",
|
||||
"value": "red"
|
||||
},
|
||||
{
|
||||
"trait_type": "nose",
|
||||
"value": "green"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Token 1 Name",
|
||||
"description": "Token 1 Description",
|
||||
"image": "1.png",
|
||||
"animation_url": "1.mp4",
|
||||
"attributes": [
|
||||
{
|
||||
"trait_type": "eyes",
|
||||
"value": "blue"
|
||||
},
|
||||
{
|
||||
"trait_type": "nose",
|
||||
"value": "yellow"
|
||||
}
|
||||
]
|
||||
}
|
||||
]`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { isAddress, NATIVE_TOKEN_ADDRESS, ZERO_ADDRESS } from "thirdweb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -109,10 +110,10 @@ export function CurrencySelector({
|
||||
className="rounded-r-none rounded-l-md"
|
||||
onClick={() => setIsAddingCurrency(false)}
|
||||
>
|
||||
<-
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
className="w-full rounded-none"
|
||||
className={cn("w-full rounded-none", className)}
|
||||
onChange={(e) => setEditCustomCurrency(e.target.value)}
|
||||
placeholder="ERC20 Address"
|
||||
required
|
||||
@@ -156,7 +157,7 @@ export function CurrencySelector({
|
||||
: value?.toLowerCase()
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={className}>
|
||||
<SelectValue placeholder="Select Currency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { FilePreview } from "@app/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/file-preview";
|
||||
import { FilePlusIcon, UploadIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
useDropzone,
|
||||
} from "react-dropzone";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { FilePreview } from "@/components/blocks/file-preview";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AsteriskIcon, InfoIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function FormFieldSetup(props: {
|
||||
htmlFor?: string;
|
||||
@@ -10,13 +11,22 @@ export function FormFieldSetup(props: {
|
||||
children: React.ReactNode;
|
||||
tooltip?: React.ReactNode;
|
||||
isRequired: boolean;
|
||||
labelClassName?: string;
|
||||
labelContainerClassName?: string;
|
||||
helperText?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<div className="mb-2 inline-flex items-center gap-1">
|
||||
<Label htmlFor={props.htmlFor}>{props.label}</Label>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 inline-flex items-center gap-1",
|
||||
props.labelContainerClassName,
|
||||
)}
|
||||
>
|
||||
<Label htmlFor={props.htmlFor} className={props.labelClassName}>
|
||||
{props.label}
|
||||
</Label>
|
||||
|
||||
{props.isRequired && (
|
||||
<AsteriskIcon className="size-3.5 text-destructive-text" />
|
||||
|
||||
@@ -11,9 +11,10 @@ import { SelectWithSearch } from "@/components/blocks/select-with-search";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { useTokensData } from "@/hooks/tokens";
|
||||
import { replaceIpfsUrl } from "@/lib/sdk";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fallbackChainIcon } from "@/utils/chain-icons";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { Spinner } from "../ui/Spinner/Spinner";
|
||||
|
||||
type Option = { label: string; value: string };
|
||||
|
||||
@@ -121,7 +122,10 @@ export function TokenSelector(props: {
|
||||
return option.label;
|
||||
}
|
||||
const resolvedSrc = token.iconUri
|
||||
? replaceIpfsUrl(token.iconUri, props.client)
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: token.iconUri,
|
||||
})
|
||||
: fallbackChainIcon;
|
||||
|
||||
return (
|
||||
@@ -186,9 +190,14 @@ export function TokenSelector(props: {
|
||||
options={options}
|
||||
overrideSearchFn={searchFn}
|
||||
placeholder={
|
||||
tokensQuery.isPending
|
||||
? "Loading Tokens"
|
||||
: props.placeholder || "Select Token"
|
||||
tokensQuery.isPending ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
<span>Loading tokens</span>
|
||||
</div>
|
||||
) : (
|
||||
props.placeholder || "Select Token"
|
||||
)
|
||||
}
|
||||
popoverContentClassName={props.popoverContentClassName}
|
||||
renderOption={renderOption}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type React from "react";
|
||||
import { type Dispatch, type SetStateAction, useMemo } from "react";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type CodeEnvironment =
|
||||
| "javascript"
|
||||
@@ -50,6 +51,7 @@ interface CodeSegmentProps {
|
||||
isInstallCommand?: boolean;
|
||||
hideTabs?: boolean;
|
||||
onlyTabs?: boolean;
|
||||
codeContainerClassName?: string;
|
||||
}
|
||||
|
||||
export const CodeSegment: React.FC<CodeSegmentProps> = ({
|
||||
@@ -59,6 +61,7 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
|
||||
isInstallCommand,
|
||||
hideTabs,
|
||||
onlyTabs,
|
||||
codeContainerClassName,
|
||||
}) => {
|
||||
const activeEnvironment: CodeEnvironment = useMemo(() => {
|
||||
return (
|
||||
@@ -86,7 +89,7 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex flex-col overflow-hidden rounded-lg border border-border"
|
||||
"flex flex-col overflow-hidden rounded-lg border border-border grow"
|
||||
}
|
||||
>
|
||||
{!hideTabs && (
|
||||
@@ -106,7 +109,11 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
|
||||
|
||||
{onlyTabs ? null : (
|
||||
<CodeClient
|
||||
className="rounded-none border-none"
|
||||
className={cn(
|
||||
"rounded-none border-none grow",
|
||||
codeContainerClassName,
|
||||
)}
|
||||
scrollableContainerClassName="h-full"
|
||||
code={code}
|
||||
lang={
|
||||
isInstallCommand
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
import { ArrowDownToLineIcon, FileTextIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { handleDownload } from "../download-file-button";
|
||||
|
||||
export function DownloadableCode(props: {
|
||||
code: string;
|
||||
lang: "csv" | "json";
|
||||
fileNameWithExtension: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="!my-5 bg-card">
|
||||
<p className="text-sm text-muted-foreground border border-b-0 px-4 py-3 pr-3.5 rounded-lg rounded-b-none flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
{props.fileNameWithExtension}
|
||||
</span>
|
||||
<Button
|
||||
className="h-auto bg-background p-2"
|
||||
onClick={() => {
|
||||
handleDownload({
|
||||
fileContent: props.code,
|
||||
fileFormat:
|
||||
props.lang === "csv" ? "text/csv" : "application/json",
|
||||
fileNameWithExtension: props.fileNameWithExtension,
|
||||
});
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowDownToLineIcon className="size-3" />
|
||||
</Button>
|
||||
</p>
|
||||
<div className="relative">
|
||||
<CodeClient
|
||||
code={props.code}
|
||||
lang={props.lang}
|
||||
scrollableClassName="max-h-[300px] bg-background bg-card"
|
||||
className="rounded-t-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+7
-2
@@ -1,5 +1,6 @@
|
||||
import { ArrowDownToLineIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function handleDownload(params: {
|
||||
fileContent: string;
|
||||
@@ -22,10 +23,14 @@ export function DownloadFileButton(props: {
|
||||
fileNameWithExtension: string;
|
||||
fileFormat: "text/csv" | "application/json";
|
||||
label: string;
|
||||
variant?: "default" | "outline";
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className="gap-2"
|
||||
className={cn("gap-2", props.className)}
|
||||
variant={props.variant}
|
||||
onClick={() => {
|
||||
handleDownload({
|
||||
fileContent: props.fileContent,
|
||||
@@ -35,7 +40,7 @@ export function DownloadFileButton(props: {
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<ArrowDownToLineIcon className="size-4" />
|
||||
<ArrowDownToLineIcon className={cn("size-4", props.iconClassName)} />
|
||||
{props.label}
|
||||
</Button>
|
||||
);
|
||||
@@ -26,7 +26,7 @@ export function DropZone(props: {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-center rounded-md border border-dashed bg-card py-10 hover:border-active-border",
|
||||
"flex cursor-pointer items-center justify-center rounded-md border border-dashed bg-card py-10 hover:border-active-border px-4",
|
||||
props.isError &&
|
||||
"border-red-500 bg-red-200/30 text-red-500 hover:border-red-600 dark:border-red-900 dark:bg-red-900/30 dark:hover:border-red-800",
|
||||
props.className,
|
||||
|
||||
+10
-2
@@ -37,7 +37,12 @@ export function FilePreview(props: {
|
||||
|
||||
if (!objectUrl) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center", props.className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center bg-accent",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<ImageOffIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
@@ -56,7 +61,10 @@ export function FilePreview(props: {
|
||||
|
||||
return (
|
||||
<MediaRenderer
|
||||
className={cn(props.className, "[&>div]:!bg-muted")}
|
||||
className={cn(
|
||||
props.className,
|
||||
"[&>div]:!bg-accent [&_a]:!text-muted-foreground [&_a]:!no-underline [&_svg]:!size-6 [&_svg]:!text-muted-foreground relative overflow-hidden",
|
||||
)}
|
||||
client={props.client}
|
||||
src={objectUrl}
|
||||
/>
|
||||
@@ -94,7 +94,7 @@ export const MarkdownRenderer: React.FC<{
|
||||
<h2
|
||||
className={cn(
|
||||
commonHeadingClassName,
|
||||
"mb-3 border-border border-b pb-2 text-xl md:text-2xl",
|
||||
"mb-3 border-dashed border-b pb-2 text-xl md:text-2xl",
|
||||
)}
|
||||
{...cleanedProps(props)}
|
||||
/>
|
||||
@@ -105,7 +105,7 @@ export const MarkdownRenderer: React.FC<{
|
||||
{...cleanedProps(props)}
|
||||
className={cn(
|
||||
commonHeadingClassName,
|
||||
"mt-8 mb-3 border-border border-b pb-2 text-lg md:text-xl",
|
||||
"mt-8 mb-3 border-dashed border-b pb-2 text-lg md:text-xl",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
@@ -147,7 +147,7 @@ export const MarkdownRenderer: React.FC<{
|
||||
li: ({ children: c, ...props }) => (
|
||||
<li
|
||||
className={cn(
|
||||
"mb-1.5 text-muted-foreground leading-loose [&>p]:m-0",
|
||||
"text-muted-foreground leading-relaxed [&>p]:m-0",
|
||||
markdownProps.li?.className,
|
||||
)}
|
||||
{...cleanedProps(props)}
|
||||
@@ -157,7 +157,7 @@ export const MarkdownRenderer: React.FC<{
|
||||
),
|
||||
ol: (props) => (
|
||||
<ol
|
||||
className="mb-4 list-outside list-decimal pl-5 [&_ol_li:first-of-type]:mt-1.5 [&_ul_li:first-of-type]:mt-1.5"
|
||||
className="mb-4 list-outside list-decimal pl-4 space-y-2 [&>li]:first:mt-2"
|
||||
{...cleanedProps(props)}
|
||||
/>
|
||||
),
|
||||
@@ -165,7 +165,7 @@ export const MarkdownRenderer: React.FC<{
|
||||
p: (props) => (
|
||||
<p
|
||||
className={cn(
|
||||
"mb-4 text-muted-foreground leading-loose",
|
||||
"mb-3 text-muted-foreground leading-7",
|
||||
markdownProps.p?.className,
|
||||
)}
|
||||
{...cleanedProps(props)}
|
||||
@@ -201,7 +201,7 @@ export const MarkdownRenderer: React.FC<{
|
||||
ul: (props) => {
|
||||
return (
|
||||
<ul
|
||||
className="mb-4 list-outside list-disc pl-5 [&_ol_li:first-of-type]:mt-1.5 [&_ul_li:first-of-type]:mt-1.5"
|
||||
className="mb-4 list-outside list-disc pl-4 space-y-2 [&>li]:first:mt-2"
|
||||
{...cleanedProps(props)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ interface SelectWithSearchProps
|
||||
}[];
|
||||
value: string | undefined;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
placeholder: string | React.ReactNode;
|
||||
searchPlaceholder?: string;
|
||||
className?: string;
|
||||
overrideSearchFn?: (
|
||||
|
||||
@@ -23,7 +23,6 @@ export function WalletAddress(props: {
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
client: ThirdwebClient;
|
||||
preventOpenOnFocus?: boolean;
|
||||
}) {
|
||||
// default back to zero address if no address provided
|
||||
const address = useMemo(() => props.address || ZERO_ADDRESS, [props.address]);
|
||||
@@ -68,10 +67,7 @@ export function WalletAddress(props: {
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger
|
||||
asChild
|
||||
tabIndex={props.preventOpenOnFocus ? -1 : undefined}
|
||||
>
|
||||
<HoverCardTrigger asChild tabIndex={-1}>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex flex-row items-center gap-2 px-0",
|
||||
|
||||
+29
-25
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
import { Flex, FormControl } from "@chakra-ui/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { verifyContract } from "app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/sources/ContractSourcesPage";
|
||||
import { FormHelperText, FormLabel } from "chakra/form";
|
||||
import { Text } from "chakra/text";
|
||||
import {
|
||||
ArrowUpFromLineIcon,
|
||||
CircleAlertIcon,
|
||||
@@ -27,6 +24,7 @@ import {
|
||||
reportContractDeployed,
|
||||
reportContractDeployFailed,
|
||||
} from "@/analytics/report";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { NetworkSelectorButton } from "@/components/misc/NetworkSelectorButton";
|
||||
import { SolidityInput } from "@/components/solidity-inputs";
|
||||
import {
|
||||
@@ -932,14 +930,17 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
<Fieldset legend="Deploy Options">
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Chain */}
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Chain</FormLabel>
|
||||
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
Select a network to deploy this contract on. We recommend
|
||||
starting with a testnet
|
||||
</p>
|
||||
|
||||
<FormFieldSetup
|
||||
isRequired
|
||||
label="Chain"
|
||||
errorMessage={undefined}
|
||||
helperText={
|
||||
<span>
|
||||
Select a network to deploy this contract on. We recommend
|
||||
starting with a testnet
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<NetworkSelectorButton
|
||||
@@ -953,7 +954,6 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
: metadata?.networksForDeployment?.networksEnabled
|
||||
}
|
||||
/>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
className="gap-3"
|
||||
@@ -967,12 +967,11 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
|
||||
{metadata?.deployType === "standard" && (
|
||||
<>
|
||||
{/* Deterministic deploy */}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
@@ -1006,16 +1005,21 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
|
||||
{/* Optional Salt Input */}
|
||||
{isCreate2Deployment && (
|
||||
<FormControl>
|
||||
<Flex alignItems="center" my={1}>
|
||||
<FormLabel display="flex" flex="1" mb={0}>
|
||||
<Flex alignItems="baseline" gap={1}>
|
||||
Optional Salt Input
|
||||
<Text size="label.sm">(saltForCreate2)</Text>
|
||||
</Flex>
|
||||
</FormLabel>
|
||||
<FormHelperText mt={0}>string</FormHelperText>
|
||||
</Flex>
|
||||
<FormFieldSetup
|
||||
isRequired={false}
|
||||
label={
|
||||
<span className="flex items-center gap-1">
|
||||
Optional Salt Input
|
||||
<span className="font-normal text-muted-foreground text-xs">
|
||||
(saltForCreate2)
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
errorMessage={
|
||||
form.getFieldState("saltForCreate2", form.formState)
|
||||
.error?.message
|
||||
}
|
||||
>
|
||||
<SolidityInput
|
||||
solidityType="string"
|
||||
{...form.register("saltForCreate2")}
|
||||
@@ -1034,7 +1038,7 @@ export const CustomContractForm: React.FC<CustomContractFormProps> = ({
|
||||
Include deployer wallet address in salt (recommended)
|
||||
</span>
|
||||
</CheckboxWithLabel>
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
+9
-15
@@ -1,8 +1,7 @@
|
||||
import { FormControl } from "@chakra-ui/react";
|
||||
import { FormErrorMessage, FormLabel } from "chakra/form";
|
||||
import { useMemo } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { FetchDeployMetadataResult } from "thirdweb/contract";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { SolidityInput } from "@/components/solidity-inputs";
|
||||
import type { CustomContractDeploymentForm } from "./custom-contract";
|
||||
import { PrimarySaleFieldset } from "./primary-sale-fieldset";
|
||||
@@ -104,14 +103,15 @@ function RenderModule(props: {
|
||||
`moduleData.${module.name}.${param.name}` as const;
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState(formFieldKey, form.formState).error
|
||||
}
|
||||
isRequired
|
||||
<FormFieldSetup
|
||||
key={formFieldKey}
|
||||
htmlFor={formFieldKey}
|
||||
label={param.name}
|
||||
isRequired={true}
|
||||
errorMessage={
|
||||
form.getFieldState(formFieldKey, form.formState).error?.message
|
||||
}
|
||||
>
|
||||
<FormLabel> {param.name}</FormLabel>
|
||||
<SolidityInput
|
||||
client={props.client}
|
||||
// @ts-expect-error - old types, need to update
|
||||
@@ -119,13 +119,7 @@ function RenderModule(props: {
|
||||
solidityType={param.type}
|
||||
{...form.register(formFieldKey)}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState(formFieldKey, form.formState).error
|
||||
?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
+9
-11
@@ -1,8 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { BasisPointsInput } from "@/components/blocks/BasisPointsInput";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { SolidityInput } from "@/components/solidity-inputs";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { Fieldset } from "./common";
|
||||
import type { CustomContractDeploymentForm } from "./custom-contract";
|
||||
|
||||
@@ -78,30 +78,28 @@ export const PlatformFeeFieldset: React.FC<PlatformFeeFieldsetProps> = ({
|
||||
</FormFieldSetup>
|
||||
</>
|
||||
) : isMarketplace ? (
|
||||
<p className="mb-3 pt-4 text-muted-foreground text-sm italic">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
A 2.5% platform fee is deducted from each sale to support ongoing
|
||||
platform operations and improvements.{" "}
|
||||
<Link
|
||||
className="text-blue-500 underline"
|
||||
platform operations and improvements. <br />
|
||||
<UnderlineLink
|
||||
href={"https://thirdweb.com/pricing"}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
See fee breakdown on pricing page.
|
||||
</Link>
|
||||
</UnderlineLink>
|
||||
</p>
|
||||
) : (
|
||||
<p className="mb-3 pt-4 text-muted-foreground text-sm italic">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
A 2.5% platform fee is deducted from each primary sale price to
|
||||
support ongoing platform operations and improvements.{" "}
|
||||
<Link
|
||||
className="text-blue-500 underline"
|
||||
support ongoing platform operations and improvements. <br />
|
||||
<UnderlineLink
|
||||
href={"https://thirdweb.com/pricing"}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
See fee breakdown on pricing page.
|
||||
</Link>
|
||||
</UnderlineLink>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+27
-30
@@ -1,20 +1,24 @@
|
||||
import type { Abi } from "abitype";
|
||||
import { Select } from "chakra-react-select";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
interface AbiSelectorProps {
|
||||
abi: Abi;
|
||||
defaultValue: string;
|
||||
value: string;
|
||||
onChange: (fn: string) => void;
|
||||
}
|
||||
|
||||
export const AbiSelector: React.FC<AbiSelectorProps> = ({
|
||||
export function AbiSelector({
|
||||
abi,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
}) => {
|
||||
}: {
|
||||
abi: Abi;
|
||||
defaultValue: string;
|
||||
value: string;
|
||||
onChange: (fn: string) => void;
|
||||
}) {
|
||||
const options = useMemo(() => {
|
||||
return abi
|
||||
.filter((f) => f.type === "function")
|
||||
@@ -26,24 +30,17 @@ export const AbiSelector: React.FC<AbiSelectorProps> = ({
|
||||
}, [abi]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-row items-center gap-2">
|
||||
<Select
|
||||
chakraStyles={{
|
||||
container: (provided) => ({
|
||||
...provided,
|
||||
width: "full",
|
||||
}),
|
||||
}}
|
||||
defaultValue={options.find((o) => o.value === defaultValue)}
|
||||
onChange={(selectedFn) => {
|
||||
if (selectedFn) {
|
||||
onChange((selectedFn as { label: string; value: string }).value);
|
||||
}
|
||||
}}
|
||||
options={options}
|
||||
placeholder="Select function"
|
||||
value={options.find((o) => o.value === value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={value} onValueChange={onChange} defaultValue={defaultValue}>
|
||||
<SelectTrigger className="w-full bg-card">
|
||||
<SelectValue placeholder="Select function" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+25
-29
@@ -1,4 +1,3 @@
|
||||
import { FormControl, useBreakpointValue } from "@chakra-ui/react";
|
||||
import type { AbiParameter } from "abitype";
|
||||
import { useId, useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
@@ -12,6 +11,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { getTemplateValuesForType } from "@/lib/deployment/template-values";
|
||||
import { DecodedInputArrayFieldset } from "./decoded-bytes-input/decoded-input-array-fieldset";
|
||||
import { RefInputFieldset } from "./ref-contract-input/ref-input-fieldset";
|
||||
@@ -25,7 +25,7 @@ export const ContractParamsFieldset: React.FC<ContractParamsFieldsetProps> = ({
|
||||
client,
|
||||
}) => {
|
||||
const form = useFormContext();
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
const isMobile = useIsMobile();
|
||||
const displayNameId = useId();
|
||||
const descriptionId = useId();
|
||||
const [isCustomInputEnabledArray, setIsCustomInputEnabledArray] = useState(
|
||||
@@ -70,22 +70,20 @@ export const ContractParamsFieldset: React.FC<ContractParamsFieldsetProps> = ({
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<h2 className="font-semibold text-3xl tracking-tight">
|
||||
<h2 className="text-2xl font-semibold tracking-tight mb-1">
|
||||
Contract Parameters
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm mb-6">
|
||||
These are the parameters users will need to fill in when deploying this
|
||||
contract.
|
||||
</p>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<div className="flex flex-col gap-8">
|
||||
{deployParams.map((param, idx) => {
|
||||
const paramTemplateValues = getTemplateValuesForType(param.type);
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-border bg-card p-6"
|
||||
className="rounded-lg border border-border bg-card p-4"
|
||||
key={`implementation_${param.name}`}
|
||||
>
|
||||
{/* Title + Type */}
|
||||
@@ -100,12 +98,12 @@ export const ContractParamsFieldset: React.FC<ContractParamsFieldsetProps> = ({
|
||||
)}
|
||||
</h3>
|
||||
<InlineCode
|
||||
className="px-2 text-base text-muted-foreground"
|
||||
className="px-2 text-muted-foreground text-sm"
|
||||
code={param.type}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-5" />
|
||||
<div className="h-4" />
|
||||
|
||||
{/* Display Name */}
|
||||
<FormFieldSetup
|
||||
@@ -140,7 +138,7 @@ export const ContractParamsFieldset: React.FC<ContractParamsFieldsetProps> = ({
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
|
||||
<div className="h-5" />
|
||||
<div className="h-4" />
|
||||
|
||||
{/* Description */}
|
||||
<FormFieldSetup
|
||||
@@ -217,25 +215,23 @@ export const ContractParamsFieldset: React.FC<ContractParamsFieldsetProps> = ({
|
||||
|
||||
{/* Inputs */}
|
||||
{!isCustomInputEnabledArray[idx] ? (
|
||||
<FormControl>
|
||||
<SolidityInput
|
||||
className="!bg-background !text-sm placeholder:!text-sm"
|
||||
client={client}
|
||||
placeholder={
|
||||
isMobile ||
|
||||
paramTemplateValues?.[0]?.value ===
|
||||
"{{trusted_forwarders}}"
|
||||
? "Pre-filled value."
|
||||
: "This value will be pre-filled in the deploy form."
|
||||
}
|
||||
solidityType={param.type}
|
||||
{...form.register(
|
||||
`constructorParams.${
|
||||
param.name ? param.name : "*"
|
||||
}.defaultValue`,
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<SolidityInput
|
||||
className="!bg-background !text-sm placeholder:!text-sm"
|
||||
client={client}
|
||||
placeholder={
|
||||
isMobile ||
|
||||
paramTemplateValues?.[0]?.value ===
|
||||
"{{trusted_forwarders}}"
|
||||
? "Pre-filled value."
|
||||
: "This value will be pre-filled in the deploy form."
|
||||
}
|
||||
solidityType={param.type}
|
||||
{...form.register(
|
||||
`constructorParams.${
|
||||
param.name ? param.name : "*"
|
||||
}.defaultValue`,
|
||||
)}
|
||||
/>
|
||||
) : param.type === "address" || param.type === "address[]" ? (
|
||||
<RefInputFieldset client={client} param={param} />
|
||||
) : (
|
||||
|
||||
+127
-107
@@ -1,33 +1,26 @@
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
ListItem,
|
||||
UnorderedList,
|
||||
} from "@chakra-ui/react";
|
||||
import type { Abi } from "abitype";
|
||||
import { Button } from "chakra/button";
|
||||
import { Heading } from "chakra/heading";
|
||||
import { Text } from "chakra/text";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction, useEffect } from "react";
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useCustomFactoryAbi } from "@/hooks/contract-hooks";
|
||||
import { AbiSelector } from "./abi-selector";
|
||||
|
||||
interface CustomFactoryProps {
|
||||
export function CustomFactory(props: {
|
||||
setCustomFactoryAbi: Dispatch<SetStateAction<Abi>>;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const CustomFactory: React.FC<CustomFactoryProps> = ({
|
||||
setCustomFactoryAbi,
|
||||
client,
|
||||
}) => {
|
||||
}) {
|
||||
const { setCustomFactoryAbi, client } = props;
|
||||
const form = useFormContext();
|
||||
|
||||
const customFactoryAbi = useCustomFactoryAbi(
|
||||
@@ -35,7 +28,6 @@ export const CustomFactory: React.FC<CustomFactoryProps> = ({
|
||||
form.watch("customFactoryAddresses[0].value"),
|
||||
form.watch("customFactoryAddresses[0].key"),
|
||||
);
|
||||
|
||||
// FIXME: all of this logic needs to be reworked
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
@@ -58,102 +50,130 @@ export const CustomFactory: React.FC<CustomFactoryProps> = ({
|
||||
}, [fields, append]);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={12} pb={0} px={0}>
|
||||
<UnorderedList>
|
||||
<Text as={ListItem}>
|
||||
<div className="space-y-12">
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Use this if you want to invoke your own function with custom logic
|
||||
when users deploy your contract.
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You need to have factory contracts pre-deployed to all networks that
|
||||
you want to support.
|
||||
</Text>
|
||||
</UnorderedList>
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<Heading size="title.md">Factory contract addresses</Heading>
|
||||
<Text>
|
||||
Paste the contract address of your factory contracts. Your contract
|
||||
can be deployed only to networks with valid factory address.
|
||||
</Text>
|
||||
</Flex>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id}>
|
||||
<FormControl as={Flex} gap={4} isRequired>
|
||||
<Box w={{ base: "full", md: "40%" }}>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name={`customFactoryAddresses[${index}].key`}
|
||||
render={({ field: _field }) => {
|
||||
return (
|
||||
<SingleNetworkSelector
|
||||
chainId={_field.value}
|
||||
client={client}
|
||||
onChange={(value) => {
|
||||
_field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box w="full">
|
||||
<Input
|
||||
isRequired
|
||||
{...form.register(`customFactoryAddresses[${index}].value`)}
|
||||
placeholder="Factory contract address"
|
||||
/>
|
||||
</Box>
|
||||
<IconButton
|
||||
aria-label="Remove row"
|
||||
icon={<TrashIcon className="size-5" />}
|
||||
isDisabled={fields.length === 1 || form.formState.isSubmitting}
|
||||
onClick={() => remove(index)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Factory contract addresses */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold tracking-tight mb-1">
|
||||
Factory contract addresses
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Paste the contract address of your factory contracts. Your contract
|
||||
can be deployed only to networks with valid factory address.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 mb-5">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex flex-col lg:flex-row gap-4 lg:items-end"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`customFactoryAddresses[${index}].key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full lg:w-80 space-y-1.5">
|
||||
<FormLabel>Network</FormLabel>
|
||||
<SingleNetworkSelector
|
||||
chainId={field.value}
|
||||
disableChainId
|
||||
className="bg-card"
|
||||
client={client}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`customFactoryAddresses[${index}].value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="grow space-y-1.5">
|
||||
<FormLabel>Factory contract address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
required
|
||||
className="bg-card"
|
||||
{...field}
|
||||
placeholder="0x..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-full p-0 size-10 bg-card self-end"
|
||||
aria-label="Remove row"
|
||||
disabled={fields.length === 1 || form.formState.isSubmitting}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
leftIcon={<PlusIcon className="size-5" />}
|
||||
onClick={() => append({ key: "", value: "" })}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
className="rounded-full bg-card"
|
||||
onClick={() => append({ key: "", value: "" })}
|
||||
>
|
||||
Add Network
|
||||
<PlusIcon className="size-5 mr-2" /> Add Network
|
||||
</Button>
|
||||
</div>
|
||||
</Flex>
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<Heading size="title.md">Factory function</Heading>
|
||||
<Text>Choose the factory function to deploy your contracts.</Text>
|
||||
</Flex>
|
||||
<FormControl isRequired>
|
||||
{customFactoryAbi?.data ? (
|
||||
<AbiSelector
|
||||
abi={customFactoryAbi.data}
|
||||
defaultValue="deployProxyByImplementation"
|
||||
onChange={(selectedFn) =>
|
||||
form.setValue(
|
||||
"factoryDeploymentData.customFactoryInput.factoryFunction",
|
||||
selectedFn,
|
||||
)
|
||||
}
|
||||
value={form.watch(
|
||||
</div>
|
||||
|
||||
{/* Factory function */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold tracking-tight mb-1">
|
||||
Factory function
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Choose the factory function to deploy your contracts.
|
||||
</p>
|
||||
|
||||
{customFactoryAbi?.data ? (
|
||||
<AbiSelector
|
||||
abi={customFactoryAbi.data}
|
||||
defaultValue="deployProxyByImplementation"
|
||||
onChange={(selectedFn) =>
|
||||
form.setValue(
|
||||
"factoryDeploymentData.customFactoryInput.factoryFunction",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Text fontStyle="italic">
|
||||
Custom factory ABI not found. Please provide a valid imported
|
||||
contract on the previous step.
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</Flex>
|
||||
selectedFn,
|
||||
)
|
||||
}
|
||||
value={form.watch(
|
||||
"factoryDeploymentData.customFactoryInput.factoryFunction",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-red-500 font-medium">
|
||||
Custom factory ABI not found. Please provide a valid imported
|
||||
contract on the previous step.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+44
-48
@@ -1,10 +1,7 @@
|
||||
import { Flex, FormControl, ListItem, UnorderedList } from "@chakra-ui/react";
|
||||
import type { Abi } from "abitype";
|
||||
import { Heading } from "chakra/heading";
|
||||
import { Link } from "chakra/link";
|
||||
import { Text } from "chakra/text";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { UnderlineLink } from "../../ui/UnderlineLink";
|
||||
import { AbiSelector } from "./abi-selector";
|
||||
import { NetworksFieldset } from "./networks-fieldset";
|
||||
|
||||
@@ -13,63 +10,62 @@ interface DefaultFactoryProps {
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const DefaultFactory: React.FC<DefaultFactoryProps> = ({
|
||||
abi,
|
||||
client,
|
||||
}) => {
|
||||
export function DefaultFactory({ abi, client }: DefaultFactoryProps) {
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={12} pb={0} px={0}>
|
||||
<UnorderedList>
|
||||
<Text as={ListItem}>
|
||||
<div className="space-y-12">
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Default factory lets users deploy your contract to{" "}
|
||||
<strong>any EVM network</strong>.
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
<em className="font-medium">any EVM network</em>.
|
||||
</p>
|
||||
<p>
|
||||
The factory deploys EIP-1167 minimal proxies of your contract. This
|
||||
makes it much cheaper to deploy.
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
</p>
|
||||
<p>
|
||||
The factory is{" "}
|
||||
<Link
|
||||
color="primary.500"
|
||||
<UnderlineLink
|
||||
href="https://github.com/thirdweb-dev/contracts/blob/main/contracts/TWStatelessFactory.sol"
|
||||
isExternal
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
open-source
|
||||
</Link>
|
||||
</UnderlineLink>
|
||||
, permissionless, and does not alter contract ownership.
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
</p>
|
||||
<p>
|
||||
Your contract needs to be written in the upgradeable/initializable
|
||||
pattern. It needs to contain an initializer function.
|
||||
</Text>
|
||||
</UnorderedList>
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<Heading size="title.md">Initializer function</Heading>
|
||||
<Text>
|
||||
Choose the initializer function to invoke on your proxy contracts.
|
||||
</Text>
|
||||
</Flex>
|
||||
<FormControl isRequired>
|
||||
<AbiSelector
|
||||
abi={abi}
|
||||
defaultValue="initialize"
|
||||
onChange={(selectedFn) =>
|
||||
form.setValue(
|
||||
"factoryDeploymentData.implementationInitializerFunction",
|
||||
selectedFn,
|
||||
)
|
||||
}
|
||||
value={form.watch(
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Initializer function */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-1">Initializer function</h3>
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
Choose the initializer function to invoke on your proxy contracts.
|
||||
</p>
|
||||
|
||||
<AbiSelector
|
||||
abi={abi}
|
||||
defaultValue="initialize"
|
||||
onChange={(selectedFn) =>
|
||||
form.setValue(
|
||||
"factoryDeploymentData.implementationInitializerFunction",
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
selectedFn,
|
||||
)
|
||||
}
|
||||
value={form.watch(
|
||||
"factoryDeploymentData.implementationInitializerFunction",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Networks */}
|
||||
<NetworksFieldset client={client} />
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+12
-22
@@ -1,5 +1,4 @@
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLinksInput } from "./external-links-input";
|
||||
@@ -12,35 +11,26 @@ export const ExternalLinksFieldset = () => {
|
||||
name: "externalLinks",
|
||||
});
|
||||
|
||||
// FIXME: all of this logic needs to be reworked
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (fields.length === 0) {
|
||||
append(
|
||||
{
|
||||
name: "",
|
||||
url: "",
|
||||
},
|
||||
{ shouldFocus: false },
|
||||
);
|
||||
}
|
||||
}, [fields, append]);
|
||||
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="font-semibold text-xl tracking-tight">Resources</h3>
|
||||
<p className="text-muted-foreground">
|
||||
<fieldset>
|
||||
<div>
|
||||
<h3 className="font-semibold text-xl tracking-tight mb-1">Resources</h3>
|
||||
<p className="text-muted-foreground text-sm mb-5">
|
||||
Provide links to docs, usage guides etc. for the contract.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{fields.map((item, index) => (
|
||||
<ExternalLinksInput index={index} key={item.id} remove={remove} />
|
||||
<ExternalLinksInput
|
||||
index={index}
|
||||
key={item.id}
|
||||
remove={remove}
|
||||
disableRemove={fields.length === 1}
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
<Button
|
||||
className="gap-2"
|
||||
className="gap-2 bg-card rounded-full"
|
||||
onClick={() =>
|
||||
append({
|
||||
name: "",
|
||||
@@ -51,7 +41,7 @@ export const ExternalLinksFieldset = () => {
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon className="size-5" />
|
||||
<PlusIcon className="size-4" />
|
||||
Add Resource
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+62
-62
@@ -3,82 +3,82 @@ import { useFormContext } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface ExternalLinksInputProps {
|
||||
index: number;
|
||||
remove: (index: number) => void;
|
||||
disableRemove: boolean;
|
||||
}
|
||||
|
||||
export const ExternalLinksInput: React.FC<ExternalLinksInputProps> = ({
|
||||
index,
|
||||
remove,
|
||||
disableRemove,
|
||||
}) => {
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex w-full flex-col gap-4 md:flex-row md:gap-2">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<Label className="capitalize" htmlFor={`externalLinks.${index}.name`}>
|
||||
Resource Name
|
||||
</Label>
|
||||
<Input
|
||||
id={`externalLinks.${index}.name`}
|
||||
placeholder="Resource name"
|
||||
{...form.register(`externalLinks.${index}.name`)}
|
||||
/>
|
||||
{form.getFieldState(`externalLinks.${index}.name`, form.formState)
|
||||
.error?.message && (
|
||||
<p className="text-destructive-text text-sm">
|
||||
{
|
||||
form.getFieldState(
|
||||
`externalLinks.${index}.name`,
|
||||
form.formState,
|
||||
).error?.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<Label className="capitalize" htmlFor={`externalLinks.${index}.url`}>
|
||||
Link
|
||||
</Label>
|
||||
<Input
|
||||
id={`externalLinks.${index}.url`}
|
||||
placeholder="Provide URL to the resource page"
|
||||
{...form.register(`externalLinks.${index}.url`, {
|
||||
required: true,
|
||||
validate: (value: string) => {
|
||||
if (value.match(new RegExp(/^https:\/\/[^\s/$.?#].[^\s]*$/))) {
|
||||
return true;
|
||||
}
|
||||
return "Provide a valid URL";
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{form.getFieldState(`externalLinks.${index}.url`, form.formState)
|
||||
.error?.message && (
|
||||
<p className="text-destructive-text text-sm">
|
||||
{
|
||||
form.getFieldState(`externalLinks.${index}.url`, form.formState)
|
||||
.error?.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Remove row"
|
||||
className="self-end"
|
||||
onClick={() => remove(index)}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<TrashIcon className="size-5" />
|
||||
<span className="sr-only">Remove row</span>
|
||||
</Button>
|
||||
<div className="flex w-full flex-col gap-3 md:flex-row">
|
||||
{/* Resource name */}
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="capitalize" htmlFor={`externalLinks.${index}.name`}>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id={`externalLinks.${index}.name`}
|
||||
{...form.register(`externalLinks.${index}.name`)}
|
||||
className="bg-card"
|
||||
/>
|
||||
{form.getFieldState(`externalLinks.${index}.name`, form.formState).error
|
||||
?.message && (
|
||||
<p className="text-destructive-text text-sm">
|
||||
{
|
||||
form.getFieldState(`externalLinks.${index}.name`, form.formState)
|
||||
.error?.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{/* Resource URL */}
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label className="capitalize" htmlFor={`externalLinks.${index}.url`}>
|
||||
Link
|
||||
</Label>
|
||||
<Input
|
||||
id={`externalLinks.${index}.url`}
|
||||
className="bg-card"
|
||||
{...form.register(`externalLinks.${index}.url`, {
|
||||
required: true,
|
||||
validate: (value: string) => {
|
||||
if (value.match(new RegExp(/^https:\/\/[^\s/$.?#].[^\s]*$/))) {
|
||||
return true;
|
||||
}
|
||||
return "Provide a valid URL";
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{form.getFieldState(`externalLinks.${index}.url`, form.formState).error
|
||||
?.message && (
|
||||
<p className="text-destructive-text text-sm">
|
||||
{
|
||||
form.getFieldState(`externalLinks.${index}.url`, form.formState)
|
||||
.error?.message
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="self-end rounded-full p-0 bg-card"
|
||||
disabled={disableRemove}
|
||||
onClick={() => remove(index)}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
aria-label="Remove resource"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+39
-43
@@ -1,10 +1,8 @@
|
||||
import { ButtonGroup, Flex } from "@chakra-ui/react";
|
||||
import type { Abi } from "abitype";
|
||||
import { Button } from "chakra/button";
|
||||
import { Heading } from "chakra/heading";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { TabButtons } from "../../ui/tabs";
|
||||
import { CustomFactory } from "./custom-factory";
|
||||
import { DefaultFactory } from "./default-factory";
|
||||
|
||||
@@ -20,47 +18,45 @@ export const FactoryFieldset: React.FC<FactoryFieldsetProps> = ({
|
||||
client,
|
||||
}) => {
|
||||
const form = useFormContext();
|
||||
const tab = form.watch("deployType");
|
||||
|
||||
return (
|
||||
<Flex as="fieldset" direction="column" gap={12}>
|
||||
<Flex direction="column" gap={6}>
|
||||
<Heading size="title.lg">Factory deploy settings</Heading>
|
||||
<ButtonGroup size="sm" spacing={{ base: 0.5, md: 2 }} variant="ghost">
|
||||
<Button
|
||||
_active={{
|
||||
bg: "bgBlack",
|
||||
color: "bgWhite",
|
||||
}}
|
||||
isActive={form.watch("deployType") === "autoFactory"}
|
||||
onClick={() => form.setValue("deployType", "autoFactory")}
|
||||
rounded="lg"
|
||||
type="button"
|
||||
>
|
||||
Default Factory
|
||||
</Button>
|
||||
<Button
|
||||
_active={{
|
||||
bg: "bgBlack",
|
||||
color: "bgWhite",
|
||||
}}
|
||||
isActive={form.watch("deployType") === "customFactory"}
|
||||
onClick={() => form.setValue("deployType", "customFactory")}
|
||||
rounded="lg"
|
||||
type="button"
|
||||
>
|
||||
Custom Factory (Advanced)
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
{form.watch("deployType") === "autoFactory" && (
|
||||
<DefaultFactory abi={abi} client={client} />
|
||||
)}
|
||||
{form.watch("deployType") === "customFactory" && (
|
||||
<CustomFactory
|
||||
client={client}
|
||||
setCustomFactoryAbi={setCustomFactoryAbi}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight mb-1">
|
||||
Factory deploy settings
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Choose how you want to deploy your contract
|
||||
</p>
|
||||
|
||||
<TabButtons
|
||||
tabClassName="!text-sm font-semibold"
|
||||
tabs={[
|
||||
{
|
||||
name: "Default Factory",
|
||||
onClick: () => form.setValue("deployType", "autoFactory"),
|
||||
isActive: tab === "autoFactory",
|
||||
},
|
||||
{
|
||||
name: "Custom Factory (Advanced)",
|
||||
onClick: () => form.setValue("deployType", "customFactory"),
|
||||
isActive: tab === "customFactory",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
{form.watch("deployType") === "autoFactory" && (
|
||||
<DefaultFactory abi={abi} client={client} />
|
||||
)}
|
||||
|
||||
{form.watch("deployType") === "customFactory" && (
|
||||
<CustomFactory
|
||||
client={client}
|
||||
setCustomFactoryAbi={setCustomFactoryAbi}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+19
-21
@@ -1,4 +1,3 @@
|
||||
import { FormControl, useBreakpointValue } from "@chakra-ui/react";
|
||||
import type { AbiParameter } from "abitype";
|
||||
import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
@@ -7,6 +6,7 @@ import { SolidityInput } from "@/components/solidity-inputs";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { getTemplateValuesForType } from "@/lib/deployment/template-values";
|
||||
import { RefInputImplFieldset } from "./ref-contract-impl-input/ref-input-impl-fieldset";
|
||||
|
||||
@@ -19,7 +19,7 @@ export const ImplementationParamsFieldset: React.FC<
|
||||
> = ({ implParams, client }) => {
|
||||
const form = useFormContext();
|
||||
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [isCustomInputEnabled, setIsCustomInputEnabled] = useState(
|
||||
Array(implParams.length).fill(false),
|
||||
@@ -119,25 +119,23 @@ export const ImplementationParamsFieldset: React.FC<
|
||||
</div>
|
||||
|
||||
{!isCustomInputEnabled[idx] ? (
|
||||
<FormControl>
|
||||
<SolidityInput
|
||||
className="!bg-background !text-sm placeholder:!text-sm"
|
||||
client={client}
|
||||
placeholder={
|
||||
isMobile ||
|
||||
paramTemplateValues?.[0]?.value ===
|
||||
"{{trusted_forwarders}}"
|
||||
? "Pre-filled value."
|
||||
: "This value will be pre-filled in the deploy form."
|
||||
}
|
||||
solidityType={param.type}
|
||||
{...form.register(
|
||||
`implConstructorParams.${
|
||||
param.name ? param.name : "*"
|
||||
}.defaultValue`,
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<SolidityInput
|
||||
className="!bg-background !text-sm placeholder:!text-sm"
|
||||
client={client}
|
||||
placeholder={
|
||||
isMobile ||
|
||||
paramTemplateValues?.[0]?.value ===
|
||||
"{{trusted_forwarders}}"
|
||||
? "Pre-filled value."
|
||||
: "This value will be pre-filled in the deploy form."
|
||||
}
|
||||
solidityType={param.type}
|
||||
{...form.register(
|
||||
`implConstructorParams.${
|
||||
param.name ? param.name : "*"
|
||||
}.defaultValue`,
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<RefInputImplFieldset client={client} param={param} />
|
||||
)}
|
||||
|
||||
+222
-270
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
import { Box, Divider, Flex, IconButton } from "@chakra-ui/react";
|
||||
import type { Abi } from "abitype";
|
||||
import { Button } from "chakra/button";
|
||||
import { Text } from "chakra/text";
|
||||
import { ChevronFirstIcon } from "lucide-react";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
@@ -15,6 +12,8 @@ import {
|
||||
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import { reportContractPublished } from "@/analytics/report";
|
||||
import { CustomConnectWallet } from "@/components/connect-wallet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import {
|
||||
DASHBOARD_ENGINE_RELAYER_URL,
|
||||
DASHBOARD_FORWARDER_ADDRESS,
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
import { useEns, useFunctionParamsFromABI } from "@/hooks/contract-hooks";
|
||||
import { useTxNotifications } from "@/hooks/useTxNotifications";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useIsomorphicLayoutEffect } from "@/lib/useIsomorphicLayoutEffect";
|
||||
import { ContractParamsFieldset } from "./contract-params-fieldset";
|
||||
import { FactoryFieldset } from "./factory-fieldset";
|
||||
import { ImplementationParamsFieldset } from "./impl-params-fieldset";
|
||||
@@ -183,284 +181,238 @@ export function ContractPublishForm(props: {
|
||||
// during loading and after success we should stay in loading state
|
||||
const isPending = sendTx.isPending || sendTx.isSuccess;
|
||||
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
window?.scrollTo({
|
||||
behavior: "smooth",
|
||||
top: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const formId = useId();
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<div className="w-full">
|
||||
<Flex
|
||||
as="form"
|
||||
direction="column"
|
||||
gap={6}
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
if (!account) {
|
||||
// no account, do nothing
|
||||
return;
|
||||
}
|
||||
const addressArray =
|
||||
Object.keys(data?.customFactoryAddresses || {}).length > 0
|
||||
? (data?.customFactoryAddresses as unknown as {
|
||||
key: number;
|
||||
value: string;
|
||||
}[])
|
||||
: [];
|
||||
<form
|
||||
className="flex flex-col gap-6"
|
||||
id={formId}
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
if (!account) {
|
||||
// no account, do nothing
|
||||
return;
|
||||
}
|
||||
const addressArray =
|
||||
Object.keys(data?.customFactoryAddresses || {}).length > 0
|
||||
? (data?.customFactoryAddresses as unknown as {
|
||||
key: number;
|
||||
value: string;
|
||||
}[])
|
||||
: [];
|
||||
|
||||
const addressObj = addressArray.reduce<Record<number, string>>(
|
||||
(obj, item) => {
|
||||
obj[item.key] = item.value;
|
||||
return obj;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const addressObj = addressArray.reduce<Record<number, string>>(
|
||||
(obj, item) => {
|
||||
obj[item.key] = item.value;
|
||||
return obj;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const metadata = {
|
||||
...data,
|
||||
factoryDeploymentData: {
|
||||
...data.factoryDeploymentData,
|
||||
customFactoryInput: {
|
||||
customFactoryAddresses:
|
||||
addressObj ||
|
||||
data.factoryDeploymentData?.customFactoryInput
|
||||
?.customFactoryAddresses,
|
||||
factoryFunction:
|
||||
data.factoryDeploymentData?.customFactoryInput
|
||||
?.factoryFunction || "deployProxyByImplementation",
|
||||
params:
|
||||
data.factoryDeploymentData?.customFactoryInput?.params ||
|
||||
[],
|
||||
},
|
||||
implementationAddresses:
|
||||
data.factoryDeploymentData?.implementationAddresses || {},
|
||||
implementationInitializerFunction:
|
||||
data.factoryDeploymentData
|
||||
?.implementationInitializerFunction || "initialize",
|
||||
const metadata = {
|
||||
...data,
|
||||
factoryDeploymentData: {
|
||||
...data.factoryDeploymentData,
|
||||
customFactoryInput: {
|
||||
customFactoryAddresses:
|
||||
addressObj ||
|
||||
data.factoryDeploymentData?.customFactoryInput
|
||||
?.customFactoryAddresses,
|
||||
factoryFunction:
|
||||
data.factoryDeploymentData?.customFactoryInput
|
||||
?.factoryFunction || "deployProxyByImplementation",
|
||||
params:
|
||||
data.factoryDeploymentData?.customFactoryInput?.params || [],
|
||||
},
|
||||
networksForDeployment: {
|
||||
allNetworks:
|
||||
data.deployType === "customFactory"
|
||||
? false
|
||||
: data.networksForDeployment?.allNetworks,
|
||||
networksEnabled:
|
||||
addressArray.length > 0
|
||||
? Object.keys(addressObj).map((val) => Number(val))
|
||||
: data.networksForDeployment?.networksEnabled,
|
||||
},
|
||||
} satisfies FetchDeployMetadataResult & {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
implementationAddresses:
|
||||
data.factoryDeploymentData?.implementationAddresses || {},
|
||||
implementationInitializerFunction:
|
||||
data.factoryDeploymentData?.implementationInitializerFunction ||
|
||||
"initialize",
|
||||
},
|
||||
networksForDeployment: {
|
||||
allNetworks:
|
||||
data.deployType === "customFactory"
|
||||
? false
|
||||
: data.networksForDeployment?.allNetworks,
|
||||
networksEnabled:
|
||||
addressArray.length > 0
|
||||
? Object.keys(addressObj).map((val) => Number(val))
|
||||
: data.networksForDeployment?.networksEnabled,
|
||||
},
|
||||
} satisfies FetchDeployMetadataResult & {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
const tx = publishContract({
|
||||
account,
|
||||
contract: getContractPublisher(props.client),
|
||||
metadata,
|
||||
previousMetadata: props.publishMetadata,
|
||||
});
|
||||
const tx = publishContract({
|
||||
account,
|
||||
contract: getContractPublisher(props.client),
|
||||
metadata,
|
||||
previousMetadata: props.publishMetadata,
|
||||
});
|
||||
|
||||
sendTx.mutate(tx, {
|
||||
onError: (err) => {
|
||||
console.error("Failed to publish contract", err);
|
||||
onError(err);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
onSuccess();
|
||||
sendTx.mutate(tx, {
|
||||
onError: (err) => {
|
||||
console.error("Failed to publish contract", err);
|
||||
onError(err);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
onSuccess();
|
||||
|
||||
reportContractPublished({
|
||||
contractName: props.publishMetadata.name,
|
||||
deployType: data.deployType,
|
||||
publisher: ensNameOrAddress ?? "",
|
||||
version: data.version,
|
||||
});
|
||||
await props.onPublishSuccess().catch((err) => {
|
||||
console.error("Failed to run onPublishSuccess", err);
|
||||
});
|
||||
if (successRedirectUrl) {
|
||||
router.push(successRedirectUrl, { scroll: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
})}
|
||||
>
|
||||
{fieldsetToShow !== "landing" && (
|
||||
<div>
|
||||
<IconButton
|
||||
aria-label="Back"
|
||||
icon={<ChevronFirstIcon className="size-6" />}
|
||||
onClick={() =>
|
||||
fieldsetToShow === "contractParams" &&
|
||||
(form.watch("deployType") === "autoFactory" ||
|
||||
form.watch("deployType") === "customFactory")
|
||||
? setFieldsetToShow("factory")
|
||||
: fieldsetToShow === "contractParams" &&
|
||||
form.watch("deployType") === "standard"
|
||||
? setFieldsetToShow("networks")
|
||||
: fieldsetToShow === "implParams"
|
||||
? setFieldsetToShow("contractParams")
|
||||
: setFieldsetToShow("landing")
|
||||
}
|
||||
variant="ghost"
|
||||
w="inherit"
|
||||
reportContractPublished({
|
||||
contractName: props.publishMetadata.name,
|
||||
deployType: data.deployType,
|
||||
publisher: ensNameOrAddress ?? "",
|
||||
version: data.version,
|
||||
});
|
||||
await props.onPublishSuccess().catch((err) => {
|
||||
console.error("Failed to run onPublishSuccess", err);
|
||||
});
|
||||
if (successRedirectUrl) {
|
||||
router.push(successRedirectUrl, { scroll: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
})}
|
||||
>
|
||||
{fieldsetToShow !== "landing" && (
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="-translate-x-3"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
fieldsetToShow === "contractParams" &&
|
||||
(form.watch("deployType") === "autoFactory" ||
|
||||
form.watch("deployType") === "customFactory")
|
||||
? setFieldsetToShow("factory")
|
||||
: fieldsetToShow === "contractParams" &&
|
||||
form.watch("deployType") === "standard"
|
||||
? setFieldsetToShow("networks")
|
||||
: fieldsetToShow === "implParams"
|
||||
? setFieldsetToShow("contractParams")
|
||||
: setFieldsetToShow("landing")
|
||||
}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldsetToShow === "landing" && (
|
||||
<LandingFieldset
|
||||
client={props.client}
|
||||
latestVersion={latestVersion}
|
||||
placeholderVersion={placeholderVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fieldsetToShow === "contractParams" && (
|
||||
<ContractParamsFieldset
|
||||
client={props.client}
|
||||
deployParams={deployParams}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fieldsetToShow === "implParams" && implDeployParams?.length > 0 && (
|
||||
<ImplementationParamsFieldset
|
||||
client={props.client}
|
||||
implParams={implDeployParams}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fieldsetToShow === "factory" && (
|
||||
<div className="flex flex-col gap-24">
|
||||
<FactoryFieldset
|
||||
abi={props.publishMetadata.abi || []}
|
||||
client={props.client}
|
||||
setCustomFactoryAbi={setCustomFactoryAbi}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldsetToShow === "networks" && (
|
||||
<NetworksFieldset client={props.client} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 justify-end border-t py-6 mt-6">
|
||||
{!account ? (
|
||||
<CustomConnectWallet
|
||||
client={props.client}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
/>
|
||||
) : fieldsetToShow === "landing" &&
|
||||
form.watch("deployType") === "standard" ? (
|
||||
<NextButton
|
||||
disabled={disableNext}
|
||||
onClick={() => setFieldsetToShow("networks")}
|
||||
/>
|
||||
) : fieldsetToShow === "landing" &&
|
||||
(form.watch("deployType") === "autoFactory" ||
|
||||
form.watch("deployType") === "customFactory") ? (
|
||||
<NextButton
|
||||
disabled={disableNext}
|
||||
onClick={() => setFieldsetToShow("factory")}
|
||||
/>
|
||||
) : fieldsetToShow !== "contractParams" &&
|
||||
fieldsetToShow !== "implParams" &&
|
||||
deployParams?.length > 0 ? (
|
||||
<NextButton
|
||||
disabled={disableNext}
|
||||
onClick={() => setFieldsetToShow("contractParams")}
|
||||
/>
|
||||
) : fieldsetToShow !== "contractParams" &&
|
||||
fieldsetToShow !== "implParams" &&
|
||||
deployParams?.length === 0 &&
|
||||
implDeployParams?.length > 0 ? (
|
||||
<NextButton
|
||||
disabled={disableNext}
|
||||
onClick={() => setFieldsetToShow("implParams")}
|
||||
/>
|
||||
) : fieldsetToShow === "contractParams" &&
|
||||
implDeployParams?.length > 0 ? (
|
||||
<NextButton
|
||||
disabled={disableNext}
|
||||
onClick={() => setFieldsetToShow("implParams")}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-start justify-between gap-4 w-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Publishing your contract is free, we cover all gas costs
|
||||
</span>
|
||||
|
||||
<Button
|
||||
className="gap-2"
|
||||
form={formId}
|
||||
disabled={isDisabled}
|
||||
type="submit"
|
||||
>
|
||||
Back
|
||||
</IconButton>
|
||||
{isPending && <Spinner className="size-4" />}
|
||||
{sendTx.isSuccess
|
||||
? "Preparing page"
|
||||
: sendTx.isPending
|
||||
? "Publishing contract"
|
||||
: "Publish Contract"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fieldsetToShow === "landing" && (
|
||||
<LandingFieldset
|
||||
client={props.client}
|
||||
latestVersion={latestVersion}
|
||||
placeholderVersion={placeholderVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fieldsetToShow === "contractParams" && (
|
||||
<ContractParamsFieldset
|
||||
client={props.client}
|
||||
deployParams={deployParams}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fieldsetToShow === "implParams" && implDeployParams?.length > 0 && (
|
||||
<ImplementationParamsFieldset
|
||||
client={props.client}
|
||||
implParams={implDeployParams}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fieldsetToShow === "factory" && (
|
||||
<Flex flexDir="column" gap={24}>
|
||||
<FactoryFieldset
|
||||
abi={props.publishMetadata.abi || []}
|
||||
client={props.client}
|
||||
setCustomFactoryAbi={setCustomFactoryAbi}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{fieldsetToShow === "networks" && (
|
||||
<Flex flexDir="column" gap={24}>
|
||||
<NetworksFieldset client={props.client} fromStandard />
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex flexDir="column" gap={6}>
|
||||
<Divider />
|
||||
<Flex
|
||||
alignItems="center"
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
gap={4}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
{!account ? (
|
||||
<>
|
||||
<Box />
|
||||
<CustomConnectWallet
|
||||
client={props.client}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
/>
|
||||
</>
|
||||
) : fieldsetToShow === "landing" &&
|
||||
form.watch("deployType") === "standard" ? (
|
||||
<>
|
||||
<Box />
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
isDisabled={disableNext}
|
||||
onClick={() => setFieldsetToShow("networks")}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</>
|
||||
) : fieldsetToShow === "landing" &&
|
||||
(form.watch("deployType") === "autoFactory" ||
|
||||
form.watch("deployType") === "customFactory") ? (
|
||||
<>
|
||||
<Box />
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
isDisabled={disableNext}
|
||||
onClick={() => setFieldsetToShow("factory")}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</>
|
||||
) : fieldsetToShow !== "contractParams" &&
|
||||
fieldsetToShow !== "implParams" &&
|
||||
deployParams?.length > 0 ? (
|
||||
<>
|
||||
<Box />
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
isDisabled={disableNext}
|
||||
onClick={() => setFieldsetToShow("contractParams")}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</>
|
||||
) : fieldsetToShow !== "contractParams" &&
|
||||
fieldsetToShow !== "implParams" &&
|
||||
deployParams?.length === 0 &&
|
||||
implDeployParams?.length > 0 ? (
|
||||
<>
|
||||
<Box />
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
isDisabled={disableNext}
|
||||
onClick={() => setFieldsetToShow("implParams")}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</>
|
||||
) : fieldsetToShow === "contractParams" &&
|
||||
implDeployParams?.length > 0 ? (
|
||||
<>
|
||||
<Box />
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
isDisabled={disableNext}
|
||||
onClick={() => setFieldsetToShow("implParams")}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fontStyle="italic">
|
||||
Publishing your contract is free, we cover all gas costs.
|
||||
</Text>
|
||||
<Button
|
||||
// differentiate this from the edit button
|
||||
borderRadius="md"
|
||||
colorScheme="blue"
|
||||
form={formId}
|
||||
isDisabled={isDisabled}
|
||||
isLoading={isPending}
|
||||
key="submit-button"
|
||||
loadingText={
|
||||
sendTx.isSuccess
|
||||
? "Preparing page"
|
||||
: "Publishing contract"
|
||||
}
|
||||
position="relative"
|
||||
type="submit"
|
||||
>
|
||||
Publish Contract
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function NextButton(props: { disabled: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<Button
|
||||
disabled={props.disabled}
|
||||
onClick={props.onClick}
|
||||
className="gap-2 w-32"
|
||||
>
|
||||
Next
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
+189
-170
@@ -1,41 +1,30 @@
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
Textarea,
|
||||
} from "@chakra-ui/react";
|
||||
import { Card } from "chakra/card";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
|
||||
import { Heading } from "chakra/heading";
|
||||
import { Link } from "chakra/link";
|
||||
import { Text } from "chakra/text";
|
||||
import { compare, validate } from "compare-versions";
|
||||
import { useState } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { ExtendedMetadata } from "thirdweb/utils";
|
||||
import { SelectOption } from "@/components/batch-upload/lazy-mint-form/select-option";
|
||||
import { FileInput } from "@/components/blocks/FileInput";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { SolidityInput } from "@/components/solidity-inputs";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownRenderer } from "../../blocks/markdown-renderer";
|
||||
import { TabButtons } from "../../ui/tabs";
|
||||
import { UnderlineLink } from "../../ui/UnderlineLink";
|
||||
import { ExternalLinksFieldset } from "./external-links-fieldset";
|
||||
|
||||
interface LandingFieldsetProps {
|
||||
type LandingFieldsetProps = {
|
||||
latestVersion: string | undefined;
|
||||
placeholderVersion: string;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
};
|
||||
|
||||
export const LandingFieldset: React.FC<LandingFieldsetProps> = ({
|
||||
export function LandingFieldset({
|
||||
latestVersion,
|
||||
placeholderVersion,
|
||||
client,
|
||||
}) => {
|
||||
}: LandingFieldsetProps) {
|
||||
const form = useFormContext<ExtendedMetadata>();
|
||||
const logoUrl = form.watch("logo");
|
||||
|
||||
@@ -66,205 +55,235 @@ export const LandingFieldset: React.FC<LandingFieldsetProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const [readmeTab, setReadmeTab] = useState<"write" | "preview">("write");
|
||||
const [changelogTab, setChangelogTab] = useState<"write" | "preview">(
|
||||
"write",
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex as="fieldset" direction="column" gap={16} mt={{ base: 4, md: 12 }}>
|
||||
<Flex direction="column" gap={2}>
|
||||
<Heading size="title.lg">
|
||||
<fieldset className="space-y-12">
|
||||
{/* header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">
|
||||
{!latestVersion ? "Publish" : "Edit"} your contract
|
||||
</Heading>
|
||||
<Text fontStyle="normal">
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Publishing your contract makes it shareable, discoverable, and
|
||||
deployable in a single click.{" "}
|
||||
<Link
|
||||
color="blue.500"
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/contracts/publish/overview"
|
||||
isExternal
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex gap={6} w="full">
|
||||
<FormControl isInvalid={!!form.formState.errors.logo} w="auto">
|
||||
<FormLabel>Image</FormLabel>
|
||||
<Box width="141px">
|
||||
</UnderlineLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* logo, name, description */}
|
||||
<div className="flex flex-col md:flex-row gap-6 w-full">
|
||||
<FormFieldSetup
|
||||
label="Image"
|
||||
errorMessage={form.formState.errors?.logo?.message as string}
|
||||
isRequired={false}
|
||||
className="w-auto"
|
||||
>
|
||||
<div className="w-[200px]">
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
className="rounded border border-border transition-all duration-200"
|
||||
className="rounded-lg border bg-card transition-all duration-200"
|
||||
client={client}
|
||||
helperText="Image"
|
||||
// @ts-expect-error - we upload the file later this is fine
|
||||
setValue={(file) => form.setValue("logo", file)}
|
||||
value={logoUrl}
|
||||
/>
|
||||
</Box>
|
||||
<FormErrorMessage>
|
||||
{form.formState.errors?.logo?.message as unknown as string}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<Flex flexDir="column" gap={4} w="full">
|
||||
<FormControl
|
||||
isInvalid={!!form.formState.errors.displayName}
|
||||
isRequired
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full grow">
|
||||
<FormFieldSetup
|
||||
htmlFor="displayName"
|
||||
label="Contract Name"
|
||||
errorMessage={form.formState.errors?.displayName?.message}
|
||||
isRequired={true}
|
||||
>
|
||||
<FormLabel>Contract Name</FormLabel>
|
||||
<Input
|
||||
id="displayName"
|
||||
onChange={(e) => form.setValue("displayName", e.target.value)}
|
||||
placeholder="Ex. MyContract"
|
||||
className="bg-card"
|
||||
value={form.watch("displayName")}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{form.formState.errors?.displayName?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!form.formState.errors.description}>
|
||||
<FormLabel>Description</FormLabel>
|
||||
</FormFieldSetup>
|
||||
|
||||
<FormFieldSetup
|
||||
htmlFor="description"
|
||||
label="Description"
|
||||
errorMessage={form.formState.errors?.description?.message}
|
||||
isRequired={false}
|
||||
className="grow flex flex-col"
|
||||
>
|
||||
<Textarea
|
||||
id="description"
|
||||
onChange={(e) => form.setValue("description", e.target.value)}
|
||||
placeholder="Briefly describe what your contract does."
|
||||
rows={2}
|
||||
className="bg-card grow"
|
||||
value={form.watch("description")}
|
||||
/>
|
||||
|
||||
<FormErrorMessage>
|
||||
{form.formState.errors?.description?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Heading mb={2} size="title.md">
|
||||
README
|
||||
</Heading>
|
||||
<Text mb={4} size="body.md">
|
||||
<h2 className="text-xl font-semibold mb-1 tracking-tight">README</h2>
|
||||
<p className="text-muted-foreground text-sm mb-5">
|
||||
Describe what your contract does and how it should be used. Markdown
|
||||
formatting is supported.
|
||||
</Text>
|
||||
<FormControl isInvalid={!!form.formState.errors.readme}>
|
||||
<Tabs colorScheme="gray" isLazy lazyBehavior="keepMounted">
|
||||
<TabList
|
||||
borderBottomColor="borderColor"
|
||||
borderBottomWidth="1px"
|
||||
px={0}
|
||||
>
|
||||
<Tab gap={2}>
|
||||
<Heading size="label.lg">Write</Heading>
|
||||
</Tab>
|
||||
<Tab gap={2}>
|
||||
<Heading size="label.lg">Preview</Heading>
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanels pt={2}>
|
||||
<TabPanel pb={0} px={0}>
|
||||
<Textarea
|
||||
{...form.register("readme")}
|
||||
placeholder="Describe how users can use this contract. Add links if relevant."
|
||||
rows={12}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{form.formState.errors?.readme?.message}
|
||||
</FormErrorMessage>
|
||||
</TabPanel>
|
||||
<TabPanel pb={0} px={0}>
|
||||
<Card>
|
||||
<MarkdownRenderer markdownText={form.watch("readme") || ""} />
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</FormControl>
|
||||
</p>
|
||||
|
||||
<TabButtons
|
||||
tabClassName="!text-sm"
|
||||
tabs={[
|
||||
{
|
||||
name: "Write",
|
||||
onClick: () => setReadmeTab("write"),
|
||||
isActive: readmeTab === "write",
|
||||
},
|
||||
{
|
||||
name: "Preview",
|
||||
onClick: () => setReadmeTab("preview"),
|
||||
isActive: readmeTab === "preview",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-2">
|
||||
{readmeTab === "write" && (
|
||||
<Textarea
|
||||
{...form.register("readme")}
|
||||
placeholder="Describe how users can use this contract. Add links if relevant."
|
||||
rows={12}
|
||||
className="bg-card"
|
||||
/>
|
||||
)}
|
||||
|
||||
{readmeTab === "preview" && (
|
||||
<div className="p-4 rounded-lg border border-border bg-card">
|
||||
<MarkdownRenderer markdownText={form.watch("readme") || ""} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{form.formState.errors.readme && (
|
||||
<p className="text-red-500 text-sm mt-2">
|
||||
{form.formState.errors.readme.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExternalLinksFieldset />
|
||||
|
||||
<div>
|
||||
<Heading mb={2} size="title.md">
|
||||
Version information
|
||||
</Heading>
|
||||
<Text mb={4} size="body.md">
|
||||
<h2 className="text-xl font-semibold mb-1">Version information</h2>
|
||||
<p className="text-muted-foreground text-sm mb-5">
|
||||
Set your contract version number, add release notes, and link to your
|
||||
contract's audit report.
|
||||
</Text>
|
||||
<Flex flexDir="column" gap={6}>
|
||||
<FormControl isInvalid={!!form.formState.errors.version} isRequired>
|
||||
<Flex alignItems="center" mb={1}>
|
||||
<FormLabel flex="1" mb={0}>
|
||||
Version
|
||||
</FormLabel>
|
||||
{latestVersion && (
|
||||
<Text size="body.md">latest version: {latestVersion}</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<FormFieldSetup
|
||||
htmlFor="version"
|
||||
label={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span>Version</span>
|
||||
{latestVersion && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
latest version: {latestVersion}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
errorMessage={form.formState.errors?.version?.message}
|
||||
isRequired={true}
|
||||
>
|
||||
<Input
|
||||
id="version"
|
||||
onChange={handleVersionChange}
|
||||
className="bg-card"
|
||||
placeholder={placeholderVersion}
|
||||
value={form.watch("version")}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{form.formState.errors?.version?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</FormFieldSetup>
|
||||
|
||||
{latestVersion && (
|
||||
<FormControl isInvalid={!!form.formState.errors.changelog}>
|
||||
<FormLabel>Release notes</FormLabel>
|
||||
<Tabs colorScheme="gray" isLazy lazyBehavior="keepMounted">
|
||||
<TabList
|
||||
borderBottomColor="borderColor"
|
||||
borderBottomWidth="1px"
|
||||
px={0}
|
||||
>
|
||||
<Tab gap={2}>
|
||||
<Heading size="label.lg">Write</Heading>
|
||||
</Tab>
|
||||
<Tab gap={2}>
|
||||
<Heading size="label.lg">Preview</Heading>
|
||||
</Tab>
|
||||
</TabList>
|
||||
<TabPanels pt={2}>
|
||||
<TabPanel pb={0} px={0}>
|
||||
<Textarea
|
||||
{...form.register("changelog")}
|
||||
placeholder="Mention what is new in this version of your contract."
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Release notes</h3>
|
||||
{form.formState.errors?.changelog?.message && (
|
||||
<p className="text-red-500 text-sm mt-2">
|
||||
{form.formState.errors.changelog.message}
|
||||
</p>
|
||||
)}
|
||||
<TabButtons
|
||||
tabClassName="!text-sm"
|
||||
tabs={[
|
||||
{
|
||||
name: "Write",
|
||||
onClick: () => setChangelogTab("write"),
|
||||
isActive: changelogTab === "write",
|
||||
},
|
||||
{
|
||||
name: "Preview",
|
||||
onClick: () => setChangelogTab("preview"),
|
||||
isActive: changelogTab === "preview",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mt-2">
|
||||
{changelogTab === "write" && (
|
||||
<Textarea
|
||||
{...form.register("changelog")}
|
||||
placeholder="Mention what is new in this version of your contract."
|
||||
className="bg-card"
|
||||
/>
|
||||
)}
|
||||
|
||||
{changelogTab === "preview" && (
|
||||
<div className="p-4 rounded-lg border bg-card">
|
||||
<MarkdownRenderer
|
||||
markdownText={form.watch("changelog") || ""}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{form.formState.errors?.changelog?.message}
|
||||
</FormErrorMessage>
|
||||
</TabPanel>
|
||||
<TabPanel pb={0} px={0}>
|
||||
<Card>
|
||||
<MarkdownRenderer
|
||||
markdownText={form.watch("changelog") || ""}
|
||||
/>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<FormControl isInvalid={!!form.formState.errors.audit}>
|
||||
<FormLabel>Audit report</FormLabel>
|
||||
|
||||
<FormFieldSetup
|
||||
label="Audit report"
|
||||
errorMessage={form.formState.errors?.audit?.message}
|
||||
isRequired={false}
|
||||
helperText="You can add a IPFS hash or URL pointing to an audit report, or add a file and we'll upload it to IPFS."
|
||||
>
|
||||
<SolidityInput
|
||||
client={client}
|
||||
solidityType="string"
|
||||
{...form.register("audit")}
|
||||
placeholder="ipfs://... or https://..."
|
||||
className="bg-card"
|
||||
/>
|
||||
<FormHelperText>
|
||||
<Text size="body.sm">
|
||||
You can add a IPFS hash or URL pointing to an audit report, or
|
||||
add a file and we'll upload it to IPFS.
|
||||
</Text>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</FormFieldSetup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Heading mb={2} size="title.md">
|
||||
Deployment options
|
||||
</Heading>
|
||||
<Text mb={4} size="body.md">
|
||||
<h2 className="text-xl font-semibold mb-1">Deployment options</h2>
|
||||
<p className="text-muted-foreground text-sm mb-3">
|
||||
Choose how users will deploy your published contract.
|
||||
</Text>
|
||||
<Flex flexDir="column" gap={2} width="full">
|
||||
</p>
|
||||
<div className="flex flex-col md:flex-row gap-4 w-full">
|
||||
<SelectOption
|
||||
className="w-full"
|
||||
description="Users will directly deploy the full contract."
|
||||
@@ -282,8 +301,8 @@ export const LandingFieldset: React.FC<LandingFieldsetProps> = ({
|
||||
name="Deploy via factory"
|
||||
onClick={() => form.setValue("deployType", "autoFactory")}
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
</fieldset>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+36
-40
@@ -1,5 +1,3 @@
|
||||
import { Heading } from "chakra/heading";
|
||||
import { Text } from "chakra/text";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { MultiNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
@@ -10,57 +8,55 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NetworksFieldsetProps {
|
||||
fromStandard?: boolean;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const NetworksFieldset: React.FC<NetworksFieldsetProps> = ({
|
||||
fromStandard,
|
||||
client,
|
||||
}) => {
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col", fromStandard ? "gap-6" : "gap-4")}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Heading size={fromStandard ? "title.lg" : "title.md"}>
|
||||
Networks that your contract can be deployed to
|
||||
</Heading>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2" data-required>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
form.setValue("networksForDeployment.allNetworks", value === "all")
|
||||
}
|
||||
value={
|
||||
form.watch("networksForDeployment.allNetworks") ? "all" : "specific"
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All networks</SelectItem>
|
||||
<SelectItem value="specific">Specific networks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight mb-1">
|
||||
Select networks
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
Select the networks that your contract can be deployed to
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
form.setValue("networksForDeployment.allNetworks", value === "all")
|
||||
}
|
||||
value={
|
||||
form.watch("networksForDeployment.allNetworks") ? "all" : "specific"
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-card">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All networks</SelectItem>
|
||||
<SelectItem value="specific">Specific networks</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{!form.watch("networksForDeployment.allNetworks") && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text>Please select the networks you want to enable:</Text>
|
||||
<MultiNetworkSelector
|
||||
client={client}
|
||||
onChange={(chainIds) =>
|
||||
form.setValue("networksForDeployment.networksEnabled", chainIds)
|
||||
}
|
||||
selectedChainIds={
|
||||
form.watch("networksForDeployment.networksEnabled") || []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<MultiNetworkSelector
|
||||
className="bg-card"
|
||||
client={client}
|
||||
onChange={(chainIds) =>
|
||||
form.setValue("networksForDeployment.networksEnabled", chainIds)
|
||||
}
|
||||
selectedChainIds={
|
||||
form.watch("networksForDeployment.networksEnabled") || []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+5
-9
@@ -1,20 +1,16 @@
|
||||
import type { AbiParameter } from "abitype";
|
||||
import { Button } from "chakra/button";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RefContractImplInput } from "./ref-input-impl";
|
||||
|
||||
interface RefInputImplFieldsetProps {
|
||||
export function RefInputImplFieldset(props: {
|
||||
param: AbiParameter;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const RefInputImplFieldset: React.FC<RefInputImplFieldsetProps> = ({
|
||||
param,
|
||||
client,
|
||||
}) => {
|
||||
}) {
|
||||
const { param, client } = props;
|
||||
const form = useFormContext();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
@@ -63,4 +59,4 @@ export const RefInputImplFieldset: React.FC<RefInputImplFieldsetProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+51
-54
@@ -1,85 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { Edit3Icon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
|
||||
import {
|
||||
AccountAddress,
|
||||
AccountAvatar,
|
||||
AccountBlobbie,
|
||||
AccountName,
|
||||
AccountProvider,
|
||||
} from "thirdweb/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useEns } from "@/hooks/contract-hooks";
|
||||
import { replaceDeployerAddress } from "@/lib/publisher-utils";
|
||||
import { shortenIfAddress } from "@/utils/usedapp-external";
|
||||
|
||||
interface PublisherHeaderProps {
|
||||
wallet: string;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
export const PublisherHeader: React.FC<PublisherHeaderProps> = ({
|
||||
export function PublisherLink({
|
||||
wallet,
|
||||
client,
|
||||
}) => {
|
||||
}: {
|
||||
wallet: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const ensQuery = useEns({
|
||||
addressOrEnsName: wallet,
|
||||
client,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<h4 className="font-semibold text-lg tracking-tight">Published by</h4>
|
||||
|
||||
<AccountProvider
|
||||
// passing zero address during loading time to prevent the component from crashing
|
||||
address={ensQuery.data?.address || ZERO_ADDRESS}
|
||||
client={client}
|
||||
>
|
||||
<div className="relative flex items-center gap-3">
|
||||
<AccountProvider
|
||||
// passing zero address during loading time to prevent the component from crashing
|
||||
address={ensQuery.data?.address || ZERO_ADDRESS}
|
||||
client={client}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 relative">
|
||||
<div>
|
||||
<AccountAvatar
|
||||
className="size-10 rounded-full border border-border border-solid object-cover"
|
||||
className="size-[34px] rounded-full border border-border border-solid object-cover"
|
||||
fallbackComponent={
|
||||
<AccountBlobbie className="size-10 rounded-full" />
|
||||
<div className="size-[34px] rounded-full flex items-center justify-center border bg-card">
|
||||
<Edit3Icon className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
loadingComponent={<Skeleton className="size-10 rounded-full" />}
|
||||
loadingComponent={<Skeleton className="size-[34px] rounded-full" />}
|
||||
/>
|
||||
|
||||
<Link
|
||||
className="before:absolute before:inset-0 hover:underline"
|
||||
href={replaceDeployerAddress(
|
||||
`/${ensQuery.data?.ensName || wallet}`,
|
||||
)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<AccountName
|
||||
className="font-medium"
|
||||
fallbackComponent={
|
||||
// When social profile API support other TLDs as well - we can remove this condition
|
||||
ensQuery.data?.ensName ? (
|
||||
<span> {ensQuery.data?.ensName} </span>
|
||||
) : (
|
||||
<AccountAddress
|
||||
formatFn={(addr) =>
|
||||
shortenIfAddress(replaceDeployerAddress(addr))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
formatFn={(name) => replaceDeployerAddress(name)}
|
||||
loadingComponent={<Skeleton className="h-8 w-40" />}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</AccountProvider>
|
||||
|
||||
<Button asChild className="bg-card" variant="outline">
|
||||
<Link href={replaceDeployerAddress(`/${wallet}`)}>
|
||||
View all contracts
|
||||
<Link
|
||||
className="hover:underline before:absolute before:inset-0 space-y-1.5"
|
||||
href={replaceDeployerAddress(`/${ensQuery.data?.ensName || wallet}`)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div className="text-sm font-medium text-foreground leading-none">
|
||||
Published by
|
||||
</div>
|
||||
|
||||
<AccountName
|
||||
className="text-sm text-foreground leading-none"
|
||||
fallbackComponent={
|
||||
// When social profile API support other TLDs as well - we can remove this condition
|
||||
ensQuery.data?.ensName ? (
|
||||
<span className="text-sm text-muted-foreground leading-none">
|
||||
{ensQuery.data?.ensName}
|
||||
</span>
|
||||
) : (
|
||||
<AccountAddress
|
||||
className="text-sm text-muted-foreground leading-none"
|
||||
formatFn={(addr) =>
|
||||
shortenIfAddress(replaceDeployerAddress(addr))
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
formatFn={(name) => replaceDeployerAddress(name)}
|
||||
loadingComponent={<Skeleton className="h-[17px] w-40" />}
|
||||
/>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccountProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Image from "next/image";
|
||||
import type { FetchDeployMetadataResult } from "thirdweb/contract";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { replaceIpfsUrl } from "@/lib/sdk";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import generalContractIcon from "../../../../../public/assets/tw-icons/general.png";
|
||||
|
||||
type ContractIdImageProps = {
|
||||
@@ -29,13 +29,13 @@ export const ContractIdImage: React.FC<ContractIdImageProps> = ({
|
||||
className="size-8 rounded-full"
|
||||
src={
|
||||
DASHBOARD_THIRDWEB_SECRET_KEY
|
||||
? replaceIpfsUrl(
|
||||
logo,
|
||||
getConfiguredThirdwebClient({
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
)
|
||||
uri: logo,
|
||||
})
|
||||
: logo
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
"use client";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
Flex,
|
||||
GridItem,
|
||||
List,
|
||||
ListItem,
|
||||
SimpleGrid,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
useBreakpointValue,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
type Abi,
|
||||
type AbiEvent,
|
||||
type AbiFunction,
|
||||
formatAbiItem,
|
||||
} from "abitype";
|
||||
import { Button } from "chakra/button";
|
||||
import { Card } from "chakra/card";
|
||||
import { Heading } from "chakra/heading";
|
||||
import { Link } from "chakra/link";
|
||||
import { Text } from "chakra/text";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import * as ERC20Ext from "thirdweb/extensions/erc20";
|
||||
@@ -35,23 +14,19 @@ import * as ERC1155Ext from "thirdweb/extensions/erc1155";
|
||||
import * as ERC4337Ext from "thirdweb/extensions/erc4337";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { toFunctionSelector } from "thirdweb/utils";
|
||||
|
||||
import {
|
||||
type CodeEnvironment,
|
||||
CodeSegment,
|
||||
} from "@/components/blocks/code/code-segment.client";
|
||||
import { getContractFunctionsFromAbi } from "@/components/contract-components/getContractFunctionsFromAbi";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { useContractEvents } from "@/hooks/contract-hooks";
|
||||
|
||||
interface CodeOverviewProps {
|
||||
abi?: Abi;
|
||||
contractAddress?: string;
|
||||
onlyInstall?: boolean;
|
||||
chainId: number;
|
||||
noSidebar?: boolean;
|
||||
}
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const COMMANDS = {
|
||||
events: {
|
||||
@@ -434,7 +409,7 @@ const EXTENSION_NAMESPACE_FUNCTION_MAPPING = {
|
||||
>
|
||||
>;
|
||||
|
||||
interface SnippetOptions {
|
||||
type SnippetOptions = {
|
||||
contractAddress?: string;
|
||||
fn?: AbiFunction | AbiEvent;
|
||||
args?: string[];
|
||||
@@ -442,7 +417,7 @@ interface SnippetOptions {
|
||||
clientId?: string;
|
||||
chainId?: number;
|
||||
extensionNamespace?: string;
|
||||
}
|
||||
};
|
||||
|
||||
export function formatSnippet(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
@@ -537,13 +512,21 @@ export function formatSnippet(
|
||||
return code;
|
||||
}
|
||||
|
||||
export const CodeOverview: React.FC<CodeOverviewProps> = ({
|
||||
abi,
|
||||
contractAddress = "0x...",
|
||||
onlyInstall = false,
|
||||
noSidebar = false,
|
||||
chainId,
|
||||
}) => {
|
||||
export function CodeOverview(props: {
|
||||
abi?: Abi;
|
||||
contractAddress?: string;
|
||||
onlyInstall?: boolean;
|
||||
chainId: number;
|
||||
noSidebar?: boolean;
|
||||
}) {
|
||||
const {
|
||||
abi,
|
||||
contractAddress = "0x...",
|
||||
onlyInstall = false,
|
||||
noSidebar = false,
|
||||
chainId,
|
||||
} = props;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const defaultEnvironment = searchParams?.get("environment") as
|
||||
| CodeEnvironment
|
||||
@@ -556,7 +539,7 @@ export const CodeOverview: React.FC<CodeOverviewProps> = ({
|
||||
const [tab, setTab] = useState("write");
|
||||
|
||||
const address = useActiveAccount()?.address;
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const functionSelectors = useMemo(() => {
|
||||
return (abi || [])
|
||||
@@ -626,340 +609,306 @@ export const CodeOverview: React.FC<CodeOverviewProps> = ({
|
||||
events && events.length > 0 ? events[0] : undefined,
|
||||
);
|
||||
|
||||
const tabList = useMemo(() => {
|
||||
const _tablist: Array<{
|
||||
name: string;
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
}> = [];
|
||||
|
||||
if (writeFunctions && writeFunctions.length > 0) {
|
||||
_tablist.push({
|
||||
name: "Write",
|
||||
onClick: () => setTab("write"),
|
||||
isActive: tab === "write",
|
||||
});
|
||||
}
|
||||
|
||||
if (readFunctions && readFunctions.length > 0) {
|
||||
_tablist.push({
|
||||
name: "Read",
|
||||
onClick: () => setTab("read"),
|
||||
isActive: tab === "read",
|
||||
});
|
||||
}
|
||||
|
||||
if (events && events.length > 0) {
|
||||
_tablist.push({
|
||||
name: "Events",
|
||||
onClick: () => setTab("events"),
|
||||
isActive: tab === "events",
|
||||
});
|
||||
}
|
||||
|
||||
return _tablist;
|
||||
}, [writeFunctions, readFunctions, events, tab]);
|
||||
|
||||
return (
|
||||
<SimpleGrid
|
||||
columns={12}
|
||||
display={{ base: "block", md: "grid" }}
|
||||
gap={16}
|
||||
justifyContent="space-between"
|
||||
maxW="full"
|
||||
overflowX={{ base: "scroll", md: "hidden" }}
|
||||
>
|
||||
<GridItem as={Flex} colSpan={12} flexDir="column" gap={12}>
|
||||
{isAccountFactory && (
|
||||
<Flex flexDirection="column" gap={4}>
|
||||
<Flex flexDir="column" gap={6}>
|
||||
<Heading size="title.md">Integrate your account factory</Heading>
|
||||
<Alert
|
||||
alignItems="start"
|
||||
as={Flex}
|
||||
borderRadius="md"
|
||||
flexDir="column"
|
||||
gap={2}
|
||||
status="info"
|
||||
>
|
||||
<Flex justifyContent="start">
|
||||
<AlertIcon />
|
||||
<AlertTitle>Account Factory</AlertTitle>
|
||||
</Flex>
|
||||
<AlertDescription>
|
||||
The recommended way to use account factories is to integrate
|
||||
the{" "}
|
||||
<UnderlineLink
|
||||
className="text-primary-500"
|
||||
href="https://portal.thirdweb.com/connect/account-abstraction/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
thirdweb SDK
|
||||
</UnderlineLink>{" "}
|
||||
in your applications. This will ensure account contracts are
|
||||
deployed for your users only when they need it.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<CodeSegment
|
||||
environment={environment}
|
||||
hideTabs
|
||||
setEnvironment={setEnvironment}
|
||||
snippet={formatSnippet(
|
||||
(WALLETS_SNIPPETS.find((w) => w.id === "smart-wallet")
|
||||
?.supportedLanguages || {}) as Record<
|
||||
CodeEnvironment,
|
||||
string
|
||||
>,
|
||||
{
|
||||
address,
|
||||
chainId,
|
||||
contractAddress,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<div className="space-y-12">
|
||||
{isAccountFactory && (
|
||||
<AccountFactorySection
|
||||
address={address}
|
||||
chainId={chainId}
|
||||
contractAddress={contractAddress}
|
||||
environment={environment}
|
||||
setEnvironment={setEnvironment}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{isAccountFactory
|
||||
? "Direct contract interaction (advanced)"
|
||||
: chainInfo
|
||||
? "Interact with this contract from your app"
|
||||
: "Getting Started"}
|
||||
</h2>
|
||||
</div>
|
||||
{(noSidebar || isMobile) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">Choose a language:</p>
|
||||
<CodeSegment
|
||||
environment={environment}
|
||||
onlyTabs
|
||||
setEnvironment={setEnvironment}
|
||||
snippet={COMMANDS.install}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Flex flexDirection="column" gap={4}>
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<Heading size="title.md">
|
||||
{isAccountFactory
|
||||
? "Direct contract interaction (advanced)"
|
||||
: chainInfo
|
||||
? "Interact with this contract from your app"
|
||||
: "Getting Started"}
|
||||
</Heading>
|
||||
</Flex>
|
||||
{(noSidebar || isMobile) && (
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<Text>Choose a language:</Text>
|
||||
<div className="flex flex-col gap-2">
|
||||
{environment === "react-native" || environment === "unity" ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Install the latest version of the SDK.{" "}
|
||||
<UnderlineLink
|
||||
className="text-primary-500"
|
||||
href={`https://portal.thirdweb.com/${environment}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn how in the{" "}
|
||||
{environment === "react-native" ? "React Native" : "Unity"}{" "}
|
||||
documentation
|
||||
</UnderlineLink>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Install the latest version of the SDK:
|
||||
</p>
|
||||
<CodeSegment
|
||||
environment={environment}
|
||||
onlyTabs
|
||||
hideTabs
|
||||
isInstallCommand
|
||||
setEnvironment={setEnvironment}
|
||||
snippet={COMMANDS.install}
|
||||
/>
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
<Flex flexDir="column" gap={2}>
|
||||
{environment === "react-native" || environment === "unity" ? (
|
||||
<Text>
|
||||
Install the latest version of the SDK. <br />
|
||||
<UnderlineLink
|
||||
className="text-primary-500"
|
||||
href={`https://portal.thirdweb.com/${environment}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn how in the{" "}
|
||||
{environment === "react-native" ? "React Native" : "Unity"}{" "}
|
||||
documentation
|
||||
</UnderlineLink>
|
||||
.
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text>Install the latest version of the SDK:</Text>
|
||||
<CodeSegment
|
||||
environment={environment}
|
||||
hideTabs
|
||||
isInstallCommand
|
||||
setEnvironment={setEnvironment}
|
||||
snippet={COMMANDS.install}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex flexDir="column" gap={2}>
|
||||
<Text>Initialize the SDK and contract on your project:</Text>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Initialize the SDK and contract on your project:
|
||||
</p>
|
||||
<CodeSegment
|
||||
environment={environment}
|
||||
hideTabs
|
||||
setEnvironment={setEnvironment}
|
||||
snippet={formatSnippet(COMMANDS.setup, {
|
||||
chainId,
|
||||
contractAddress,
|
||||
})}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You will need to pass a client ID/secret key to use thirdweb's
|
||||
infrastructure services. If you don't have any API keys yet you
|
||||
can create one by creating a project for free from the{" "}
|
||||
<UnderlineLink href="/team">dashboard</UnderlineLink>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!onlyInstall && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight mb-3">
|
||||
All Functions & Events
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_2fr] gap-4">
|
||||
<div className="border bg-card rounded-lg overflow-hidden ">
|
||||
{(writeFunctions || []).length > 0 ||
|
||||
(readFunctions || []).length > 0 ? (
|
||||
<div>
|
||||
{/* tabs */}
|
||||
<div className="bg-background">
|
||||
<TabButtons
|
||||
tabs={tabList}
|
||||
tabClassName="!text-sm"
|
||||
tabContainerClassName="px-3 pt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tab === "write" && (
|
||||
<AccentButtonListContainer>
|
||||
{writeFunctions?.map((fn) => (
|
||||
<AccentButton
|
||||
isActive={write?.signature === fn.signature}
|
||||
key={fn.signature}
|
||||
label={fn.name}
|
||||
onClick={() => {
|
||||
setTab("write");
|
||||
setWrite(fn);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</AccentButtonListContainer>
|
||||
)}
|
||||
|
||||
{tab === "read" && (
|
||||
<AccentButtonListContainer>
|
||||
{readFunctions?.map((fn) => (
|
||||
<AccentButton
|
||||
isActive={read?.signature === fn.signature}
|
||||
key={fn.signature}
|
||||
onClick={() => {
|
||||
setTab("read");
|
||||
setRead(fn);
|
||||
}}
|
||||
label={fn.name}
|
||||
/>
|
||||
))}
|
||||
</AccentButtonListContainer>
|
||||
)}
|
||||
|
||||
{tab === "events" && (
|
||||
<AccentButtonListContainer>
|
||||
{events?.map((ev) => (
|
||||
<AccentButton
|
||||
isActive={event?.name === ev.name}
|
||||
key={ev.name}
|
||||
onClick={() => {
|
||||
setTab("events");
|
||||
setEvent(ev);
|
||||
}}
|
||||
label={ev.name}
|
||||
/>
|
||||
))}
|
||||
</AccentButtonListContainer>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<CodeSegment
|
||||
environment={environment}
|
||||
hideTabs
|
||||
setEnvironment={setEnvironment}
|
||||
snippet={formatSnippet(COMMANDS.setup, {
|
||||
chainId,
|
||||
contractAddress,
|
||||
})}
|
||||
/>
|
||||
<Text>
|
||||
You will need to pass a client ID/secret key to use
|
||||
thirdweb's infrastructure services. If you don't have
|
||||
any API keys yet you can create one by creating a project for free
|
||||
from the{" "}
|
||||
<Link color="primary.500" href="/team">
|
||||
dashboard
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{!onlyInstall && (
|
||||
<Flex flexDirection="column" gap={6}>
|
||||
<Heading size="title.md">All Functions & Events</Heading>
|
||||
<SimpleGrid columns={12} gap={3} height="100%">
|
||||
<GridItem
|
||||
as={Card}
|
||||
colSpan={{ base: 12, md: 4 }}
|
||||
height="100%"
|
||||
overflow="auto"
|
||||
overflowY="auto"
|
||||
pt={0}
|
||||
px={0}
|
||||
>
|
||||
<List height="100%" overflowX="hidden">
|
||||
{((writeFunctions || []).length > 0 ||
|
||||
(readFunctions || []).length > 0) && (
|
||||
<Tabs
|
||||
colorScheme="gray"
|
||||
display="flex"
|
||||
flexDir="column"
|
||||
h="100%"
|
||||
position="relative"
|
||||
>
|
||||
<TabList as={Flex}>
|
||||
{(writeFunctions || []).length > 0 && (
|
||||
<Tab flex="1 1 0" gap={2}>
|
||||
<Heading color="inherit" my={1} size="label.md">
|
||||
Write
|
||||
</Heading>
|
||||
</Tab>
|
||||
)}
|
||||
{(readFunctions || []).length > 0 && (
|
||||
<Tab flex="1 1 0" gap={2}>
|
||||
<Heading color="inherit" my={1} size="label.md">
|
||||
Read
|
||||
</Heading>
|
||||
</Tab>
|
||||
)}
|
||||
{(events || []).length > 0 && (
|
||||
<Tab flex="1 1 0" gap={2}>
|
||||
<Heading color="inherit" my={1} size="label.md">
|
||||
Events
|
||||
</Heading>
|
||||
</Tab>
|
||||
)}
|
||||
</TabList>
|
||||
<TabPanels h="auto" overflow="auto">
|
||||
<TabPanel>
|
||||
{writeFunctions?.map((fn) => (
|
||||
<ListItem key={fn.signature} my={0.5}>
|
||||
<Button
|
||||
_hover={{
|
||||
opacity: 1,
|
||||
textDecor: "underline",
|
||||
}}
|
||||
color="heading"
|
||||
fontFamily="mono"
|
||||
fontWeight={
|
||||
tab === "write" &&
|
||||
write?.signature === fn.signature
|
||||
? 600
|
||||
: 400
|
||||
}
|
||||
onClick={() => {
|
||||
setTab("write");
|
||||
setWrite(fn);
|
||||
}}
|
||||
opacity={
|
||||
tab === "write" &&
|
||||
write?.signature === fn.signature
|
||||
? 1
|
||||
: 0.65
|
||||
}
|
||||
size="sm"
|
||||
variant="link"
|
||||
>
|
||||
{fn.name}
|
||||
</Button>
|
||||
</ListItem>
|
||||
))}
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
{readFunctions?.map((fn) => (
|
||||
<ListItem key={fn.signature} my={0.5}>
|
||||
<Button
|
||||
_hover={{
|
||||
opacity: 1,
|
||||
textDecor: "underline",
|
||||
}}
|
||||
color="heading"
|
||||
fontFamily="mono"
|
||||
fontWeight={
|
||||
tab === "read" &&
|
||||
read?.signature === fn.signature
|
||||
? 600
|
||||
: 400
|
||||
}
|
||||
onClick={() => {
|
||||
setTab("read");
|
||||
setRead(fn);
|
||||
}}
|
||||
opacity={
|
||||
tab === "read" &&
|
||||
read?.signature === fn.signature
|
||||
? 1
|
||||
: 0.65
|
||||
}
|
||||
size="sm"
|
||||
variant="link"
|
||||
>
|
||||
{fn.name}
|
||||
</Button>
|
||||
</ListItem>
|
||||
))}
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
{events?.map((ev) => (
|
||||
<ListItem key={ev.name} my={0.5}>
|
||||
<Button
|
||||
_hover={{
|
||||
opacity: 1,
|
||||
textDecor: "underline",
|
||||
}}
|
||||
color="heading"
|
||||
fontFamily="mono"
|
||||
fontWeight={
|
||||
tab === "events" &&
|
||||
(event as AbiEvent).name ===
|
||||
(ev as AbiEvent).name
|
||||
? 600
|
||||
: 400
|
||||
}
|
||||
onClick={() => {
|
||||
setTab("events");
|
||||
setEvent(ev);
|
||||
}}
|
||||
opacity={
|
||||
tab === "events" &&
|
||||
(event as AbiEvent).name ===
|
||||
(ev as AbiEvent).name
|
||||
? 1
|
||||
: 0.65
|
||||
}
|
||||
size="sm"
|
||||
variant="link"
|
||||
>
|
||||
{ev.name}
|
||||
</Button>
|
||||
</ListItem>
|
||||
))}
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
)}
|
||||
</List>
|
||||
</GridItem>
|
||||
<GridItem
|
||||
as={Card}
|
||||
colSpan={{ base: 12, md: 8 }}
|
||||
height="100%"
|
||||
overflow="auto"
|
||||
>
|
||||
<CodeSegment
|
||||
environment={environment}
|
||||
setEnvironment={setEnvironment}
|
||||
snippet={formatSnippet(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
COMMANDS[tab as keyof typeof COMMANDS] as any,
|
||||
{
|
||||
args:
|
||||
abi
|
||||
?.filter(
|
||||
(f) => f.type === "function" || f.type === "event",
|
||||
)
|
||||
?.find(
|
||||
(f) =>
|
||||
f.name ===
|
||||
(tab === "read"
|
||||
? read?.name
|
||||
: tab === "write"
|
||||
? write?.name
|
||||
: event?.name),
|
||||
)
|
||||
?.inputs.map((i) => i.name || "") || [],
|
||||
snippet={formatSnippet(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
COMMANDS[tab as keyof typeof COMMANDS] as any,
|
||||
{
|
||||
args:
|
||||
abi
|
||||
?.filter(
|
||||
(f) => f.type === "function" || f.type === "event",
|
||||
)
|
||||
?.find(
|
||||
(f) =>
|
||||
f.name ===
|
||||
(tab === "read"
|
||||
? read?.name
|
||||
: tab === "write"
|
||||
? write?.name
|
||||
: event?.name),
|
||||
)
|
||||
?.inputs.map((i) => i.name || "") || [],
|
||||
|
||||
chainId,
|
||||
contractAddress,
|
||||
extensionNamespace,
|
||||
fn:
|
||||
tab === "read" ? read : tab === "write" ? write : event,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</GridItem>
|
||||
</SimpleGrid>
|
||||
</Flex>
|
||||
)}
|
||||
</GridItem>
|
||||
</SimpleGrid>
|
||||
chainId,
|
||||
contractAddress,
|
||||
extensionNamespace,
|
||||
fn: tab === "read" ? read : tab === "write" ? write : event,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function AccentButtonListContainer(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 px-2 py-3 max-h-[300px] lg:max-h-[700px] overflow-y-auto">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccentButton(props: {
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"text-sm justify-start h-auto py-1.5 px-2 text-muted-foreground font-normal font-mono",
|
||||
props.isActive && "text-foreground bg-accent",
|
||||
)}
|
||||
onClick={props.onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{props.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountFactorySection(props: {
|
||||
environment: CodeEnvironment;
|
||||
setEnvironment: (environment: CodeEnvironment) => void;
|
||||
address?: string;
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
Integrate your account factory
|
||||
</h2>
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="w-5 h-5 text-blue-500" />
|
||||
<AlertTitle>Account Factory</AlertTitle>
|
||||
<AlertDescription>
|
||||
The recommended way to use account factories is to integrate the{" "}
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/transactions/sponsor"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
thirdweb SDK
|
||||
</UnderlineLink>{" "}
|
||||
in your applications. This will ensure account contracts are
|
||||
deployed for your users only when they need it.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CodeSegment
|
||||
environment={props.environment}
|
||||
hideTabs
|
||||
setEnvironment={props.setEnvironment}
|
||||
snippet={formatSnippet(
|
||||
(WALLETS_SNIPPETS.find((w) => w.id === "smart-wallet")
|
||||
?.supportedLanguages || {}) as Record<CodeEnvironment, string>,
|
||||
{
|
||||
address: props.address,
|
||||
chainId: props.chainId,
|
||||
contractAddress: props.contractAddress,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,15 +22,16 @@ export default function ContractFunctionComment({
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<p className="mt-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold mt-6 mb-3">
|
||||
About this function <Badge>Beta</Badge>
|
||||
</p>
|
||||
</h3>
|
||||
<CodeClient
|
||||
code={query.data}
|
||||
copyButtonClassName="hidden"
|
||||
lang="wikitext"
|
||||
lang="solidity"
|
||||
className="bg-background"
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Divider,
|
||||
Flex,
|
||||
GridItem,
|
||||
Image,
|
||||
List,
|
||||
SimpleGrid,
|
||||
Tab,
|
||||
TabList,
|
||||
Table,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react";
|
||||
import type { AbiEvent, AbiFunction } from "abitype";
|
||||
import { Button } from "chakra/button";
|
||||
import { Card } from "chakra/card";
|
||||
import { Heading } from "chakra/heading";
|
||||
import { Text } from "chakra/text";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
type Dispatch,
|
||||
lazy,
|
||||
@@ -45,12 +20,21 @@ import {
|
||||
type CodeEnvironment,
|
||||
CodeSegment,
|
||||
} from "@/components/blocks/code/code-segment.client";
|
||||
import { camelToTitle } from "@/components/solidity-inputs/helpers";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { useContractFunctionSelectors } from "@/hooks/contract-ui/useContractFunctionSelectors";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { COMMANDS, formatSnippet } from "../code-overview";
|
||||
import { InteractiveAbiFunction } from "./interactive-abi-function";
|
||||
@@ -137,25 +121,21 @@ function ContractFunctionInner(props: ContractFunctionProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={1.5}>
|
||||
<Flex alignItems="center" direction="row" flexWrap="wrap" gap={2}>
|
||||
<Flex alignItems="baseline" flexWrap="wrap" gap={1}>
|
||||
<Heading size="subtitle.md">{camelToTitle(fn.name)}</Heading>
|
||||
<Heading className="text-muted-foreground" size="subtitle.sm">
|
||||
({fn.name})
|
||||
</Heading>
|
||||
</Flex>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center flex-wrap gap-2 border-b pb-3 mb-3">
|
||||
<h3 className="text-lg font-semibold">{fn.name}</h3>
|
||||
{isFunction && <Badge variant="success">{fn.stateMutability}</Badge>}
|
||||
{functionSelector && (
|
||||
<CopyTextButton
|
||||
className="ml-auto text-xs"
|
||||
className="ml-auto text-xs font-mono py-1"
|
||||
copyIconPosition="right"
|
||||
textToCopy={functionSelector}
|
||||
textToShow={functionSelector}
|
||||
tooltip="The selector of this function"
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
{isFunction && (
|
||||
<InteractiveAbiFunction
|
||||
abiFunction={fn}
|
||||
@@ -166,20 +146,20 @@ function ContractFunctionInner(props: ContractFunctionProps) {
|
||||
)}
|
||||
|
||||
{codeSnippet && (
|
||||
<>
|
||||
<Heading mt={6} size="subtitle.md">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold mt-6 mb-3">
|
||||
Use this function in your app
|
||||
</Heading>
|
||||
<Divider mb={2} />
|
||||
</h3>
|
||||
<CodeSegment
|
||||
environment={environment}
|
||||
setEnvironment={setEnvironment}
|
||||
snippet={codeSnippet}
|
||||
codeContainerClassName="bg-background"
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
<ContractFunctionComment contract={contract} functionName={fn.name} />
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,84 +168,48 @@ function ContractFunctionInputs(props: { fn: AbiFunction | AbiEvent }) {
|
||||
const isFunction = "stateMutability" in fn;
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={1.5}>
|
||||
<Flex
|
||||
alignItems={{ base: "start", md: "center" }}
|
||||
direction={{ base: "column", md: "row" }}
|
||||
gap={2}
|
||||
>
|
||||
<Flex alignItems="baseline" flexWrap="wrap" gap={1}>
|
||||
<Heading size="subtitle.md">{camelToTitle(fn.name)}</Heading>
|
||||
<Heading className="text-muted-foreground" size="subtitle.sm">
|
||||
({fn.name})
|
||||
</Heading>
|
||||
</Flex>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center flex-wrap gap-2 border-b pb-2.5 mb-3">
|
||||
<h3 className="text-base font-semibold">{fn.name}</h3>
|
||||
{isFunction && <Badge variant="success">{fn.stateMutability}</Badge>}
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
{fn.inputs?.length ? (
|
||||
<>
|
||||
<Divider my={2} />
|
||||
<Flex flexDir="column" gap={3}>
|
||||
<Heading size="label.lg">Inputs</Heading>
|
||||
<Box
|
||||
borderTopRadius="lg"
|
||||
overflowX="auto"
|
||||
p={0}
|
||||
position="relative"
|
||||
>
|
||||
<Table size="sm">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th border="none">
|
||||
<Heading as="label" color="faded" size="label.sm">
|
||||
Name
|
||||
</Heading>
|
||||
</Th>
|
||||
<Th border="none">
|
||||
<Heading as="label" color="faded" size="label.sm">
|
||||
Type
|
||||
</Heading>
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{fn.inputs.map((input, idx) => (
|
||||
<Tr
|
||||
_last={{ borderBottomWidth: 0 }}
|
||||
borderBottomWidth={1}
|
||||
key={`${input.name}+${idx}}`}
|
||||
>
|
||||
<Td
|
||||
borderBottomColor="borderColor"
|
||||
borderBottomWidth="inherit"
|
||||
>
|
||||
{input?.name ? (
|
||||
<Text fontFamily="mono">{input.name}</Text>
|
||||
) : (
|
||||
<Text
|
||||
className="text-muted-foreground"
|
||||
fontStyle="italic"
|
||||
>
|
||||
No name defined
|
||||
</Text>
|
||||
)}
|
||||
</Td>
|
||||
<Td
|
||||
borderBottomColor="borderColor"
|
||||
borderBottomWidth="inherit"
|
||||
>
|
||||
<Text fontFamily="mono">{input.type}</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Flex>
|
||||
</>
|
||||
) : null}
|
||||
</Flex>
|
||||
<div>
|
||||
<h4 className="text-base font-semibold mb-3">Inputs</h4>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fn.inputs.map((input, idx) => (
|
||||
<TableRow key={`${input.name}+${idx}}`}>
|
||||
<TableCell className="font-medium py-3">
|
||||
{input?.name ? (
|
||||
<span className="font-mono">{input.name}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground italic">
|
||||
No name defined
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-3">
|
||||
<span className="font-mono">{input.type}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-base">No inputs required</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,6 +224,8 @@ type ExtensionFunctions = {
|
||||
functions: AbiFunction[];
|
||||
};
|
||||
|
||||
type ReadOrWriteTab = "read" | "write";
|
||||
|
||||
export const ContractFunctionsPanel: React.FC<ContractFunctionsPanelProps> = ({
|
||||
fnsOrEvents,
|
||||
contract,
|
||||
@@ -296,6 +242,7 @@ export const ContractFunctionsPanel: React.FC<ContractFunctionsPanelProps> = ({
|
||||
});
|
||||
return results;
|
||||
}, [fnsOrEvents]);
|
||||
|
||||
const writeFunctions: ExtensionFunctions[] = useMemo(() => {
|
||||
return functionsWithExtension
|
||||
.map((e) => {
|
||||
@@ -313,6 +260,7 @@ export const ContractFunctionsPanel: React.FC<ContractFunctionsPanelProps> = ({
|
||||
})
|
||||
.filter((e) => e !== undefined) as ExtensionFunctions[];
|
||||
}, [functionsWithExtension]);
|
||||
|
||||
const viewFunctions: ExtensionFunctions[] = useMemo(() => {
|
||||
return functionsWithExtension
|
||||
.map((e) => {
|
||||
@@ -336,166 +284,117 @@ export const ContractFunctionsPanel: React.FC<ContractFunctionsPanelProps> = ({
|
||||
return fnsOrEvents.filter((fn) => !("stateMutability" in fn)) as AbiEvent[];
|
||||
}, [fnsOrEvents]);
|
||||
|
||||
// Load state from the URL
|
||||
const searchParams = useSearchParams();
|
||||
const _selector = searchParams?.get("selector");
|
||||
const _item = _selector
|
||||
? fnsOrEvents.find((o) => {
|
||||
if (o.type === "function") {
|
||||
const selector = toFunctionSelector(o as AbiFunction);
|
||||
return selector === _selector;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
: undefined;
|
||||
const defaultSelected = fnsOrEvents[0];
|
||||
|
||||
const [selectedFunction, setSelectedFunction] = useState<
|
||||
AbiFunction | AbiEvent | undefined
|
||||
>(_item ?? fnsOrEvents[0]);
|
||||
// Set the active tab to Write or Read depends on the `_item`
|
||||
const _defaultTabIndex =
|
||||
_item &&
|
||||
"stateMutability" in _item &&
|
||||
(_item.stateMutability === "view" || _item.stateMutability === "pure")
|
||||
? 1
|
||||
: 0;
|
||||
>(defaultSelected);
|
||||
|
||||
const [_keywordSearch, setKeywordSearch] = useState<string>("");
|
||||
const [keywordSearch] = useDebounce(_keywordSearch, 150);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ReadOrWriteTab>(() => {
|
||||
const isDefaultSelectedReadFunction = viewFunctions.find((e) =>
|
||||
e.functions.find((fn) => fn.name === defaultSelected?.name),
|
||||
);
|
||||
return isDefaultSelectedReadFunction ? "read" : "write";
|
||||
});
|
||||
|
||||
const functionSection = (e: ExtensionFunctions) => {
|
||||
const filteredFunctions = keywordSearch
|
||||
? e.functions.filter((o) =>
|
||||
o.name.toLowerCase().includes(keywordSearch.toLowerCase()),
|
||||
)
|
||||
: e.functions;
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" key={e.extension} mb={6}>
|
||||
{e.extension ? (
|
||||
<>
|
||||
<Flex alignContent="center" alignItems="center" gap={2}>
|
||||
<Image
|
||||
alt="Extension detected"
|
||||
mb="2px"
|
||||
objectFit="contain"
|
||||
src="/assets/dashboard/extension-check.svg"
|
||||
<div key={e.extension} className="pb-6">
|
||||
<div className="space-y-0.5">
|
||||
{selectedFunction &&
|
||||
filteredFunctions.map((fn) => (
|
||||
<FunctionsOrEventsListItem
|
||||
fn={fn}
|
||||
key={`${fn.name}_${fn.type}_${fn.inputs.length}`}
|
||||
selectedFunction={selectedFunction}
|
||||
setSelectedFunction={setSelectedFunction}
|
||||
/>
|
||||
<Heading as="label" size="label.md">
|
||||
{e.extension}
|
||||
</Heading>
|
||||
</Flex>
|
||||
<Divider my={2} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Flex alignContent="center" alignItems="center" gap={2}>
|
||||
<Heading as="label" size="label.md">
|
||||
Other Functions
|
||||
</Heading>
|
||||
</Flex>
|
||||
<Divider my={2} />
|
||||
</>
|
||||
)}
|
||||
{selectedFunction &&
|
||||
filteredFunctions.map((fn) => (
|
||||
<FunctionsOrEventsListItem
|
||||
fn={fn}
|
||||
key={`${fn.name}_${fn.type}_${fn.inputs.length}`}
|
||||
selectedFunction={selectedFunction}
|
||||
setSelectedFunction={setSelectedFunction}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={12} gap={5} height="100%">
|
||||
<GridItem
|
||||
as={Card}
|
||||
className="bg-card"
|
||||
colSpan={{ base: 12, md: 4 }}
|
||||
height="100%"
|
||||
overflow="auto"
|
||||
overflowY="auto"
|
||||
pt={0}
|
||||
px={0}
|
||||
>
|
||||
<List height="100%" overflowX="hidden">
|
||||
{(writeFunctions.length > 0 || viewFunctions.length > 0) && (
|
||||
<Tabs
|
||||
colorScheme="gray"
|
||||
defaultIndex={_defaultTabIndex}
|
||||
display="flex"
|
||||
flexDir="column"
|
||||
h="100%"
|
||||
position="relative"
|
||||
>
|
||||
<TabList as={Flex}>
|
||||
{writeFunctions.length > 0 && (
|
||||
<Tab flex="1 1 0" gap={2}>
|
||||
<Heading color="inherit" my={1} size="label.md">
|
||||
Write
|
||||
</Heading>
|
||||
</Tab>
|
||||
)}
|
||||
{viewFunctions.length > 0 && (
|
||||
<Tab flex="1 1 0" gap={2}>
|
||||
<Heading color="inherit" my={1} size="label.md">
|
||||
Read
|
||||
</Heading>
|
||||
</Tab>
|
||||
)}
|
||||
</TabList>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_2fr] h-full bg-card rounded-lg border">
|
||||
{/* left */}
|
||||
<div className="overflow-auto border-b lg:border-b-0 lg:border-r">
|
||||
{(writeFunctions.length > 0 || viewFunctions.length > 0) && (
|
||||
<div className="flex flex-col h-full relative">
|
||||
<TabButtons
|
||||
tabContainerClassName="px-3 pt-2"
|
||||
tabClassName="!text-sm"
|
||||
tabs={[
|
||||
...(viewFunctions.length > 0
|
||||
? [
|
||||
{
|
||||
name: "Read",
|
||||
onClick: () => setActiveTab("read"),
|
||||
isActive: activeTab === "read",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(writeFunctions.length > 0
|
||||
? [
|
||||
{
|
||||
name: "Write",
|
||||
onClick: () => setActiveTab("write"),
|
||||
isActive: activeTab === "write",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="sticky top-0 z-[1]">
|
||||
<div className="relative w-full">
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-auto rounded-none border-x-0 bg-card py-3 pl-9 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onChange={(e) => setKeywordSearch(e.target.value)}
|
||||
placeholder="Search"
|
||||
value={_keywordSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabPanels h="auto" overflow="auto">
|
||||
{writeFunctions.length > 0 && (
|
||||
<TabPanel>
|
||||
{writeFunctions.map((e) => functionSection(e))}
|
||||
</TabPanel>
|
||||
)}
|
||||
{viewFunctions.length > 0 && (
|
||||
<TabPanel>
|
||||
{viewFunctions.map((e) => functionSection(e))}
|
||||
</TabPanel>
|
||||
)}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{events.length > 0 && selectedFunction && (
|
||||
<Box overflowX="hidden" pt={2} px={4}>
|
||||
{events.map((fn) => (
|
||||
<FunctionsOrEventsListItem
|
||||
fn={fn}
|
||||
key={`${fn.name}_${fn.type}_${fn.inputs.length}`}
|
||||
selectedFunction={selectedFunction}
|
||||
setSelectedFunction={setSelectedFunction}
|
||||
<div className="sticky top-0 z-[1]">
|
||||
<div className="relative w-full">
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-[50%] left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-auto rounded-none border-x-0 border-t-0 bg-card py-3 pl-9 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
onChange={(e) => setKeywordSearch(e.target.value)}
|
||||
placeholder="Search"
|
||||
value={_keywordSearch}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</List>
|
||||
</GridItem>
|
||||
<GridItem
|
||||
as={Card}
|
||||
className="bg-card"
|
||||
colSpan={{ base: 12, md: 8 }}
|
||||
height="100%"
|
||||
overflow="auto"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-auto overflow-auto pt-2 px-2">
|
||||
{activeTab === "write" &&
|
||||
writeFunctions.length > 0 &&
|
||||
writeFunctions.map((e) => functionSection(e))}
|
||||
|
||||
{activeTab === "read" &&
|
||||
viewFunctions.length > 0 &&
|
||||
viewFunctions.map((e) => functionSection(e))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{events.length > 0 && selectedFunction && (
|
||||
<div className="p-4 space-y-0.5">
|
||||
{events.map((fn) => (
|
||||
<FunctionsOrEventsListItem
|
||||
fn={fn}
|
||||
key={`${fn.name}_${fn.type}_${fn.inputs.length}`}
|
||||
selectedFunction={selectedFunction}
|
||||
setSelectedFunction={setSelectedFunction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* right */}
|
||||
<div className="overflow-auto p-4">
|
||||
{selectedFunction && (
|
||||
<ContractFunction
|
||||
contract={contract}
|
||||
@@ -503,8 +402,8 @@ export const ContractFunctionsPanel: React.FC<ContractFunctionsPanelProps> = ({
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
)}
|
||||
</GridItem>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -524,31 +423,19 @@ const FunctionsOrEventsListItem: React.FC<FunctionsOrEventsListItemProps> = ({
|
||||
const isActive =
|
||||
selectedFunction?.name === fn.name &&
|
||||
selectedFunction.inputs?.length === fn.inputs?.length;
|
||||
const pathname = usePathname();
|
||||
const router = useDashboardRouter();
|
||||
return (
|
||||
<li className="my-1">
|
||||
<Button
|
||||
className={cn(
|
||||
"!font-medium !text-muted-foreground hover:!text-foreground font-mono",
|
||||
{
|
||||
"!text-foreground": isActive,
|
||||
},
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedFunction(fn);
|
||||
|
||||
// Only apply to the Explorer page
|
||||
if (pathname?.endsWith("/explorer") && fn.type === "function") {
|
||||
const selector = toFunctionSelector(fn);
|
||||
router.push(`${pathname}?selector=${selector}`);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="link"
|
||||
>
|
||||
{fn.name}
|
||||
</Button>
|
||||
</li>
|
||||
<Button
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground font-mono w-full justify-start h-auto px-2 py-1.5",
|
||||
isActive && "text-foreground bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedFunction(fn);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{fn.name}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Flex,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Tabs,
|
||||
} from "@chakra-ui/react";
|
||||
import type { Abi, AbiEvent, AbiFunction } from "abitype";
|
||||
import { Heading } from "chakra/heading";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { SourcesPanel } from "@/components/contract-components/shared/sources-panel";
|
||||
import type { SourceFile } from "@/components/contract-components/types";
|
||||
import { CodeOverview } from "@/components/contracts/code-overview";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { ContractFunctionsPanel } from "./contract-function";
|
||||
|
||||
interface ContractFunctionsOverview {
|
||||
@@ -22,103 +15,96 @@ interface ContractFunctionsOverview {
|
||||
contract?: ThirdwebContract;
|
||||
sources?: SourceFile[];
|
||||
abi?: Abi;
|
||||
onlyFunctions?: boolean;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
type Tab = "functions" | "events" | "code" | "sources";
|
||||
|
||||
export const ContractFunctionsOverview: React.FC<ContractFunctionsOverview> = ({
|
||||
functions,
|
||||
events,
|
||||
contract,
|
||||
sources,
|
||||
abi,
|
||||
onlyFunctions,
|
||||
isLoggedIn,
|
||||
}) => {
|
||||
if (onlyFunctions) {
|
||||
return (
|
||||
<Flex flexDir="column" gap={2} height="100%">
|
||||
{functions && functions.length > 0 && (
|
||||
<ContractFunctionsPanel
|
||||
contract={contract}
|
||||
fnsOrEvents={functions}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
const [activeTab, setActiveTab] = useState<Tab | undefined>(() => {
|
||||
if (functions && functions.length > 0) return "functions";
|
||||
if (events && events.length > 0) return "events";
|
||||
if (abi) return "code";
|
||||
if (sources && sources.length > 0) return "sources";
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// Tab index: 0 = Functions, 1 = Events, 2 = Code, 3 = Sources
|
||||
const tabOptions = [
|
||||
...(functions && functions.length > 0
|
||||
? [
|
||||
{
|
||||
name: "Functions",
|
||||
onClick: () => setActiveTab("functions"),
|
||||
isActive: activeTab === "functions",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(events && events.length > 0
|
||||
? [
|
||||
{
|
||||
name: "Events",
|
||||
onClick: () => setActiveTab("events"),
|
||||
isActive: activeTab === "events",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(abi
|
||||
? [
|
||||
{
|
||||
name: "Code",
|
||||
onClick: () => setActiveTab("code"),
|
||||
isActive: activeTab === "code",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(sources && sources.length > 0
|
||||
? [
|
||||
{
|
||||
name: "Sources",
|
||||
onClick: () => setActiveTab("sources"),
|
||||
isActive: activeTab === "sources",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={2} w="100%">
|
||||
<Tabs colorScheme="gray" isLazy lazyBehavior="keepMounted">
|
||||
<TabList borderBottomColor="borderColor" borderBottomWidth="1px" px={0}>
|
||||
{functions && functions.length > 0 ? (
|
||||
<Tab>
|
||||
<Heading color="inherit" my={1} size="label.lg">
|
||||
Functions
|
||||
</Heading>
|
||||
</Tab>
|
||||
) : null}
|
||||
{events?.length ? (
|
||||
<Tab>
|
||||
<Heading color="inherit" my={1} size="label.lg">
|
||||
Events
|
||||
</Heading>
|
||||
</Tab>
|
||||
) : null}
|
||||
{abi && (
|
||||
<Tab>
|
||||
<Heading color="inherit" my={1} size="label.lg">
|
||||
Code
|
||||
</Heading>
|
||||
</Tab>
|
||||
)}
|
||||
{sources && sources?.length > 0 && (
|
||||
<Tab>
|
||||
<Heading color="inherit" my={1} size="label.lg">
|
||||
Sources
|
||||
</Heading>
|
||||
</Tab>
|
||||
)}
|
||||
</TabList>
|
||||
<TabPanels py={2}>
|
||||
{functions && functions.length > 0 ? (
|
||||
<TabPanel h="40rem">
|
||||
<ContractFunctionsPanel
|
||||
contract={contract}
|
||||
fnsOrEvents={functions}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</TabPanel>
|
||||
) : null}
|
||||
{events && events?.length > 0 ? (
|
||||
<TabPanel>
|
||||
<ContractFunctionsPanel
|
||||
contract={contract}
|
||||
fnsOrEvents={events}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</TabPanel>
|
||||
) : null}
|
||||
{abi && (
|
||||
<TabPanel>
|
||||
<div className="flex flex-col gap-6">
|
||||
<CodeOverview
|
||||
abi={abi}
|
||||
chainId={contract?.chain.id || 1}
|
||||
noSidebar
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
)}
|
||||
{(sources || abi) && (
|
||||
<TabPanel>
|
||||
<SourcesPanel abi={abi} sources={sources} />
|
||||
</TabPanel>
|
||||
)}
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</Flex>
|
||||
<div>
|
||||
<TabButtons tabs={tabOptions} />
|
||||
<div className="pt-4">
|
||||
{functions && functions.length > 0 && activeTab === "functions" && (
|
||||
<div className="h-[70vh]">
|
||||
<ContractFunctionsPanel
|
||||
contract={contract}
|
||||
fnsOrEvents={functions}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{events && events.length > 0 && activeTab === "events" && (
|
||||
<div className="h-[70vh]">
|
||||
<ContractFunctionsPanel
|
||||
contract={contract}
|
||||
fnsOrEvents={events}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{abi && activeTab === "code" && (
|
||||
<CodeOverview abi={abi} chainId={contract?.chain.id || 1} noSidebar />
|
||||
)}
|
||||
{sources && sources.length > 0 && activeTab === "sources" && (
|
||||
<SourcesPanel abi={abi} sources={sources} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+112
-130
@@ -1,20 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ButtonGroup,
|
||||
Divider,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
} from "@chakra-ui/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { type AbiFunction, type AbiParameter, formatAbiItem } from "abitype";
|
||||
import { Button } from "chakra/button";
|
||||
import { Card } from "chakra/card";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
|
||||
import { Heading } from "chakra/heading";
|
||||
import { Text } from "chakra/text";
|
||||
import { ExternalLinkIcon, InfoIcon, PlayIcon } from "lucide-react";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
InfoIcon,
|
||||
PlayIcon,
|
||||
RefreshCcwIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { FormProvider, useFieldArray, useForm } from "react-hook-form";
|
||||
@@ -29,16 +22,19 @@ import {
|
||||
} from "thirdweb";
|
||||
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import { parseAbiParams, stringify, toFunctionSelector } from "thirdweb/utils";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { SolidityInput } from "@/components/solidity-inputs";
|
||||
import { camelToTitle } from "@/components/solidity-inputs/helpers";
|
||||
import { TransactionButton } from "@/components/tx-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { PlainTextCodeBlock } from "@/components/ui/code/plaintext-code";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { replaceIpfsUrl } from "@/lib/sdk";
|
||||
|
||||
function formatResponseData(data: unknown): {
|
||||
type: "json" | "text";
|
||||
@@ -122,11 +118,11 @@ function formatContractCall(
|
||||
return parsedParams;
|
||||
}
|
||||
|
||||
interface InteractiveAbiFunctionProps {
|
||||
type InteractiveAbiFunctionProps = {
|
||||
abiFunction: AbiFunction;
|
||||
contract: ThirdwebContract;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
function useAsyncRead(contract: ThirdwebContract, abiFunction: AbiFunction) {
|
||||
const formattedAbi = formatAbiItem({
|
||||
@@ -366,97 +362,72 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Card
|
||||
as={Flex}
|
||||
bg="transparent"
|
||||
border="none"
|
||||
borderRadius="none"
|
||||
boxShadow="none"
|
||||
flexDirection="column"
|
||||
flexGrow={1}
|
||||
gap={4}
|
||||
gridColumn={{ base: "span 12", md: "span 9" }}
|
||||
p={0}
|
||||
w="100%"
|
||||
>
|
||||
<Flex
|
||||
as="form"
|
||||
direction="column"
|
||||
gap={2}
|
||||
id={formId}
|
||||
position="relative"
|
||||
w="100%"
|
||||
>
|
||||
{fields.length > 0 && (
|
||||
<>
|
||||
<Divider mb="8px" />
|
||||
{fields.map((item, index) => {
|
||||
return (
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState(
|
||||
`params.${index}.value`,
|
||||
form.formState,
|
||||
).error
|
||||
}
|
||||
key={item.id}
|
||||
mb="8px"
|
||||
>
|
||||
<Flex justify="space-between">
|
||||
<FormLabel>{camelToTitle(item.key)}</FormLabel>
|
||||
<Text fontSize="12px">{item.key}</Text>
|
||||
</Flex>
|
||||
<SolidityInput
|
||||
// @ts-expect-error - old types, need to update
|
||||
solidityComponents={item.components}
|
||||
solidityName={item.key}
|
||||
solidityType={item.type}
|
||||
{...form.register(`params.${index}.value`)}
|
||||
functionName={abiFunction.name}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{
|
||||
form.getFieldState(
|
||||
`params.${index}.value`,
|
||||
form.formState,
|
||||
).error?.message
|
||||
}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
<form className="space-y-5 relative w-full" id={formId}>
|
||||
{fields.length > 0 &&
|
||||
fields.map((item, index) => {
|
||||
const fieldError = form.getFieldState(
|
||||
`params.${index}.value`,
|
||||
form.formState,
|
||||
).error;
|
||||
|
||||
return (
|
||||
<FormFieldSetup
|
||||
key={item.id}
|
||||
htmlFor={`params.${index}.value`}
|
||||
labelClassName="w-full"
|
||||
labelContainerClassName="flex w-full"
|
||||
label={
|
||||
<div className="flex justify-between items-center w-full gap-3">
|
||||
<span>{camelToTitle(item.key)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.key}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
errorMessage={fieldError?.message}
|
||||
isRequired={false}
|
||||
className="mb-2"
|
||||
>
|
||||
<SolidityInput
|
||||
// @ts-expect-error - old types, need to update
|
||||
solidityComponents={item.components}
|
||||
solidityName={item.key}
|
||||
solidityType={item.type}
|
||||
{...form.register(`params.${index}.value`)}
|
||||
functionName={abiFunction.name}
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
})}
|
||||
|
||||
{abiFunction.stateMutability === "payable" && (
|
||||
<>
|
||||
<Divider mb="8px" />
|
||||
<FormControl gap={0.5}>
|
||||
<FormLabel>Native Token Value</FormLabel>
|
||||
<Input {...form.register("value")} />
|
||||
<FormHelperText>
|
||||
The native currency value (in Ether) to send with this
|
||||
transaction (ex: 0.01 to send 0.01 native currency).
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</>
|
||||
<FormFieldSetup
|
||||
htmlFor="value"
|
||||
label="Native Token Value"
|
||||
errorMessage={undefined}
|
||||
isRequired={false}
|
||||
helperText="The native currency value (in Ether) to send with this transaction (ex: 0.01 to send 0.01 native currency)."
|
||||
>
|
||||
<Input {...form.register("value")} className="bg-background" />
|
||||
</FormFieldSetup>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Heading size="label.sm">Error</Heading>
|
||||
<h3 className="text-sm font-medium">Error</h3>
|
||||
<InlineCode
|
||||
className="relative w-full whitespace-pre-wrap rounded-md border border-border p-4 text-red-500"
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
code={formatError(error as any)}
|
||||
/>
|
||||
</>
|
||||
) : readLoading ? (
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
) : formattedResponseData ? (
|
||||
<>
|
||||
<Divider />
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Heading size="label.sm">Output</Heading>
|
||||
<h3 className="text-sm font-medium">Output</h3>
|
||||
{/* Show the Solidity type of the function's output */}
|
||||
{abiFunction.outputs.length > 0 && (
|
||||
<Badge variant="default">
|
||||
@@ -466,30 +437,32 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
|
||||
</div>
|
||||
|
||||
{formattedResponseData.type === "text" ? (
|
||||
<PlainTextCodeBlock code={formattedResponseData.data} />
|
||||
<PlainTextCodeBlock
|
||||
code={formattedResponseData.data}
|
||||
className="bg-background"
|
||||
/>
|
||||
) : (
|
||||
<CodeClient
|
||||
code={formattedResponseData.data}
|
||||
lang={formattedResponseData.type}
|
||||
className="bg-background"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* If the result is an IPFS URI, show a handy link so that users can open it in a new tab */}
|
||||
{formattedResponseData.type === "text" &&
|
||||
formattedResponseData.data.startsWith("ipfs://") && (
|
||||
<Text size="label.sm">
|
||||
<UnderlineLink
|
||||
href={replaceIpfsUrl(
|
||||
formattedResponseData.data,
|
||||
contract.client,
|
||||
)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Open in gateway
|
||||
</UnderlineLink>
|
||||
</Text>
|
||||
<Link
|
||||
href={`https://ipfs.io/ipfs/${formattedResponseData.data.split("ipfs://")[1]}`}
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-2 mt-1"
|
||||
target="_blank"
|
||||
>
|
||||
Open In IPFS Gateway
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Same with the logic above but this time it's applied to traditional urls */}
|
||||
{((formattedResponseData.type === "text" &&
|
||||
formattedResponseData.data.startsWith("https://")) ||
|
||||
@@ -506,37 +479,46 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Flex>
|
||||
</form>
|
||||
|
||||
<Divider mt="auto" />
|
||||
<ButtonGroup ml="auto">
|
||||
<div className="flex gap-3 justify-end">
|
||||
{isView ? (
|
||||
<Button
|
||||
colorScheme="primary"
|
||||
form={formId}
|
||||
isDisabled={!abiFunction}
|
||||
isLoading={readLoading}
|
||||
disabled={!abiFunction}
|
||||
onClick={handleContractRead}
|
||||
rightIcon={<PlayIcon className="size-4" />}
|
||||
className="gap-2 bg-background rounded-full"
|
||||
variant="outline"
|
||||
>
|
||||
Run
|
||||
{/* run / rerun */}
|
||||
{readLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : readData ? (
|
||||
<RefreshCcwIcon className="size-4 text-muted-foreground" />
|
||||
) : (
|
||||
<PlayIcon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
{readData ? "Run again" : readLoading ? "Running" : "Run"}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
isDisabled={
|
||||
!abiFunction || txSimulation.isPending || mutationLoading
|
||||
}
|
||||
isLoading={txSimulation.isPending}
|
||||
onClick={handleContractSimulation}
|
||||
>
|
||||
<ToolTipLabel label="Simulate the transaction to see its potential outcome without actually sending it to the network. This action doesn't cost gas.">
|
||||
<span className="mr-3">
|
||||
<InfoIcon className="size-5" />
|
||||
</span>
|
||||
</ToolTipLabel>
|
||||
Simulate
|
||||
</Button>
|
||||
<ToolTipLabel label="Simulate the transaction to see its potential outcome without actually sending it to the network. This action doesn't cost gas.">
|
||||
<Button
|
||||
className="gap-2 bg-background"
|
||||
variant="outline"
|
||||
disabled={
|
||||
!abiFunction || txSimulation.isPending || mutationLoading
|
||||
}
|
||||
onClick={handleContractSimulation}
|
||||
>
|
||||
{txSimulation.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
<InfoIcon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
Simulate
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
disabled={
|
||||
@@ -553,8 +535,8 @@ export const InteractiveAbiFunction: React.FC<InteractiveAbiFunctionProps> = (
|
||||
</TransactionButton>
|
||||
</>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
import {
|
||||
Flex,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
Tooltip,
|
||||
} from "@chakra-ui/react";
|
||||
import { Button } from "chakra/button";
|
||||
import { FormErrorMessage, FormLabel } from "chakra/form";
|
||||
import { BanIcon, PlusIcon, TrashIcon, UploadIcon, XIcon } from "lucide-react";
|
||||
import { PlusIcon, TrashIcon, XIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
type ArrayPath,
|
||||
@@ -25,7 +14,16 @@ import {
|
||||
type WatchObserver,
|
||||
} from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { FileInput } from "@/components/blocks/FileInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type OptionalPropertiesInput = {
|
||||
[key: string]: string | number;
|
||||
@@ -46,17 +44,15 @@ interface IPropertiesFormControlProps<
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const PropertiesFormControl = <
|
||||
export function PropertiesFormControl<
|
||||
TFieldValues extends IPropertyFieldValues,
|
||||
>({
|
||||
control,
|
||||
register,
|
||||
watch,
|
||||
errors,
|
||||
setValue,
|
||||
client,
|
||||
}: React.PropsWithChildren<IPropertiesFormControlProps<TFieldValues>>) => {
|
||||
const { fields, append, remove, replace } = useFieldArray({
|
||||
}: React.PropsWithChildren<IPropertiesFormControlProps<TFieldValues>>) {
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "attributes" as ArrayPath<TFieldValues>,
|
||||
});
|
||||
@@ -72,19 +68,7 @@ export const PropertiesFormControl = <
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Flex align="center" direction="row" justify="space-between">
|
||||
<FormLabel m={0}>Attributes</FormLabel>
|
||||
<Button
|
||||
colorScheme="red"
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
onClick={() => replace([{ trait_type: "", value: "" } as any])}
|
||||
rightIcon={<BanIcon className="size-4" />}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Flex>
|
||||
<FormLabel>Attributes</FormLabel>
|
||||
{fields.map((field, index) => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
const keyError = (errors as any)?.attributes?.[index]?.trait_type
|
||||
@@ -92,102 +76,108 @@ export const PropertiesFormControl = <
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
const valueError = (errors as any)?.attributes?.[index]?.value
|
||||
?.message as string | undefined;
|
||||
const isInvalid = !!(keyError || valueError);
|
||||
const _isInvalid = !!(keyError || valueError);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-2" key={field.id}>
|
||||
<FormControl
|
||||
className="flex flex-row items-start gap-3"
|
||||
isInvalid={isInvalid}
|
||||
>
|
||||
<FormControl isInvalid={!!keyError}>
|
||||
<Input
|
||||
{...register(
|
||||
`attributes.${index}.trait_type` as Path<TFieldValues>,
|
||||
)}
|
||||
placeholder="trait_type"
|
||||
/>
|
||||
<FormErrorMessage>{keyError}</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!valueError}>
|
||||
{watch(
|
||||
`attributes.${index}.value` as unknown as WatchObserver<TFieldValues>,
|
||||
) instanceof File ? (
|
||||
<InputGroup>
|
||||
<Input
|
||||
isDisabled
|
||||
value={
|
||||
watch(`attributes.${index}.value` as Path<TFieldValues>)
|
||||
.name
|
||||
}
|
||||
/>
|
||||
<InputRightElement>
|
||||
<TrashIcon
|
||||
className="size-4 cursor-pointer text-red-300 hover:text-red-200"
|
||||
onClick={() =>
|
||||
setValue(
|
||||
`attributes.${index}.value` as Path<TFieldValues>,
|
||||
"" as PathValue<TFieldValues, Path<TFieldValues>>,
|
||||
)
|
||||
}
|
||||
<div className="flex flex-row items-start gap-3 flex-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name={`attributes.${index}.trait_type` as Path<TFieldValues>}
|
||||
render={({ field: traitField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...traitField}
|
||||
placeholder="trait_type"
|
||||
className={cn(
|
||||
"bg-card",
|
||||
keyError && "border-destructive",
|
||||
)}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<InputGroup>
|
||||
<Input
|
||||
{...register(
|
||||
`attributes.${index}.value` as Path<TFieldValues>,
|
||||
)}
|
||||
placeholder="value"
|
||||
/>
|
||||
<InputRightElement>
|
||||
<Tooltip label="Upload file" shouldWrapChildren>
|
||||
<FileInput
|
||||
client={client}
|
||||
setValue={(file) => {
|
||||
setValue(
|
||||
`attributes.${index}.value` as Path<TFieldValues>,
|
||||
file as PathValue<
|
||||
TFieldValues,
|
||||
Path<TFieldValues>
|
||||
>,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<UploadIcon className="size-4 text-muted-foreground" />
|
||||
</FileInput>
|
||||
</Tooltip>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
{keyError && <FormMessage>{keyError}</FormMessage>}
|
||||
</FormItem>
|
||||
)}
|
||||
<FormErrorMessage>{valueError}</FormErrorMessage>
|
||||
</FormControl>
|
||||
</FormControl>
|
||||
<IconButton
|
||||
aria-label="remove key value pair"
|
||||
colorScheme="red"
|
||||
icon={<XIcon />}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={control}
|
||||
name={`attributes.${index}.value` as Path<TFieldValues>}
|
||||
render={({ field: valueField }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
{watch(
|
||||
`attributes.${index}.value` as unknown as WatchObserver<TFieldValues>,
|
||||
) instanceof File ? (
|
||||
<div className="relative">
|
||||
<Input
|
||||
disabled
|
||||
value={
|
||||
watch(
|
||||
`attributes.${index}.value` as Path<TFieldValues>,
|
||||
).name
|
||||
}
|
||||
className="pr-10 bg-card"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||
onClick={() =>
|
||||
setValue(
|
||||
`attributes.${index}.value` as Path<TFieldValues>,
|
||||
"" as PathValue<
|
||||
TFieldValues,
|
||||
Path<TFieldValues>
|
||||
>,
|
||||
)
|
||||
}
|
||||
>
|
||||
<TrashIcon className="size-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
{...valueField}
|
||||
placeholder="value"
|
||||
className={`pr-10 bg-card ${valueError ? "border-destructive" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FormControl>
|
||||
{valueError && <FormMessage>{valueError}</FormMessage>}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full p-0 size-10 bg-card"
|
||||
onClick={() => remove(index)}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
/>
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
leftIcon={<PlusIcon className="size-5" />}
|
||||
className="rounded-full"
|
||||
onClick={() =>
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
append({ trait_type: undefined, value: undefined } as any)
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
<PlusIcon className="size-4 mr-2" />
|
||||
Add Row
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,40 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Divider, Flex, GridItem, List, ListItem } from "@chakra-ui/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { LinkButton } from "chakra/button";
|
||||
import { Card } from "chakra/card";
|
||||
import { Heading } from "chakra/heading";
|
||||
import { Text } from "chakra/text";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
BookOpenTextIcon,
|
||||
CalendarDaysIcon,
|
||||
ExternalLinkIcon,
|
||||
PencilIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { download } from "thirdweb/storage";
|
||||
import invariant from "tiny-invariant";
|
||||
import { PublisherHeader } from "@/components//contract-components/publisher/publisher-header";
|
||||
import { PublisherLink } from "@/components//contract-components/publisher/publisher-header";
|
||||
import { MarkdownRenderer } from "@/components/blocks/markdown-renderer";
|
||||
import type { PublishedContractWithVersion } from "@/components/contract-components/fetch-contracts-with-versions";
|
||||
import { ContractFunctionsOverview } from "@/components/contracts/functions/contract-functions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
usePublishedContractEvents,
|
||||
usePublishedContractFunctions,
|
||||
} from "@/hooks/contract-hooks";
|
||||
import { correctAndUniqueLicenses } from "@/lib/licenses";
|
||||
import { replaceIpfsUrl } from "@/lib/sdk";
|
||||
import { publicIPFSGateway } from "@/lib/sdk";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ExtendedPublishedContract extends PublishedContractWithVersion {
|
||||
type ExtendedPublishedContract = PublishedContractWithVersion & {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
@@ -43,21 +35,19 @@ interface ExtendedPublishedContract extends PublishedContractWithVersion {
|
||||
tags?: string[];
|
||||
logo?: string;
|
||||
audit?: string;
|
||||
}
|
||||
};
|
||||
|
||||
interface PublishedContractProps {
|
||||
publishedContract: ExtendedPublishedContract;
|
||||
isLoggedIn: boolean;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const PublishedContract: React.FC<PublishedContractProps> = ({
|
||||
export function PublishedContract({
|
||||
publishedContract,
|
||||
isLoggedIn,
|
||||
client,
|
||||
}) => {
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
maxWidthClassName,
|
||||
}: {
|
||||
publishedContract: ExtendedPublishedContract;
|
||||
isLoggedIn: boolean;
|
||||
client: ThirdwebClient;
|
||||
maxWidthClassName?: string;
|
||||
}) {
|
||||
const contractFunctions = usePublishedContractFunctions(publishedContract);
|
||||
const contractEvents = usePublishedContractEvents(publishedContract);
|
||||
|
||||
@@ -108,164 +98,143 @@ export const PublishedContract: React.FC<PublishedContractProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<GridItem colSpan={{ base: 12, md: 9 }}>
|
||||
<Flex flexDir="column" gap={6}>
|
||||
{address === publishedContract.publisher && (
|
||||
<LinkButton
|
||||
href={`/contracts/publish/${encodeURIComponent(
|
||||
publishedContract.publishMetadataUri.replace("ipfs://", ""),
|
||||
)}`}
|
||||
leftIcon={<PencilIcon className="size-3" />}
|
||||
ml="auto"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Edit
|
||||
</LinkButton>
|
||||
)}
|
||||
{publishedContract?.readme && (
|
||||
<Card as={Flex} flexDir="column" gap={2} p={6} position="relative">
|
||||
<MarkdownRenderer markdownText={publishedContract?.readme} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{publishedContract?.changelog && (
|
||||
<Card as={Flex} flexDir="column" gap={2} p={0}>
|
||||
<Heading pb={2} pt={5} px={6} size="title.sm">
|
||||
{publishedContract?.version} Release Notes
|
||||
</Heading>
|
||||
<Divider />
|
||||
|
||||
<MarkdownRenderer
|
||||
className="px-6 pt-2 pb-5"
|
||||
markdownText={publishedContract?.changelog}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
{contractFunctions && (
|
||||
<Card p={0}>
|
||||
<ContractFunctionsOverview
|
||||
abi={publishedContract?.abi}
|
||||
events={contractEvents}
|
||||
functions={contractFunctions}
|
||||
isLoggedIn={isLoggedIn}
|
||||
sources={sources.data}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Flex>
|
||||
</GridItem>
|
||||
|
||||
<GridItem colSpan={{ base: 12, md: 3 }}>
|
||||
<Flex flexDir="column" gap={6}>
|
||||
{publishedContract.publisher && (
|
||||
<PublisherHeader
|
||||
<div>
|
||||
{/* right side content moved below changelog */}
|
||||
{publishedContract.publisher && (
|
||||
<div className="border-b py-6">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col md:flex-row lg:items-center gap-6 justify-between flex-wrap",
|
||||
maxWidthClassName,
|
||||
)}
|
||||
>
|
||||
<PublisherLink
|
||||
client={client}
|
||||
wallet={publishedContract.publisher}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="font-semibold text-xl tracking-tight">Details</h4>
|
||||
<List as={Flex} flexDir="column" gap={5}>
|
||||
<ul className="flex flex-col md:flex-row gap-8 lg:items-center lg:gap-6">
|
||||
{/* timestamp */}
|
||||
{publishedContract.publishTimestamp && (
|
||||
<ListItem>
|
||||
<Flex alignItems="flex-start" gap={3}>
|
||||
<CalendarDaysIcon className="size-5 text-muted-foreground" />
|
||||
<Flex direction="column" gap={1}>
|
||||
<Heading as="h5" size="label.sm">
|
||||
Publish Date
|
||||
</Heading>
|
||||
<Text lineHeight={1.2} size="body.md">
|
||||
{publishDate}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ListItem>
|
||||
<li className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-full border bg-card">
|
||||
<CalendarDaysIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1.5 [&>*]:leading-none">
|
||||
<h5 className="text-sm font-medium">Publish Date</h5>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{publishDate}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{/* audit */}
|
||||
{publishedContract?.audit && (
|
||||
<ListItem>
|
||||
<Flex alignItems="flex-start" gap={3}>
|
||||
<ShieldCheckIcon className="size-5 text-green-500" />
|
||||
<Flex direction="column" gap={1}>
|
||||
<Heading as="h5" size="label.sm">
|
||||
Audit Report
|
||||
</Heading>
|
||||
<Text lineHeight={1.2} size="body.md">
|
||||
<Link
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
href={replaceIpfsUrl(publishedContract.audit, client)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
View Audit Report
|
||||
</Link>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ListItem>
|
||||
<li>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-full border bg-card">
|
||||
<ShieldCheckIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1.5 [&>*]:leading-none">
|
||||
<h5 className="text-sm font-medium">Audit Report</h5>
|
||||
<Link
|
||||
href={publicIPFSGateway(publishedContract.audit)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 text-sm hover:underline leading-none text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
View Audit Report
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
<ListItem>
|
||||
<Flex alignItems="flex-start" gap={3}>
|
||||
<BookOpenTextIcon className="size-5 text-muted-foreground" />
|
||||
<Flex direction="column" gap={1}>
|
||||
<Heading as="h5" size="label.sm">
|
||||
License
|
||||
{licenses.length > 1 ? "s" : ""}
|
||||
</Heading>
|
||||
<Text lineHeight={1.2} size="body.md">
|
||||
{licenses.join(", ") || "None"}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ListItem>
|
||||
|
||||
{/* license */}
|
||||
<li className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-full border bg-card">
|
||||
<BookOpenTextIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1.5 [&>*]:leading-none">
|
||||
<h5 className="text-sm font-medium">
|
||||
License{licenses.length > 1 ? "s" : ""}
|
||||
</h5>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{licenses.join(", ") || "None"}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{(publishedContract?.isDeployableViaProxy &&
|
||||
hasImplementationAddresses) ||
|
||||
(publishedContract?.isDeployableViaFactory &&
|
||||
hasFactoryAddresses) ? (
|
||||
<ListItem>
|
||||
<Flex alignItems="flex-start" gap={2}>
|
||||
<ServerIcon className="size-5 text-muted-foreground" />
|
||||
<Flex direction="column" gap={1}>
|
||||
<Heading as="h5" size="label.sm">
|
||||
{publishedContract?.isDeployableViaFactory
|
||||
? "Factory"
|
||||
: "Proxy"}{" "}
|
||||
Enabled
|
||||
</Heading>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ListItem>
|
||||
<li className="flex items-center gap-2.5">
|
||||
<div className="p-2 rounded-full border bg-card">
|
||||
<ServerIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<h5 className="text-sm font-medium">
|
||||
{publishedContract?.isDeployableViaFactory
|
||||
? "Factory"
|
||||
: "Proxy"}{" "}
|
||||
Enabled
|
||||
</h5>
|
||||
</li>
|
||||
) : null}
|
||||
</List>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<div className={cn("py-8 space-y-8", maxWidthClassName)}>
|
||||
{/* main content */}
|
||||
<div className="space-y-6">
|
||||
{/* readme */}
|
||||
{publishedContract?.readme && (
|
||||
<div className="overflow-hidden">
|
||||
<MarkdownRenderer markdownText={publishedContract?.readme} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button asChild className="w-full gap-2 bg-card" variant="outline">
|
||||
<Link
|
||||
href="https://portal.thirdweb.com/contracts/publish/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about Publish{" "}
|
||||
<ExternalLinkIcon className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
</Flex>
|
||||
</GridItem>
|
||||
</>
|
||||
{/* changelog */}
|
||||
{publishedContract?.changelog && (
|
||||
<div className="overflow-hidden">
|
||||
<div className="border-b border-dashed pb-3 mb-3">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{publishedContract?.version} Release Notes
|
||||
</h3>
|
||||
</div>
|
||||
<MarkdownRenderer
|
||||
markdownText={publishedContract?.changelog}
|
||||
p={{
|
||||
className: "text-sm leading-6",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contractFunctions && (
|
||||
<ContractFunctionsOverview
|
||||
abi={publishedContract?.abi}
|
||||
events={contractEvents}
|
||||
functions={contractFunctions}
|
||||
isLoggedIn={isLoggedIn}
|
||||
sources={sources.data}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: find a place to put this
|
||||
type ContractSource = {
|
||||
filename: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
async function fetchSourceFilesFromMetadata(
|
||||
publishedMetadata: ExtendedPublishedContract,
|
||||
client: ThirdwebClient,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ComponentWithChildren } from "@/types/component-with-children";
|
||||
|
||||
interface DelayedDisplayProps {
|
||||
delay: number;
|
||||
}
|
||||
|
||||
function useDelayedDisplay(delay: number) {
|
||||
const [displayContent, setDisplayContent] = useState(false);
|
||||
|
||||
// FIXME: this is a weird thing, we should not need it - in the meantime this is a legit use case
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDisplayContent(true);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [delay]);
|
||||
|
||||
return displayContent;
|
||||
}
|
||||
|
||||
export const DelayedDisplay: ComponentWithChildren<DelayedDisplayProps> = ({
|
||||
delay,
|
||||
children,
|
||||
}) => {
|
||||
const displayContent = useDelayedDisplay(delay);
|
||||
|
||||
if (!displayContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -10,9 +10,9 @@ export const SolidityBoolInput: React.FC<SolidityInputProps> = ({
|
||||
const watchInput = form.watch(inputName);
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<div className="inline-flex overflow-hidden rounded-md border border-border">
|
||||
<div className="inline-flex">
|
||||
<Button
|
||||
className="rounded-none border-r-0"
|
||||
className="border-r-0 rounded-r-none"
|
||||
onClick={() => form.setValue(inputName, "true")}
|
||||
size="sm"
|
||||
variant={watchInput === "true" ? "default" : "outline"}
|
||||
@@ -20,7 +20,7 @@ export const SolidityBoolInput: React.FC<SolidityInputProps> = ({
|
||||
True
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-none rounded-l-none"
|
||||
className="rounded-l-none"
|
||||
onClick={() => form.setValue(inputName, "false")}
|
||||
size="sm"
|
||||
variant={watchInput === "false" ? "default" : "outline"}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import { Button, type ButtonProps } from "chakra/button";
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { FileInput } from "@/components/blocks/FileInput";
|
||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||
import { useErrorHandler } from "@/contexts/error-handler";
|
||||
import type { ComponentWithChildren } from "@/types/component-with-children";
|
||||
|
||||
@@ -39,12 +39,13 @@ export const IpfsUploadButton: ComponentWithChildren<IpfsUploadButtonProps> = ({
|
||||
<FileInput client={client} setValue={handleUpload}>
|
||||
<Button
|
||||
aria-label="Upload to IPFS"
|
||||
isLoading={storageUpload.isPending}
|
||||
rightIcon={<UploadIcon className="size-4" />}
|
||||
variant="secondary"
|
||||
disabled={storageUpload.isPending}
|
||||
size="sm"
|
||||
variant="solid"
|
||||
className="h-auto py-1.5 text-xs -translate-x-0.5 rounded-sm"
|
||||
{...buttonProps}
|
||||
>
|
||||
<UploadIcon className="size-3 mr-2 text-muted-foreground" />
|
||||
{children}
|
||||
</Button>
|
||||
</FileInput>
|
||||
|
||||
@@ -28,7 +28,7 @@ type TransactionButtonProps = Omit<ButtonProps, "variant"> & {
|
||||
transactionCount: number | undefined; // support for unknown number of tx count
|
||||
isPending: boolean;
|
||||
txChainID: number;
|
||||
variant?: "destructive" | "primary" | "default";
|
||||
variant?: "destructive" | "primary" | "default" | "outline";
|
||||
isLoggedIn: boolean;
|
||||
checkBalance?: boolean;
|
||||
client: ThirdwebClient;
|
||||
|
||||
@@ -8,29 +8,42 @@ import { ToolTipLabel } from "./tooltip";
|
||||
|
||||
export function CopyButton(props: {
|
||||
text: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
variant?: "ghost" | "primary" | "secondary";
|
||||
tooltip?: boolean;
|
||||
variant?: "ghost" | "primary" | "secondary" | "default" | "outline";
|
||||
}) {
|
||||
const { hasCopied, onCopy } = useClipboard(props.text, 1000);
|
||||
return (
|
||||
<ToolTipLabel label="Copy">
|
||||
<Button
|
||||
aria-label="Copy"
|
||||
className={cn("h-auto w-auto p-1", props.className)}
|
||||
onClick={onCopy}
|
||||
variant={props.variant || "ghost"}
|
||||
>
|
||||
{hasCopied ? (
|
||||
<CheckIcon
|
||||
className={cn("size-4 text-green-500", props.iconClassName)}
|
||||
/>
|
||||
) : (
|
||||
<CopyIcon
|
||||
className={cn("size-4 text-muted-foreground", props.iconClassName)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
const showTooltip = props.tooltip ?? true;
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
aria-label="Copy"
|
||||
className={cn(
|
||||
"h-auto w-auto flex items-center gap-2 text-muted-foreground",
|
||||
props.label ? "p-2" : "p-1",
|
||||
props.className,
|
||||
)}
|
||||
onClick={onCopy}
|
||||
variant={props.variant || "ghost"}
|
||||
>
|
||||
{hasCopied ? (
|
||||
<CheckIcon
|
||||
className={cn("size-4 text-green-500", props.iconClassName)}
|
||||
/>
|
||||
) : (
|
||||
<CopyIcon
|
||||
className={cn("size-4 text-muted-foreground", props.iconClassName)}
|
||||
/>
|
||||
)}
|
||||
{props.label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!showTooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return <ToolTipLabel label="Copy">{button}</ToolTipLabel>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"bg-card relative w-full rounded-lg border border-border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
"bg-card relative w-full rounded-xl border border-border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
@@ -38,7 +38,7 @@ const AlertTitle = React.forwardRef<
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
className={cn("font-medium leading-tight tracking-tight", className)}
|
||||
className={cn("font-medium leading-tight", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
@@ -51,7 +51,7 @@ const AlertDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1.5 text-muted-foreground text-sm [&_p]:leading-relaxed",
|
||||
"mt-1 text-muted-foreground text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:selection:bg-foreground/20",
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-inverted selection:text-inverted-foreground file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function TabLinks(props: {
|
||||
<Link
|
||||
aria-disabled={tab.isDisabled}
|
||||
className={cn(
|
||||
"relative h-auto rounded-lg px-3 font-normal text-muted-foreground text-sm hover:bg-accent lg:text-sm",
|
||||
"relative h-auto rounded-lg px-3 font-normal text-muted-foreground text-sm lg:text-sm",
|
||||
!tab.isActive && !tab.isDisabled && "hover:text-foreground",
|
||||
tab.isDisabled && "pointer-events-none",
|
||||
tab.isActive && "!text-foreground",
|
||||
|
||||
@@ -46,7 +46,7 @@ export function ToolTipLabel(props: {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100} disableHoverableContent={!props.hoverable}>
|
||||
<TooltipTrigger asChild className="!pointer-events-auto">
|
||||
<TooltipTrigger asChild className="!pointer-events-auto" tabIndex={-1}>
|
||||
{props.children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
|
||||
@@ -1223,7 +1223,7 @@ export function useEngineUpdateAccessToken(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export type CreateWebhookInput = {
|
||||
type CreateWebhookInput = {
|
||||
url: string;
|
||||
name: string;
|
||||
eventType: string;
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { replaceIpfsUrl } from "@/lib/sdk";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fallbackChainIcon } from "@/utils/chain-icons";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
|
||||
type ImageProps = React.ComponentProps<"img">;
|
||||
|
||||
@@ -19,7 +18,12 @@ export const ChainIconClient = ({
|
||||
src,
|
||||
...restProps
|
||||
}: ChainIconProps) => {
|
||||
const resolvedSrc = src ? replaceIpfsUrl(src, client) : fallbackChainIcon;
|
||||
const resolvedSrc = src
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client,
|
||||
uri: src,
|
||||
})
|
||||
: fallbackChainIcon;
|
||||
|
||||
return (
|
||||
<Img
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { resolveScheme } from "thirdweb/storage";
|
||||
|
||||
export function replaceIpfsUrl(uri: string, client: ThirdwebClient) {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return resolveScheme({
|
||||
client,
|
||||
uri,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("error resolving ipfs url", uri, err);
|
||||
return uri;
|
||||
export function publicIPFSGateway(uri: string) {
|
||||
if (uri.startsWith("ipfs://")) {
|
||||
return uri.replace("ipfs://", "https://ipfs.io/ipfs/");
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ export function teamStub(id: string, billingPlan: Team["billingPlan"]): Team {
|
||||
enabled: true,
|
||||
rateLimit: 10,
|
||||
},
|
||||
gateway: {
|
||||
enabled: true,
|
||||
rateLimit: 1000,
|
||||
},
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
dedicatedSupportChannel: null,
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { toast } from "sonner";
|
||||
|
||||
// if browser supports sharing use native sharing - use that, else just copy to clipboard + show toast
|
||||
export async function shareLink(_data: ShareData) {
|
||||
const data = { ..._data, url: _data.url || window.location.href };
|
||||
|
||||
if (
|
||||
"canShare" in navigator &&
|
||||
navigator.canShare &&
|
||||
navigator.canShare(data)
|
||||
) {
|
||||
await navigator.share(data);
|
||||
} else {
|
||||
await navigator.clipboard.writeText(data.url);
|
||||
toast.info("URL copied to clipboard");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isPossibleEVMAddress } from "@/utils/address-utils";
|
||||
|
||||
export function shortenString(str: string, extraShort = true) {
|
||||
export function shortenString(str: string, extraShort = false) {
|
||||
return `${str.substring(0, extraShort ? 4 : 6)}...${str.substring(
|
||||
str.length - (extraShort ? 3 : 4),
|
||||
)}`;
|
||||
|
||||
@@ -74,7 +74,7 @@ export default async function RoutesPage(props: {
|
||||
</div>
|
||||
<a
|
||||
className="inline-flex items-center gap-2 rounded-md bg-green-600 px-4 py-2 font-medium text-sm text-white transition-all hover:bg-green-600/90 hover:shadow-sm"
|
||||
href="https://portal.thirdweb.com/pay"
|
||||
href="https://portal.thirdweb.com/payments"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
+22
-24
@@ -23,29 +23,27 @@ export const CancelTab: React.FC<CancelTabProps> = ({
|
||||
: cancelListing({ contract, listingId: BigInt(id) });
|
||||
const cancelQuery = useSendAndConfirmTransaction();
|
||||
return (
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<TransactionButton
|
||||
className="self-end"
|
||||
client={contract.client}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={cancelQuery.isPending}
|
||||
onClick={() => {
|
||||
const promise = cancelQuery.mutateAsync(transaction, {
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
toast.promise(promise, {
|
||||
error: "Failed to cancel",
|
||||
loading: `Cancelling ${isAuction ? "auction" : "listing"}`,
|
||||
success: "Item cancelled successfully",
|
||||
});
|
||||
}}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
>
|
||||
Cancel {isAuction ? "Auction" : "Listing"}
|
||||
</TransactionButton>
|
||||
</div>
|
||||
<TransactionButton
|
||||
className="self-end"
|
||||
client={contract.client}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={cancelQuery.isPending}
|
||||
onClick={() => {
|
||||
const promise = cancelQuery.mutateAsync(transaction, {
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
toast.promise(promise, {
|
||||
error: "Failed to cancel",
|
||||
loading: `Cancelling ${isAuction ? "auction" : "listing"}`,
|
||||
success: "Item cancelled successfully",
|
||||
});
|
||||
}}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
>
|
||||
Cancel {isAuction ? "Auction" : "Listing"}
|
||||
</TransactionButton>
|
||||
);
|
||||
};
|
||||
|
||||
+8
-2
@@ -51,8 +51,14 @@ export const CreateListingButton: React.FC<CreateListingButtonProps> = ({
|
||||
<ListerOnly contract={contract}>
|
||||
<Sheet onOpenChange={setOpen} open={open}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="primary" {...restButtonProps} disabled={!address}>
|
||||
{createText} <PlusIcon className="ml-2 size-5" />
|
||||
<Button
|
||||
{...restButtonProps}
|
||||
disabled={!address}
|
||||
className="gap-2"
|
||||
size="sm"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
{createText}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-full overflow-y-auto sm:min-w-[540px] lg:min-w-[700px]">
|
||||
|
||||
+475
-416
@@ -1,14 +1,4 @@
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
Select,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
} from "@chakra-ui/react";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
|
||||
import { CircleAlertIcon, InfoIcon } from "lucide-react";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { type Dispatch, type SetStateAction, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -44,19 +34,34 @@ import { CurrencySelector } from "@/components/blocks/CurrencySelector";
|
||||
import { NFTMediaWithEmptyState } from "@/components/blocks/nft-media";
|
||||
import { SolidityInput } from "@/components/solidity-inputs";
|
||||
import { TransactionButton } from "@/components/tx-button";
|
||||
import { Alert, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useDashboardOwnedNFTs } from "@/hooks/useDashboardOwnedNFTs";
|
||||
import { useTxNotifications } from "@/hooks/useTxNotifications";
|
||||
import { useWalletNFTs } from "@/hooks/useWalletNFTs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isAlchemySupported } from "@/lib/wallet/nfts/isAlchemySupported";
|
||||
import { isMoralisSupported } from "@/lib/wallet/nfts/isMoralisSupported";
|
||||
import type { WalletNFT } from "@/lib/wallet/nfts/types";
|
||||
import { shortenIfAddress } from "@/utils/usedapp-external";
|
||||
|
||||
const LIST_FORM_ID = "marketplace-list-form";
|
||||
|
||||
type ListForm =
|
||||
| (Omit<CreateListingParams, "quantity" | "currencyContractAddress"> & {
|
||||
currencyContractAddress: string;
|
||||
@@ -221,284 +226,309 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
|
||||
const nfts = ownedWalletNFTs || walletNFTs?.result;
|
||||
|
||||
const noNfts = !nfts?.length;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-6 pb-16"
|
||||
id={LIST_FORM_ID}
|
||||
onSubmit={form.handleSubmit(async (formData) => {
|
||||
if (!account) {
|
||||
return toast.error("No account detected");
|
||||
}
|
||||
setIsFormLoading(true);
|
||||
let nftType: "ERC1155" | "ERC721";
|
||||
let _selectedContract: ThirdwebContract;
|
||||
let selectedTokenId: bigint;
|
||||
const selectedQuantity = BigInt(formData.quantity);
|
||||
try {
|
||||
if (mode === "manual") {
|
||||
if (!formData.assetContractAddress) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error("Enter a valid NFT contract address");
|
||||
}
|
||||
_selectedContract = getContract({
|
||||
address: formData.assetContractAddress,
|
||||
chain: contract.chain,
|
||||
client: contract.client,
|
||||
});
|
||||
/**
|
||||
* In manual mode we need to detect the NFT type ourselves
|
||||
* instead of relying on the third-party providers
|
||||
*/
|
||||
const [is721, is1155] = await Promise.all([
|
||||
isERC721({ contract: _selectedContract }),
|
||||
isERC1155({ contract: _selectedContract }),
|
||||
]);
|
||||
if (!is721 && !is1155) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error(
|
||||
`Error: ${formData.assetContractAddress} is neither an ERC721 or ERC1155 contract`,
|
||||
);
|
||||
}
|
||||
selectedTokenId = BigInt(formData.tokenId);
|
||||
nftType = is721 ? "ERC721" : "ERC1155";
|
||||
/**
|
||||
* Also in manual mode we need to make sure the user owns the tokenId they entered
|
||||
* For ERC1155, the owned balance must be >= the entered quantity
|
||||
* For ERC721, the owner address must match theirs
|
||||
*/
|
||||
if (nftType === "ERC1155") {
|
||||
const balance = await balanceOf({
|
||||
contract: _selectedContract,
|
||||
owner: account.address,
|
||||
tokenId: selectedTokenId,
|
||||
});
|
||||
if (balance === 0n) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error(
|
||||
`You do not own any tokenId #${selectedTokenId.toString()} from the collection: ${shortenAddress(formData.assetContractAddress)}`,
|
||||
);
|
||||
}
|
||||
if (balance < selectedQuantity) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error(
|
||||
`The balance you own for tokenId #${selectedTokenId.toString()} is less than the quantity (you own ${balance.toString()})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (selectedQuantity !== 1n) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error(
|
||||
"The quantity can only be 1 for ERC721 token",
|
||||
);
|
||||
}
|
||||
const owner = await ownerOf({
|
||||
contract: _selectedContract,
|
||||
tokenId: selectedTokenId,
|
||||
}).catch(() => undefined);
|
||||
if (owner?.toLowerCase() !== account.address.toLowerCase()) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error(
|
||||
`You do not own the tokenId #${selectedTokenId.toString()} from the collection: ${shortenAddress(formData.assetContractAddress)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!formData.selected || !selectedContract) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error("Please select an NFT to list");
|
||||
}
|
||||
nftType = formData.selected.type;
|
||||
_selectedContract = selectedContract;
|
||||
selectedTokenId = BigInt(formData.selected.id);
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-5"
|
||||
onSubmit={form.handleSubmit(async (formData) => {
|
||||
if (!account) {
|
||||
return toast.error("No account detected");
|
||||
}
|
||||
/**
|
||||
* Make sure the selected item is approved to be listed on the marketplace contract
|
||||
* todo: We are checking "isApprovedForAll" for both erc1155 and 721.
|
||||
* However for ERC721 there's also a function called "getApproved" which is used to check for approval status of a single token
|
||||
* - might worth adding that logic here.
|
||||
*/
|
||||
const isNftApproved =
|
||||
nftType === "ERC1155" ? isApprovedForAll1155 : isApprovedForAll721;
|
||||
const isApproved = await isNftApproved({
|
||||
contract: _selectedContract,
|
||||
operator: contract.address,
|
||||
owner: account.address,
|
||||
});
|
||||
|
||||
if (!isApproved) {
|
||||
const setNftApproval =
|
||||
nftType === "ERC1155"
|
||||
? setApprovalForAll1155
|
||||
: setApprovalForAll721;
|
||||
const approveTx = setNftApproval({
|
||||
approved: true,
|
||||
contract: _selectedContract,
|
||||
operator: contract.address,
|
||||
});
|
||||
|
||||
const promise = sendAndConfirmTx.mutateAsync(approveTx);
|
||||
toast.promise(promise, {
|
||||
error: "Failed to approve NFT",
|
||||
loading: "Approving NFT for listing",
|
||||
success: "NFT approved successfully",
|
||||
});
|
||||
await promise;
|
||||
}
|
||||
|
||||
if (formData.listingType === "direct") {
|
||||
// Hard code to 100 years for now
|
||||
const endTimestamp = new Date(
|
||||
new Date().setFullYear(new Date().getFullYear() + 100),
|
||||
);
|
||||
const transaction = createListing({
|
||||
assetContractAddress: _selectedContract.address,
|
||||
contract,
|
||||
currencyContractAddress: formData.currencyContractAddress,
|
||||
endTimestamp,
|
||||
pricePerToken: String(formData.pricePerToken),
|
||||
quantity: selectedQuantity,
|
||||
startTimestamp: formData.startTimestamp,
|
||||
tokenId: selectedTokenId,
|
||||
});
|
||||
|
||||
await sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onSuccess: () => setOpen(false),
|
||||
});
|
||||
|
||||
listingNotifications.onSuccess();
|
||||
} else if (formData.listingType === "auction") {
|
||||
let minimumBidAmountWei: bigint;
|
||||
let buyoutBidAmountWei: bigint;
|
||||
if (
|
||||
formData.currencyContractAddress.toLowerCase() ===
|
||||
NATIVE_TOKEN_ADDRESS.toLocaleLowerCase()
|
||||
) {
|
||||
minimumBidAmountWei = toWei(
|
||||
formData.reservePricePerToken.toString(),
|
||||
);
|
||||
buyoutBidAmountWei = toWei(
|
||||
formData.buyoutPricePerToken.toString(),
|
||||
);
|
||||
} else {
|
||||
const tokenContract = getContract({
|
||||
address: formData.currencyContractAddress,
|
||||
setIsFormLoading(true);
|
||||
let nftType: "ERC1155" | "ERC721";
|
||||
let _selectedContract: ThirdwebContract;
|
||||
let selectedTokenId: bigint;
|
||||
const selectedQuantity = BigInt(formData.quantity);
|
||||
try {
|
||||
if (mode === "manual") {
|
||||
if (!formData.assetContractAddress) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error("Enter a valid NFT contract address");
|
||||
}
|
||||
_selectedContract = getContract({
|
||||
address: formData.assetContractAddress,
|
||||
chain: contract.chain,
|
||||
client: contract.client,
|
||||
});
|
||||
const _decimals = await decimals({ contract: tokenContract });
|
||||
minimumBidAmountWei = toUnits(
|
||||
formData.reservePricePerToken.toString(),
|
||||
_decimals,
|
||||
);
|
||||
buyoutBidAmountWei = toUnits(
|
||||
formData.buyoutPricePerToken.toString(),
|
||||
_decimals,
|
||||
);
|
||||
/**
|
||||
* In manual mode we need to detect the NFT type ourselves
|
||||
* instead of relying on the third-party providers
|
||||
*/
|
||||
const [is721, is1155] = await Promise.all([
|
||||
isERC721({ contract: _selectedContract }),
|
||||
isERC1155({ contract: _selectedContract }),
|
||||
]);
|
||||
if (!is721 && !is1155) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error(
|
||||
`Error: ${formData.assetContractAddress} is neither an ERC721 or ERC1155 contract`,
|
||||
);
|
||||
}
|
||||
selectedTokenId = BigInt(formData.tokenId);
|
||||
nftType = is721 ? "ERC721" : "ERC1155";
|
||||
/**
|
||||
* Also in manual mode we need to make sure the user owns the tokenId they entered
|
||||
* For ERC1155, the owned balance must be >= the entered quantity
|
||||
* For ERC721, the owner address must match theirs
|
||||
*/
|
||||
if (nftType === "ERC1155") {
|
||||
const balance = await balanceOf({
|
||||
contract: _selectedContract,
|
||||
owner: account.address,
|
||||
tokenId: selectedTokenId,
|
||||
});
|
||||
if (balance === 0n) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error(
|
||||
`You do not own any tokenId #${selectedTokenId.toString()} from the collection: ${shortenAddress(formData.assetContractAddress)}`,
|
||||
);
|
||||
}
|
||||
if (balance < selectedQuantity) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error(
|
||||
`The balance you own for tokenId #${selectedTokenId.toString()} is less than the quantity (you own ${balance.toString()})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (selectedQuantity !== 1n) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error(
|
||||
"The quantity can only be 1 for ERC721 token",
|
||||
);
|
||||
}
|
||||
const owner = await ownerOf({
|
||||
contract: _selectedContract,
|
||||
tokenId: selectedTokenId,
|
||||
}).catch(() => undefined);
|
||||
if (owner?.toLowerCase() !== account.address.toLowerCase()) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error(
|
||||
`You do not own the tokenId #${selectedTokenId.toString()} from the collection: ${shortenAddress(formData.assetContractAddress)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!formData.selected || !selectedContract) {
|
||||
setIsFormLoading(false);
|
||||
return toast.error("Please select an NFT to list");
|
||||
}
|
||||
nftType = formData.selected.type;
|
||||
_selectedContract = selectedContract;
|
||||
selectedTokenId = BigInt(formData.selected.id);
|
||||
}
|
||||
/**
|
||||
* Make sure the selected item is approved to be listed on the marketplace contract
|
||||
* todo: We are checking "isApprovedForAll" for both erc1155 and 721.
|
||||
* However for ERC721 there's also a function called "getApproved" which is used to check for approval status of a single token
|
||||
* - might worth adding that logic here.
|
||||
*/
|
||||
const isNftApproved =
|
||||
nftType === "ERC1155"
|
||||
? isApprovedForAll1155
|
||||
: isApprovedForAll721;
|
||||
const isApproved = await isNftApproved({
|
||||
contract: _selectedContract,
|
||||
operator: contract.address,
|
||||
owner: account.address,
|
||||
});
|
||||
|
||||
if (!isApproved) {
|
||||
const setNftApproval =
|
||||
nftType === "ERC1155"
|
||||
? setApprovalForAll1155
|
||||
: setApprovalForAll721;
|
||||
const approveTx = setNftApproval({
|
||||
approved: true,
|
||||
contract: _selectedContract,
|
||||
operator: contract.address,
|
||||
});
|
||||
|
||||
const promise = sendAndConfirmTx.mutateAsync(approveTx);
|
||||
toast.promise(promise, {
|
||||
error: "Failed to approve NFT",
|
||||
loading: "Approving NFT for listing",
|
||||
success: "NFT approved successfully",
|
||||
});
|
||||
await promise;
|
||||
}
|
||||
|
||||
const transaction = createAuction({
|
||||
assetContractAddress: _selectedContract.address,
|
||||
buyoutBidAmountWei: buyoutBidAmountWei * selectedQuantity,
|
||||
contract,
|
||||
currencyContractAddress: formData.currencyContractAddress,
|
||||
endTimestamp: new Date(
|
||||
Date.now() +
|
||||
Number.parseInt(formData.listingDurationInSeconds) * 1000,
|
||||
),
|
||||
minimumBidAmountWei: minimumBidAmountWei * selectedQuantity,
|
||||
startTimestamp: formData.startTimestamp,
|
||||
tokenId: selectedTokenId,
|
||||
});
|
||||
if (formData.listingType === "direct") {
|
||||
// Hard code to 100 years for now
|
||||
const endTimestamp = new Date(
|
||||
new Date().setFullYear(new Date().getFullYear() + 100),
|
||||
);
|
||||
const transaction = createListing({
|
||||
assetContractAddress: _selectedContract.address,
|
||||
contract,
|
||||
currencyContractAddress: formData.currencyContractAddress,
|
||||
endTimestamp,
|
||||
pricePerToken: String(formData.pricePerToken),
|
||||
quantity: selectedQuantity,
|
||||
startTimestamp: formData.startTimestamp,
|
||||
tokenId: selectedTokenId,
|
||||
});
|
||||
|
||||
await sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
auctionNotifications.onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (formData.listingType === "auction") {
|
||||
auctionNotifications.onError(err);
|
||||
} else {
|
||||
listingNotifications.onError(err);
|
||||
}
|
||||
}
|
||||
await sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onSuccess: () => setOpen(false),
|
||||
});
|
||||
|
||||
setIsFormLoading(false);
|
||||
})}
|
||||
>
|
||||
{mode === "manual" ? (
|
||||
<>
|
||||
<FormControl isRequired>
|
||||
<FormHelperText className="!mt-0 mb-5">
|
||||
Manually enter the contract address and token ID of the NFT you
|
||||
want to list for sale
|
||||
</FormHelperText>
|
||||
<FormLabel>NFT Contract Address</FormLabel>
|
||||
<Input {...form.register("assetContractAddress")} />
|
||||
</FormControl>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Token ID</FormLabel>
|
||||
<Input {...form.register("tokenId")} />
|
||||
</FormControl>
|
||||
</>
|
||||
) : (
|
||||
<FormControl>
|
||||
<FormHelperText className="!mt-0 mb-5">
|
||||
Select the NFT you want to list for sale
|
||||
</FormHelperText>
|
||||
{!isSupportedChain ? (
|
||||
<Flex flexDir="column" gap={4} mb={4}>
|
||||
<div className="flex flex-row items-center gap-3 rounded-md border border-border border-orange-100 bg-orange-50 p-[10px] dark:border-orange-300 dark:bg-orange-300">
|
||||
<InfoIcon className="size-6 text-orange-400 dark:text-orange-900" />
|
||||
<p className="text-orange-800 dark:text-orange-900">
|
||||
This chain is not supported by our NFT API yet, please enter
|
||||
the contract address of the NFT you want to list.
|
||||
</p>
|
||||
</div>
|
||||
<FormControl
|
||||
isInvalid={!!form.formState.errors.selected?.contractAddress}
|
||||
>
|
||||
<FormLabel>Contract address</FormLabel>
|
||||
<SolidityInput
|
||||
client={contract.client}
|
||||
formContext={form}
|
||||
solidityType="address"
|
||||
{...form.register("selected.contractAddress", {
|
||||
required: "Contract address is required",
|
||||
})}
|
||||
placeholder="0x..."
|
||||
listingNotifications.onSuccess();
|
||||
} else if (formData.listingType === "auction") {
|
||||
let minimumBidAmountWei: bigint;
|
||||
let buyoutBidAmountWei: bigint;
|
||||
if (
|
||||
formData.currencyContractAddress.toLowerCase() ===
|
||||
NATIVE_TOKEN_ADDRESS.toLocaleLowerCase()
|
||||
) {
|
||||
minimumBidAmountWei = toWei(
|
||||
formData.reservePricePerToken.toString(),
|
||||
);
|
||||
buyoutBidAmountWei = toWei(
|
||||
formData.buyoutPricePerToken.toString(),
|
||||
);
|
||||
} else {
|
||||
const tokenContract = getContract({
|
||||
address: formData.currencyContractAddress,
|
||||
chain: contract.chain,
|
||||
client: contract.client,
|
||||
});
|
||||
const _decimals = await decimals({ contract: tokenContract });
|
||||
minimumBidAmountWei = toUnits(
|
||||
formData.reservePricePerToken.toString(),
|
||||
_decimals,
|
||||
);
|
||||
buyoutBidAmountWei = toUnits(
|
||||
formData.buyoutPricePerToken.toString(),
|
||||
_decimals,
|
||||
);
|
||||
}
|
||||
|
||||
const transaction = createAuction({
|
||||
assetContractAddress: _selectedContract.address,
|
||||
buyoutBidAmountWei: buyoutBidAmountWei * selectedQuantity,
|
||||
contract,
|
||||
currencyContractAddress: formData.currencyContractAddress,
|
||||
endTimestamp: new Date(
|
||||
Date.now() +
|
||||
Number.parseInt(formData.listingDurationInSeconds) * 1000,
|
||||
),
|
||||
minimumBidAmountWei: minimumBidAmountWei * selectedQuantity,
|
||||
startTimestamp: formData.startTimestamp,
|
||||
tokenId: selectedTokenId,
|
||||
});
|
||||
|
||||
await sendAndConfirmTx.mutateAsync(transaction, {
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
auctionNotifications.onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (formData.listingType === "auction") {
|
||||
auctionNotifications.onError(err);
|
||||
} else {
|
||||
listingNotifications.onError(err);
|
||||
}
|
||||
}
|
||||
|
||||
setIsFormLoading(false);
|
||||
})}
|
||||
>
|
||||
{mode === "manual" && (
|
||||
<>
|
||||
{/* contract address */}
|
||||
<FormItem>
|
||||
<FormDescription className="!mt-0 mb-5">
|
||||
Manually enter the contract address and token ID of the NFT you
|
||||
want to list for sale
|
||||
</FormDescription>
|
||||
<FormLabel>NFT Contract Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...form.register("assetContractAddress")}
|
||||
className="bg-card"
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{form.formState.errors.selected?.contractAddress?.message}
|
||||
</FormErrorMessage>
|
||||
<FormHelperText>
|
||||
This will display all the NFTs you own from this contract.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
) : null}
|
||||
{isWalletNFTsLoading ||
|
||||
(isOwnedNFTsLoading &&
|
||||
!isSupportedChain &&
|
||||
form.watch("selected.contractAddress")) ? (
|
||||
<div className="flex h-[60px] items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : nfts && nfts.length !== 0 ? (
|
||||
<Flex flexWrap="wrap" gap={2}>
|
||||
{nfts?.map((nft) => {
|
||||
return (
|
||||
<Tooltip
|
||||
bg="transparent"
|
||||
boxShadow="none"
|
||||
key={nft.contractAddress + nft.id}
|
||||
label={
|
||||
<Card className="p-4">
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
{/* token id */}
|
||||
<FormItem>
|
||||
<FormLabel>Token ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...form.register("tokenId")} className="bg-card" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* select owned nft */}
|
||||
{mode === "automatic" && (
|
||||
<FormItem>
|
||||
<FormDescription className="!mt-0 mb-5">
|
||||
Select the NFT you want to list for sale
|
||||
</FormDescription>
|
||||
|
||||
{!isSupportedChain ? (
|
||||
<div className="flex flex-col gap-4 mb-4">
|
||||
<div className="flex flex-row items-center gap-3 rounded-md border border-border border-orange-100 bg-orange-50 p-[10px] dark:border-orange-300 dark:bg-orange-300">
|
||||
<InfoIcon className="size-6 text-orange-400 dark:text-orange-900" />
|
||||
<p className="text-orange-800 dark:text-orange-900">
|
||||
This chain is not supported by our NFT API yet, please enter
|
||||
the contract address of the NFT you want to list.
|
||||
</p>
|
||||
</div>
|
||||
<FormItem>
|
||||
<FormLabel>Contract address</FormLabel>
|
||||
<FormControl>
|
||||
<SolidityInput
|
||||
client={contract.client}
|
||||
formContext={form}
|
||||
solidityType="address"
|
||||
{...form.register("selected.contractAddress", {
|
||||
required: "Contract address is required",
|
||||
})}
|
||||
placeholder="0x..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage>
|
||||
{form.formState.errors.selected?.contractAddress?.message}
|
||||
</FormMessage>
|
||||
<FormDescription>
|
||||
This will display all the NFTs you own from this contract.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isWalletNFTsLoading ||
|
||||
(isOwnedNFTsLoading &&
|
||||
!isSupportedChain &&
|
||||
form.watch("selected.contractAddress")) ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{new Array(8).fill(0).map((_, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<Skeleton key={index} className="size-[140px] rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : nfts && nfts.length !== 0 ? (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{nfts?.map((nft) => {
|
||||
function handleClick() {
|
||||
if (isSelected(nft)) {
|
||||
form.setValue("selected", undefined);
|
||||
} else {
|
||||
form.setValue("selected", nft);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolTipLabel
|
||||
key={nft.contractAddress + nft.id}
|
||||
label={
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Name:</strong> {nft.metadata?.name || "N/A"}
|
||||
@@ -514,144 +544,173 @@ export const CreateListingsForm: React.FC<CreateListingsFormProps> = ({
|
||||
<strong>Token Standard: </strong> {nft.type}
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
}
|
||||
placement="left-end"
|
||||
shouldWrapChildren
|
||||
>
|
||||
<Box
|
||||
borderRadius="lg"
|
||||
cursor="pointer"
|
||||
onClick={() =>
|
||||
isSelected(nft)
|
||||
? form.setValue("selected", undefined)
|
||||
: form.setValue("selected", nft)
|
||||
}
|
||||
outline={isSelected(nft) ? "3px solid" : undefined}
|
||||
outlineColor={isSelected(nft) ? "purple.500" : undefined}
|
||||
overflow="hidden"
|
||||
>
|
||||
<NFTMediaWithEmptyState
|
||||
client={contract.client}
|
||||
height="140px"
|
||||
metadata={nft.metadata}
|
||||
requireInteraction
|
||||
width="140px"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
) : nfts && nfts.length === 0 ? (
|
||||
<div className="flex flex-row items-center gap-3 rounded-md border border-border border-orange-100 bg-orange-50 p-[10px] dark:border-orange-300 dark:bg-orange-300">
|
||||
<InfoIcon className="size-6 text-orange-400 dark:text-orange-900" />
|
||||
<p className="text-orange-800 dark:text-orange-900">
|
||||
There are no NFTs owned by this wallet. You need NFTs to create
|
||||
a listing. You can create NFTs with thirdweb.{" "}
|
||||
<Link
|
||||
color="blue.600"
|
||||
href="/explore/nft"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Explore NFT contracts
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
{/** biome-ignore lint/a11y/useSemanticElements: ok */}
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleClick();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-lg cursor-pointer overflow-hidden",
|
||||
isSelected(nft) &&
|
||||
"ring-2 ring-primary ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<NFTMediaWithEmptyState
|
||||
client={contract.client}
|
||||
height="140px"
|
||||
metadata={nft.metadata}
|
||||
requireInteraction
|
||||
width="140px"
|
||||
className="bg-card"
|
||||
/>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : nfts && nfts.length === 0 ? (
|
||||
<div className="flex flex-row items-center gap-3 rounded-md border border-border border-orange-100 bg-orange-50 p-[10px] dark:border-orange-300 dark:bg-orange-300">
|
||||
<InfoIcon className="size-6 text-orange-400 dark:text-orange-900" />
|
||||
<p className="text-orange-800 dark:text-orange-900">
|
||||
There are no NFTs owned by this wallet. You need NFTs to
|
||||
create a listing. You can create NFTs with thirdweb.{" "}
|
||||
<Link
|
||||
className="text-blue-600"
|
||||
href="/explore/nft"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Explore NFT contracts
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
{/* listing currency */}
|
||||
<FormItem>
|
||||
<FormLabel>Listing Currency</FormLabel>
|
||||
<FormControl>
|
||||
<CurrencySelector
|
||||
className="bg-card"
|
||||
contractChainId={chainId}
|
||||
onChange={(e) =>
|
||||
form.setValue("currencyContractAddress", e.target.value)
|
||||
}
|
||||
value={form.watch("currencyContractAddress")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The currency you want to sell your tokens for.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
{/* listing price */}
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{form.watch("listingType") === "auction"
|
||||
? "Buyout Price Per Token"
|
||||
: "Listing Price"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...form.register("pricePerToken")} className="bg-card" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{form.watch("listingType") === "auction"
|
||||
? "The price per token a buyer can pay to instantly buyout the auction."
|
||||
: "The price of each token you are listing for sale."}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
|
||||
{/* quantity */}
|
||||
{form.watch("selected")?.type?.toLowerCase() !== "erc721" && (
|
||||
<FormItem>
|
||||
<div className="flex flex-row justify-between gap-2">
|
||||
<FormLabel>Quantity</FormLabel>
|
||||
</div>
|
||||
) : null}
|
||||
</FormControl>
|
||||
)}
|
||||
<FormControl>
|
||||
<Input {...form.register("quantity")} className="bg-card" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The number of tokens to list for sale.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
<FormControl isDisabled={mode === "automatic" && noNfts} isRequired>
|
||||
<FormLabel>Listing Currency</FormLabel>
|
||||
<CurrencySelector
|
||||
contractChainId={chainId}
|
||||
onChange={(e) =>
|
||||
form.setValue("currencyContractAddress", e.target.value)
|
||||
}
|
||||
value={form.watch("currencyContractAddress")}
|
||||
/>
|
||||
<FormHelperText>
|
||||
The currency you want to sell your tokens for.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl isDisabled={mode === "automatic" && noNfts} isRequired>
|
||||
<FormLabel>
|
||||
{form.watch("listingType") === "auction"
|
||||
? "Buyout Price Per Token"
|
||||
: "Listing Price"}
|
||||
</FormLabel>
|
||||
<Input {...form.register("pricePerToken")} />
|
||||
<FormHelperText>
|
||||
{form.watch("listingType") === "auction"
|
||||
? "The price per token a buyer can pay to instantly buyout the auction."
|
||||
: "The price of each token you are listing for sale."}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
{form.watch("selected")?.type?.toLowerCase() !== "erc721" && (
|
||||
<FormControl isDisabled={mode === "automatic" && noNfts} isRequired>
|
||||
<div className="flex flex-row justify-between gap-2">
|
||||
<FormLabel>Quantity</FormLabel>
|
||||
</div>
|
||||
<Input {...form.register("quantity")} />
|
||||
<FormHelperText>
|
||||
The number of tokens to list for sale.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
)}
|
||||
{form.watch("listingType") === "auction" && (
|
||||
<>
|
||||
<FormControl isDisabled={mode === "automatic" && noNfts} isRequired>
|
||||
<FormLabel>Reserve Price Per Token</FormLabel>
|
||||
<Input {...form.register("reservePricePerToken")} />
|
||||
<FormHelperText>
|
||||
The minimum price per token necessary to bid on this auction
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Auction Duration</FormLabel>
|
||||
<Select {...form.register("listingDurationInSeconds")}>
|
||||
{auctionTimes.map((time) => (
|
||||
<option key={time.value} value={time.value}>
|
||||
{time.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<FormHelperText>The duration of this auction.</FormHelperText>
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
{/* auction */}
|
||||
{form.watch("listingType") === "auction" && (
|
||||
<>
|
||||
<FormItem>
|
||||
<FormLabel>Reserve Price Per Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...form.register("reservePricePerToken")}
|
||||
className="bg-card"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The minimum price per token necessary to bid on this auction
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormLabel>Auction Duration</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) =>
|
||||
form.setValue("listingDurationInSeconds", value)
|
||||
}
|
||||
value={form.watch("listingDurationInSeconds")?.toString()}
|
||||
>
|
||||
<SelectTrigger className="bg-card">
|
||||
<SelectValue placeholder="Select a duration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{auctionTimes.map((time) => (
|
||||
<SelectItem
|
||||
key={time.value}
|
||||
value={time.value.toString()}
|
||||
>
|
||||
{time.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormDescription>The duration of this auction.</FormDescription>
|
||||
</FormItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "automatic" && !form.watch("selected.id") && (
|
||||
<Alert>
|
||||
<CircleAlertIcon className="size-4" />
|
||||
<AlertTitle>No NFT selected</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Need to pin these at the bottom because this is a very long form */}
|
||||
<div className="fixed right-6 bottom-4 flex flex-row items-center justify-end gap-3">
|
||||
<Button
|
||||
disabled={isFormLoading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="default"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
form={LIST_FORM_ID}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={isFormLoading}
|
||||
transactionCount={2}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
{actionText}
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</form>
|
||||
{/* Need to pin these at the bottom because this is a very long form */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
disabled={isFormLoading}
|
||||
onClick={() => setOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={isFormLoading}
|
||||
transactionCount={2}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
{actionText}
|
||||
</TransactionButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
+128
-113
@@ -1,4 +1,3 @@
|
||||
import { Flex, GridItem, SimpleGrid, usePrevious } from "@chakra-ui/react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type {
|
||||
@@ -11,169 +10,177 @@ import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { CancelTab } from "./cancel-tab";
|
||||
import { LISTING_STATUS } from "./types";
|
||||
|
||||
interface NFTDrawerProps {
|
||||
export function ListingDrawer(props: {
|
||||
contract: ThirdwebContract;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
data: DirectListing | EnglishAuction | null;
|
||||
data: DirectListing | EnglishAuction;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export const ListingDrawer: React.FC<NFTDrawerProps> = ({
|
||||
contract,
|
||||
isOpen,
|
||||
onClose,
|
||||
data,
|
||||
isLoggedIn,
|
||||
}) => {
|
||||
}) {
|
||||
const { contract, isOpen, onClose, data, isLoggedIn } = props;
|
||||
const address = useActiveAccount()?.address;
|
||||
const prevData = usePrevious(data);
|
||||
|
||||
const renderData = data || prevData;
|
||||
const isOwner =
|
||||
address?.toLowerCase() === renderData?.creatorAddress.toLowerCase();
|
||||
|
||||
const tokenId = renderData?.asset.id.toString() || "";
|
||||
|
||||
if (!renderData) {
|
||||
return null;
|
||||
}
|
||||
const isOwner = address?.toLowerCase() === data.creatorAddress.toLowerCase();
|
||||
const tokenId = data.asset.id.toString();
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={onClose} open={isOpen}>
|
||||
<SheetContent className="flex w-full flex-col gap-6 py-6 md:min-w-[600px]">
|
||||
<div className="flex flex-row gap-6">
|
||||
<SheetContent className="!w-full !max-w-lg gap-0">
|
||||
<div className="space-y-4">
|
||||
<NFTMediaWithEmptyState
|
||||
client={contract.client}
|
||||
height="150px"
|
||||
metadata={renderData.asset.metadata}
|
||||
height="120px"
|
||||
metadata={data.asset.metadata}
|
||||
requireInteraction
|
||||
width="150px"
|
||||
width="120px"
|
||||
className="border rounded-xl"
|
||||
/>
|
||||
<Flex flexDir="column" gap={2} w="70%">
|
||||
<p className="font-bold text-lg">
|
||||
{renderData.asset.metadata.name}
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-2xl leading-tight">
|
||||
{data.asset.metadata.name}
|
||||
</p>
|
||||
<p className="line-clamp-6 truncate text-base leading-normal">
|
||||
{renderData.asset.metadata.name}
|
||||
</p>
|
||||
</Flex>
|
||||
{data.asset.metadata.description && (
|
||||
<p className="line-clamp-6 truncate text-sm text-muted-foreground">
|
||||
{data.asset.metadata.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<Card className="flex flex-col gap-3 p-4">
|
||||
<SimpleGrid columns={12} placeItems="center left" rowGap={3}>
|
||||
<GridItem colSpan={3}>
|
||||
<p className="font-bold">Asset contract address</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
<Table className="my-6 border-y border-dashed">
|
||||
<TableBody>
|
||||
{/* NFT contract address */}
|
||||
<TableRow className="border-dashed">
|
||||
<StyledTableHead>NFT contract address</StyledTableHead>
|
||||
<StyledTableCell>
|
||||
<CopyAddressButton
|
||||
address={renderData.assetContractAddress}
|
||||
address={data.assetContractAddress}
|
||||
copyIconPosition="right"
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem colSpan={3}>
|
||||
<p className="font-bold">Token ID</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
</StyledTableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Token ID */}
|
||||
<TableRow className="border-dashed">
|
||||
<StyledTableHead>Token ID</StyledTableHead>
|
||||
<StyledTableCell>
|
||||
<CopyTextButton
|
||||
copyIconPosition="right"
|
||||
className="gap-4"
|
||||
textToCopy={tokenId}
|
||||
textToShow={tokenId}
|
||||
tooltip="Token ID"
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem colSpan={3}>
|
||||
<p className="font-bold">Seller</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
</StyledTableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Seller */}
|
||||
<TableRow className="border-dashed">
|
||||
<StyledTableHead>Seller</StyledTableHead>
|
||||
<StyledTableCell>
|
||||
<WalletAddress
|
||||
address={renderData.creatorAddress}
|
||||
address={data.creatorAddress}
|
||||
client={contract.client}
|
||||
className="py-1 h-auto"
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem colSpan={3}>
|
||||
<p className="font-bold">Listing ID</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
</StyledTableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Listing ID */}
|
||||
<TableRow className="border-dashed">
|
||||
<StyledTableHead>Listing ID</StyledTableHead>
|
||||
<StyledTableCell>
|
||||
<CopyTextButton
|
||||
className="gap-4"
|
||||
copyIconPosition="right"
|
||||
textToCopy={renderData.id.toString()}
|
||||
textToShow={renderData.id.toString()}
|
||||
textToCopy={data.id.toString()}
|
||||
textToShow={data.id.toString()}
|
||||
tooltip="Listing ID"
|
||||
/>
|
||||
</GridItem>
|
||||
<GridItem colSpan={3}>
|
||||
<p className="font-bold">Type</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>{renderData.asset.type}</GridItem>
|
||||
<GridItem colSpan={3}>
|
||||
<p className="font-bold">Status</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
</StyledTableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Type */}
|
||||
<TableRow className="border-dashed">
|
||||
<StyledTableHead>Type</StyledTableHead>
|
||||
<StyledTableCell>{data.asset.type}</StyledTableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Status */}
|
||||
<TableRow className="border-dashed">
|
||||
<StyledTableHead>Status</StyledTableHead>
|
||||
<StyledTableCell>
|
||||
<Badge className="uppercase">
|
||||
{LISTING_STATUS[renderData.status]}
|
||||
{LISTING_STATUS[data.status]}
|
||||
</Badge>
|
||||
</GridItem>
|
||||
<GridItem colSpan={3}>
|
||||
<p className="font-bold">Quantity</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
{(renderData.quantity || 0n).toString()}{" "}
|
||||
</StyledTableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Quantity */}
|
||||
<TableRow className="border-dashed">
|
||||
<StyledTableHead>Quantity</StyledTableHead>
|
||||
<StyledTableCell>
|
||||
{(data.quantity || 0n).toString()}{" "}
|
||||
{/* For listings that are completed, the `quantity` would be `0`
|
||||
So we show this text to make it clear */}
|
||||
{LISTING_STATUS[renderData.status] === "Completed"
|
||||
So we show this text to make it clear */}
|
||||
{LISTING_STATUS[data.status] === "Completed"
|
||||
? "(Sold out)"
|
||||
: ""}
|
||||
</GridItem>
|
||||
</StyledTableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Price */}
|
||||
{data.type === "direct-listing" && (
|
||||
<TableRow className="border-dashed">
|
||||
<StyledTableHead>Price</StyledTableHead>
|
||||
<StyledTableCell>
|
||||
{data.currencyValuePerToken.displayValue}{" "}
|
||||
{data.currencyValuePerToken.symbol}
|
||||
</StyledTableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{data?.asset.metadata.properties ? (
|
||||
<div className="space-y-1">
|
||||
<p className="font-bold">Attributes</p>
|
||||
<CodeClient
|
||||
code={
|
||||
JSON.stringify(data.asset.metadata.properties, null, 2) || ""
|
||||
}
|
||||
lang="json"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{renderData.type === "direct-listing" && (
|
||||
<>
|
||||
<GridItem colSpan={3}>
|
||||
<p className="font-bold">Price</p>
|
||||
</GridItem>
|
||||
<GridItem colSpan={9}>
|
||||
{renderData.currencyValuePerToken.displayValue}{" "}
|
||||
{renderData.currencyValuePerToken.symbol}
|
||||
</GridItem>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
{data?.asset.metadata.properties ? (
|
||||
<Card className="flex flex-col gap-3 p-4">
|
||||
<p className="font-bold">Attributes</p>
|
||||
<CodeClient
|
||||
code={
|
||||
JSON.stringify(data.asset.metadata.properties, null, 2) || ""
|
||||
}
|
||||
lang="json"
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
</Flex>
|
||||
{isOwner && (
|
||||
<CancelTab
|
||||
contract={contract}
|
||||
id={renderData.id.toString()}
|
||||
isAuction={renderData.type === "english-auction"}
|
||||
id={data.id.toString()}
|
||||
isAuction={data.type === "english-auction"}
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isOwner && renderData.status === "ACTIVE" && (
|
||||
{!isOwner && data.status === "ACTIVE" && (
|
||||
<BuyDirectListingButton
|
||||
chain={contract.chain}
|
||||
className="w-full"
|
||||
client={contract.client}
|
||||
contractAddress={renderData.assetContractAddress}
|
||||
listingId={renderData.id}
|
||||
contractAddress={data.assetContractAddress}
|
||||
listingId={data.id}
|
||||
onError={(error) => {
|
||||
toast.error("Failed to buy listing", {
|
||||
description: error.message,
|
||||
@@ -184,10 +191,18 @@ export const ListingDrawer: React.FC<NFTDrawerProps> = ({
|
||||
}}
|
||||
quantity={1n}
|
||||
>
|
||||
Buy Listing
|
||||
Buy NFT
|
||||
</BuyDirectListingButton>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function StyledTableHead({ children }: { children: React.ReactNode }) {
|
||||
return <TableHead className="px-0 py-3.5">{children}</TableHead>;
|
||||
}
|
||||
|
||||
function StyledTableCell({ children }: { children: React.ReactNode }) {
|
||||
return <TableCell className="px-0 py-3.5">{children}</TableCell>;
|
||||
}
|
||||
|
||||
+205
-287
@@ -1,49 +1,29 @@
|
||||
// biome-ignore-all lint/nursery/noNestedComponentDefinitions: TODO
|
||||
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
|
||||
|
||||
import {
|
||||
IconButton,
|
||||
Select,
|
||||
Skeleton,
|
||||
Spinner,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
usePrevious,
|
||||
} from "@chakra-ui/react";
|
||||
import type { UseQueryResult } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronFirstIcon,
|
||||
ChevronLastIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoveRightIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type Cell, type Column, usePagination, useTable } from "react-table";
|
||||
import { type Dispatch, type SetStateAction, useMemo, useState } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type {
|
||||
DirectListing,
|
||||
EnglishAuction,
|
||||
} from "thirdweb/extensions/marketplace";
|
||||
import { min } from "thirdweb/utils";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { WalletAddress } from "@/components/blocks/wallet-address";
|
||||
import { MediaCell } from "@/components/contracts/media-cell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ListingDrawer } from "./listing-drawer";
|
||||
import { LISTING_STATUS } from "./types";
|
||||
|
||||
interface MarketplaceTableProps {
|
||||
export function MarketplaceTable(props: {
|
||||
contract: ThirdwebContract;
|
||||
getAllQueryResult: UseQueryResult<DirectListing[] | EnglishAuction[]>;
|
||||
getValidQueryResult: UseQueryResult<DirectListing[] | EnglishAuction[]>;
|
||||
@@ -59,279 +39,217 @@ interface MarketplaceTableProps {
|
||||
}>
|
||||
>;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
title: string;
|
||||
cta: React.ReactNode;
|
||||
}) {
|
||||
const {
|
||||
contract,
|
||||
getAllQueryResult,
|
||||
getValidQueryResult,
|
||||
totalCountQuery,
|
||||
queryParams,
|
||||
setQueryParams,
|
||||
isLoggedIn,
|
||||
cta,
|
||||
title,
|
||||
} = props;
|
||||
|
||||
const DEFAULT_QUERY_STATE = { count: 50, start: 0 };
|
||||
|
||||
export const MarketplaceTable: React.FC<MarketplaceTableProps> = ({
|
||||
contract,
|
||||
getAllQueryResult,
|
||||
getValidQueryResult,
|
||||
totalCountQuery,
|
||||
queryParams,
|
||||
setQueryParams,
|
||||
isLoggedIn,
|
||||
}) => {
|
||||
const [listingsToShow, setListingsToShow_] = useState<"all" | "valid">("all");
|
||||
|
||||
const setListingsToShow = (value: "all" | "valid") => {
|
||||
setQueryParams(DEFAULT_QUERY_STATE);
|
||||
setQueryParams({ count: 50, start: 0 });
|
||||
setListingsToShow_(value);
|
||||
};
|
||||
|
||||
const prevData = usePrevious(
|
||||
listingsToShow === "all"
|
||||
? getAllQueryResult?.data
|
||||
: getValidQueryResult?.data,
|
||||
);
|
||||
|
||||
const renderData = useMemo(() => {
|
||||
if (listingsToShow === "all") {
|
||||
return getAllQueryResult?.data || prevData;
|
||||
return getAllQueryResult?.data;
|
||||
}
|
||||
return getValidQueryResult?.data || prevData;
|
||||
}, [getAllQueryResult, getValidQueryResult, listingsToShow, prevData]);
|
||||
return getValidQueryResult?.data;
|
||||
}, [getAllQueryResult, getValidQueryResult, listingsToShow]);
|
||||
|
||||
const tableColumns: Column<DirectListing | EnglishAuction>[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
accessor: (row) => row.id.toString(),
|
||||
Header: "Listing Id",
|
||||
},
|
||||
{
|
||||
accessor: (row) => row.asset.metadata,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
Cell: (cell: any) => <MediaCell cell={cell} client={contract.client} />,
|
||||
Header: "Media",
|
||||
},
|
||||
{
|
||||
accessor: (row) => row.asset.metadata.name ?? "N/A",
|
||||
Header: "Name",
|
||||
},
|
||||
{
|
||||
accessor: (row) => row.creatorAddress,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
Cell: ({ cell }: { cell: Cell<any, string> }) => (
|
||||
<WalletAddress address={cell.value} client={contract.client} />
|
||||
),
|
||||
Header: "Creator",
|
||||
},
|
||||
{
|
||||
accessor: (row) =>
|
||||
(row as DirectListing)?.currencyValuePerToken ||
|
||||
(row as EnglishAuction)?.buyoutCurrencyValue,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
Cell: ({ cell }: { cell: Cell<any, any> }) => {
|
||||
return (
|
||||
<p className="whitespace-nowrap text-muted-foreground">
|
||||
{cell.value.displayValue} {cell.value.symbol}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
Header: "Price",
|
||||
},
|
||||
{
|
||||
accessor: (row) => LISTING_STATUS[row.status],
|
||||
Header: "Status",
|
||||
},
|
||||
];
|
||||
}, [contract.client]);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
prepareRow,
|
||||
page,
|
||||
canPreviousPage,
|
||||
canNextPage,
|
||||
pageCount,
|
||||
gotoPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
setPageSize,
|
||||
state: { pageIndex, pageSize },
|
||||
} = useTable(
|
||||
{
|
||||
columns: tableColumns,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
data: (renderData as any) || [],
|
||||
initialState: {
|
||||
pageIndex: 0,
|
||||
pageSize: queryParams.count,
|
||||
},
|
||||
manualPagination: true,
|
||||
pageCount: Math.max(
|
||||
Math.ceil(
|
||||
Number(
|
||||
// To avoid overflow issue
|
||||
min(totalCountQuery.data || 0n, BigInt(Number.MAX_SAFE_INTEGER)),
|
||||
) / queryParams.count,
|
||||
),
|
||||
1,
|
||||
),
|
||||
},
|
||||
// FIXME: re-work tables and pagination with @tanstack/table@latest - which (I believe) does not need this workaround anymore
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
usePagination,
|
||||
const pageSize = queryParams.count;
|
||||
const currentPage = Math.floor(queryParams.start / pageSize) + 1; // PaginationButtons uses 1-based indexing
|
||||
const totalCount = Number(
|
||||
min(totalCountQuery.data || 0n, BigInt(Number.MAX_SAFE_INTEGER)),
|
||||
);
|
||||
const totalPages = Math.max(Math.ceil(totalCount / pageSize), 1);
|
||||
const showPagination = totalPages > 1;
|
||||
|
||||
// FIXME: re-work tables and pagination with @tanstack/table@latest - which (I believe) does not need this workaround anymore
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
setQueryParams({ count: pageSize, start: pageIndex * pageSize });
|
||||
}, [pageIndex, pageSize, setQueryParams]);
|
||||
// Pagination handler for PaginationButtons
|
||||
const handlePageChange = (page: number) => {
|
||||
setQueryParams({ count: pageSize, start: (page - 1) * pageSize });
|
||||
};
|
||||
|
||||
const [tokenRow, setTokenRow] = useState<
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
|
||||
const [selectedToken, setSelectedToken] = useState<
|
||||
DirectListing | EnglishAuction | null
|
||||
>(null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
className="w-18 rounded-r-none"
|
||||
onClick={() => setListingsToShow("all")}
|
||||
variant={listingsToShow === "all" ? "default" : "outline"}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-l-none"
|
||||
onClick={() => setListingsToShow("valid")}
|
||||
variant={listingsToShow === "valid" ? "default" : "outline"}
|
||||
>
|
||||
Valid
|
||||
</Button>
|
||||
</div>
|
||||
<TableContainer className="relative" maxW="100%">
|
||||
{((listingsToShow === "all" && getAllQueryResult.isFetching) ||
|
||||
(listingsToShow === "valid" && getValidQueryResult.isFetching)) && (
|
||||
<Spinner
|
||||
color="primary"
|
||||
position="absolute"
|
||||
right={4}
|
||||
size="xs"
|
||||
top={2}
|
||||
/>
|
||||
)}
|
||||
<ListingDrawer
|
||||
contract={contract}
|
||||
data={tokenRow}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isOpen={!!tokenRow}
|
||||
onClose={() => setTokenRow(null)}
|
||||
/>
|
||||
<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}
|
||||
>
|
||||
<p className="text-muted-foreground">
|
||||
{column.render("Header")}
|
||||
</p>
|
||||
</Th>
|
||||
))}
|
||||
{/* // Need to add an empty header for the drawer button */}
|
||||
<Th border="none" key="drawer" />
|
||||
</Tr>
|
||||
))}
|
||||
</Thead>
|
||||
<Tbody {...getTableBodyProps()} position="relative">
|
||||
{page.map((row, rowIndex) => {
|
||||
prepareRow(row);
|
||||
return (
|
||||
<Tr
|
||||
{...row.getRowProps()}
|
||||
_last={{ borderBottomWidth: 0 }}
|
||||
borderBottomWidth={1}
|
||||
borderColor="borderColor"
|
||||
className="hover:bg-card"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={rowIndex}
|
||||
onClick={() => setTokenRow(row.original)}
|
||||
role="group"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{row.cells.map((cell, cellIndex) => (
|
||||
<Td
|
||||
{...cell.getCellProps()}
|
||||
borderBottomWidth="inherit"
|
||||
borderColor="borderColor"
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={cellIndex}
|
||||
>
|
||||
{cell.render("Cell")}
|
||||
</Td>
|
||||
))}
|
||||
<Td borderBottomWidth="inherit" borderColor="borderColor">
|
||||
<MoveRightIcon className="size-3" />
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IconButton
|
||||
aria-label="first page"
|
||||
icon={<ChevronFirstIcon className="size-4" />}
|
||||
isDisabled={!canPreviousPage || totalCountQuery.isPending}
|
||||
onClick={() => gotoPage(0)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="previous page"
|
||||
icon={<ChevronLeftIcon className="size-4" />}
|
||||
isDisabled={!canPreviousPage || totalCountQuery.isPending}
|
||||
onClick={() => previousPage()}
|
||||
/>
|
||||
<p className="whitespace-nowrap">
|
||||
Page <strong>{pageIndex + 1}</strong> of{" "}
|
||||
<Skeleton
|
||||
as="span"
|
||||
display="inline"
|
||||
isLoaded={totalCountQuery.isSuccess}
|
||||
>
|
||||
<strong>{pageCount}</strong>
|
||||
</Skeleton>
|
||||
</p>
|
||||
<IconButton
|
||||
aria-label="next page"
|
||||
icon={<ChevronRightIcon className="size-4" />}
|
||||
isDisabled={!canNextPage || totalCountQuery.isPending}
|
||||
onClick={() => nextPage()}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="last page"
|
||||
icon={<ChevronLastIcon className="size-4" />}
|
||||
isDisabled={!canNextPage || totalCountQuery.isPending}
|
||||
onClick={() => gotoPage(pageCount - 1)}
|
||||
/>
|
||||
const isFetching =
|
||||
(listingsToShow === "all" && getAllQueryResult.isFetching) ||
|
||||
(listingsToShow === "valid" && getValidQueryResult.isFetching);
|
||||
|
||||
<Select
|
||||
isDisabled={totalCountQuery.isPending}
|
||||
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>
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between flex-col lg:flex-row gap-3 items-end">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">{title}</h2>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-row">
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-r-none"
|
||||
onClick={() => setListingsToShow("all")}
|
||||
variant={listingsToShow === "all" ? "default" : "outline"}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-l-none"
|
||||
onClick={() => setListingsToShow("valid")}
|
||||
variant={listingsToShow === "valid" ? "default" : "outline"}
|
||||
>
|
||||
Valid
|
||||
</Button>
|
||||
</div>
|
||||
{cta}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<TableContainer>
|
||||
{renderData?.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-muted-foreground">No listings found</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<p className="text-muted-foreground">Listing Id</p>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<p className="text-muted-foreground">Media</p>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<p className="text-muted-foreground">Name</p>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<p className="text-muted-foreground">Creator</p>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<p className="text-muted-foreground">Price</p>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<p className="text-muted-foreground">Status</p>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isFetching &&
|
||||
Array.from({ length: 5 }).map((_, index) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: ok
|
||||
<SkeletonRow key={index} />
|
||||
))}
|
||||
|
||||
{!isFetching &&
|
||||
renderData?.map((row, _rowIndex) => (
|
||||
<TableRow
|
||||
key={row.id.toString()}
|
||||
className="hover:bg-accent/50 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedToken(row);
|
||||
setIsDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
<TableCell>{row.id.toString()}</TableCell>
|
||||
<TableCell>
|
||||
<MediaCell
|
||||
cell={{ value: row.asset.metadata }}
|
||||
client={contract.client}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{row.asset.metadata.name ?? "N/A"}</TableCell>
|
||||
<TableCell>
|
||||
<WalletAddress
|
||||
address={row.creatorAddress}
|
||||
client={contract.client}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<p className="whitespace-nowrap text-foreground">
|
||||
{(row as DirectListing)?.currencyValuePerToken
|
||||
?.displayValue ||
|
||||
(row as EnglishAuction)?.buyoutCurrencyValue
|
||||
?.displayValue ||
|
||||
"N/A"}{" "}
|
||||
{(row as DirectListing)?.currencyValuePerToken
|
||||
?.symbol ||
|
||||
(row as EnglishAuction)?.buyoutCurrencyValue
|
||||
?.symbol ||
|
||||
""}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>{LISTING_STATUS[row.status]}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</TableContainer>
|
||||
|
||||
{showPagination && (
|
||||
<PaginationButtons
|
||||
activePage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageClick={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedToken && (
|
||||
<ListingDrawer
|
||||
contract={contract}
|
||||
data={selectedToken}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => setIsDrawerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<TableRow>
|
||||
{/* listing id */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-10" />
|
||||
</TableCell>
|
||||
{/* media */}
|
||||
<TableCell>
|
||||
<Skeleton className="size-[120px]" />
|
||||
</TableCell>
|
||||
{/* name */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</TableCell>
|
||||
{/* creator */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-40" />
|
||||
</TableCell>
|
||||
{/* price */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</TableCell>
|
||||
{/* status */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-25
@@ -4,33 +4,24 @@ import type { ThirdwebContract } from "thirdweb";
|
||||
import { CreateListingButton } from "../components/list-button";
|
||||
import { DirectListingsTable } from "./components/table";
|
||||
|
||||
interface ContractDirectListingsPageProps {
|
||||
export function ContractDirectListingsPage(props: {
|
||||
contract: ThirdwebContract;
|
||||
isLoggedIn: boolean;
|
||||
isInsightSupported: boolean;
|
||||
}
|
||||
|
||||
export const ContractDirectListingsPage: React.FC<
|
||||
ContractDirectListingsPageProps
|
||||
> = ({ contract, isLoggedIn, isInsightSupported }) => {
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
Direct Listings
|
||||
</h2>
|
||||
<div className="flex flex-row gap-4">
|
||||
<CreateListingButton
|
||||
contract={contract}
|
||||
createText="Create Direct Listing"
|
||||
isInsightSupported={isInsightSupported}
|
||||
isLoggedIn={isLoggedIn}
|
||||
type="direct-listings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DirectListingsTable contract={contract} isLoggedIn={isLoggedIn} />
|
||||
</div>
|
||||
<DirectListingsTable
|
||||
contract={props.contract}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
cta={
|
||||
<CreateListingButton
|
||||
contract={props.contract}
|
||||
createText="Create Direct Listing"
|
||||
isInsightSupported={props.isInsightSupported}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
type="direct-listings"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+15
-16
@@ -10,39 +10,38 @@ import {
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import { MarketplaceTable } from "../../components/marketplace-table";
|
||||
|
||||
interface DirectListingsTableProps {
|
||||
export function DirectListingsTable(props: {
|
||||
contract: ThirdwebContract;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_QUERY_STATE = { count: 50, start: 0 };
|
||||
|
||||
export const DirectListingsTable: React.FC<DirectListingsTableProps> = ({
|
||||
contract,
|
||||
isLoggedIn,
|
||||
}) => {
|
||||
const [queryParams, setQueryParams] = useState(DEFAULT_QUERY_STATE);
|
||||
cta: React.ReactNode;
|
||||
}) {
|
||||
const [queryParams, setQueryParams] = useState({ count: 50, start: 0 });
|
||||
const getAllQueryResult = useReadContract(getAllListings, {
|
||||
contract,
|
||||
contract: props.contract,
|
||||
count: BigInt(queryParams.count),
|
||||
start: queryParams.start,
|
||||
});
|
||||
|
||||
const getValidQueryResult = useReadContract(getAllValidListings, {
|
||||
contract,
|
||||
contract: props.contract,
|
||||
count: BigInt(queryParams.count),
|
||||
start: queryParams.start,
|
||||
});
|
||||
const totalCountQuery = useReadContract(totalListings, { contract });
|
||||
const totalCountQuery = useReadContract(totalListings, {
|
||||
contract: props.contract,
|
||||
});
|
||||
|
||||
return (
|
||||
<MarketplaceTable
|
||||
contract={contract}
|
||||
contract={props.contract}
|
||||
getAllQueryResult={getAllQueryResult}
|
||||
getValidQueryResult={getValidQueryResult}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
queryParams={queryParams}
|
||||
setQueryParams={setQueryParams}
|
||||
totalCountQuery={totalCountQuery}
|
||||
cta={props.cta}
|
||||
title="Direct Listings"
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+16
-25
@@ -4,33 +4,24 @@ import type { ThirdwebContract } from "thirdweb";
|
||||
import { CreateListingButton } from "../components/list-button";
|
||||
import { EnglishAuctionsTable } from "./components/table";
|
||||
|
||||
interface ContractEnglishAuctionsProps {
|
||||
export function ContractEnglishAuctionsPage(props: {
|
||||
contract: ThirdwebContract;
|
||||
isLoggedIn: boolean;
|
||||
isInsightSupported: boolean;
|
||||
}
|
||||
|
||||
export const ContractEnglishAuctionsPage: React.FC<
|
||||
ContractEnglishAuctionsProps
|
||||
> = ({ contract, isLoggedIn, isInsightSupported }) => {
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">
|
||||
English Auctions
|
||||
</h2>
|
||||
<div className="flex flex-row gap-4">
|
||||
<CreateListingButton
|
||||
contract={contract}
|
||||
createText="Create English Auction"
|
||||
isInsightSupported={isInsightSupported}
|
||||
isLoggedIn={isLoggedIn}
|
||||
type="english-auctions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EnglishAuctionsTable contract={contract} isLoggedIn={isLoggedIn} />
|
||||
</div>
|
||||
<EnglishAuctionsTable
|
||||
contract={props.contract}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
cta={
|
||||
<CreateListingButton
|
||||
contract={props.contract}
|
||||
createText="Create English Auction"
|
||||
isInsightSupported={props.isInsightSupported}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
type="english-auctions"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+14
-16
@@ -10,39 +10,37 @@ import {
|
||||
import { useReadContract } from "thirdweb/react";
|
||||
import { MarketplaceTable } from "../../components/marketplace-table";
|
||||
|
||||
interface EnglishAuctionsTableProps {
|
||||
export function EnglishAuctionsTable(props: {
|
||||
contract: ThirdwebContract;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_QUERY_STATE = { count: 50, start: 0 };
|
||||
|
||||
export const EnglishAuctionsTable: React.FC<EnglishAuctionsTableProps> = ({
|
||||
contract,
|
||||
isLoggedIn,
|
||||
}) => {
|
||||
const [queryParams, setQueryParams] = useState(DEFAULT_QUERY_STATE);
|
||||
cta: React.ReactNode;
|
||||
}) {
|
||||
const [queryParams, setQueryParams] = useState({ count: 50, start: 0 });
|
||||
const getAllQueryResult = useReadContract(getAllAuctions, {
|
||||
contract,
|
||||
contract: props.contract,
|
||||
count: BigInt(queryParams.count),
|
||||
start: queryParams.start,
|
||||
});
|
||||
const getValidQueryResult = useReadContract(getAllValidAuctions, {
|
||||
contract,
|
||||
contract: props.contract,
|
||||
count: BigInt(queryParams.count),
|
||||
start: queryParams.start,
|
||||
});
|
||||
const totalCountQuery = useReadContract(totalAuctions, { contract });
|
||||
const totalCountQuery = useReadContract(totalAuctions, {
|
||||
contract: props.contract,
|
||||
});
|
||||
|
||||
return (
|
||||
<MarketplaceTable
|
||||
contract={contract}
|
||||
contract={props.contract}
|
||||
getAllQueryResult={getAllQueryResult}
|
||||
getValidQueryResult={getValidQueryResult}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
queryParams={queryParams}
|
||||
setQueryParams={setQueryParams}
|
||||
totalCountQuery={totalCountQuery}
|
||||
cta={props.cta}
|
||||
title="English Auctions"
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+19
-26
@@ -1,47 +1,40 @@
|
||||
import { Box, Flex } from "@chakra-ui/react";
|
||||
import { NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { CurrencySelector } from "@/components/blocks/CurrencySelector";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PriceInput } from "../../price-input";
|
||||
import { useClaimConditionsFormContext } from "..";
|
||||
import { CustomFormControl } from "../common";
|
||||
|
||||
/**
|
||||
* Allows the user to select how much they want to charge to claim each NFT
|
||||
*/
|
||||
export const ClaimPriceInput = (props: { contractChainId: number }) => {
|
||||
const {
|
||||
formDisabled,
|
||||
isErc20,
|
||||
form,
|
||||
phaseIndex,
|
||||
field,
|
||||
isColumn,
|
||||
claimConditionType,
|
||||
} = useClaimConditionsFormContext();
|
||||
const { formDisabled, isErc20, form, phaseIndex, field, claimConditionType } =
|
||||
useClaimConditionsFormContext();
|
||||
|
||||
if (claimConditionType === "creator") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomFormControl
|
||||
disabled={formDisabled}
|
||||
error={
|
||||
<FormFieldSetup
|
||||
isRequired={false}
|
||||
errorMessage={
|
||||
form.getFieldState(`phases.${phaseIndex}.price`, form.formState).error
|
||||
?.message
|
||||
}
|
||||
label={`How much do you want to charge to claim each ${
|
||||
isErc20 ? "token" : "NFT"
|
||||
}?`}
|
||||
>
|
||||
<Flex flexDir={{ base: "column", md: "row" }} gap={2}>
|
||||
<Box minW="70px" w={{ base: "100%", md: "50%" }}>
|
||||
<PriceInput
|
||||
onChange={(val) => form.setValue(`phases.${phaseIndex}.price`, val)}
|
||||
value={field.price?.toString() || ""}
|
||||
w="full"
|
||||
/>
|
||||
</Box>
|
||||
<Box w={{ base: "100%", md: isColumn ? "50%" : "100%" }}>
|
||||
<div className={cn("flex flex-col md:flex-row gap-3")}>
|
||||
<PriceInput
|
||||
onChange={(val) => form.setValue(`phases.${phaseIndex}.price`, val)}
|
||||
value={field.price?.toString() || ""}
|
||||
disabled={formDisabled}
|
||||
className="max-w-48"
|
||||
/>
|
||||
<div className="grow max-w-md">
|
||||
<CurrencySelector
|
||||
contractChainId={props.contractChainId}
|
||||
isDisabled={formDisabled}
|
||||
@@ -53,8 +46,8 @@ export const ClaimPriceInput = (props: { contractChainId: number }) => {
|
||||
}
|
||||
value={field?.currencyAddress || NATIVE_TOKEN_ADDRESS}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</CustomFormControl>
|
||||
</div>
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
};
|
||||
|
||||
+50
-54
@@ -1,8 +1,15 @@
|
||||
import { Box, Flex, Select } from "@chakra-ui/react";
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useClaimConditionsFormContext } from "..";
|
||||
import { CustomFormControl } from "../common";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useClaimConditionsFormContext } from "../index";
|
||||
|
||||
/**
|
||||
* Allows the user to
|
||||
@@ -19,12 +26,11 @@ export const ClaimerSelection = () => {
|
||||
isErc20,
|
||||
setOpenSnapshotIndex: setOpenIndex,
|
||||
isAdmin,
|
||||
isColumn,
|
||||
claimConditionType,
|
||||
} = useClaimConditionsFormContext();
|
||||
|
||||
const handleClaimerChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const val = e.currentTarget.value as "any" | "specific" | "overrides";
|
||||
const handleClaimerChange = (value: string) => {
|
||||
const val = value as "any" | "specific" | "overrides";
|
||||
|
||||
if (val === "any") {
|
||||
form.setValue(`phases.${phaseIndex}.snapshot`, undefined);
|
||||
@@ -80,79 +86,69 @@ export const ClaimerSelection = () => {
|
||||
: `Who can claim ${isErc20 ? "tokens" : "NFTs"} during this phase?`;
|
||||
|
||||
return (
|
||||
<CustomFormControl
|
||||
disabled={formDisabled}
|
||||
error={
|
||||
<FormFieldSetup
|
||||
errorMessage={
|
||||
form.getFieldState(`phases.${phaseIndex}.snapshot`, form.formState)
|
||||
.error
|
||||
?.error?.message
|
||||
}
|
||||
helperText={helperText}
|
||||
label={label}
|
||||
isRequired={false}
|
||||
>
|
||||
<Flex direction={{ base: "column", md: "row" }} gap={4}>
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{claimConditionType === "overrides" ||
|
||||
claimConditionType === "specific" ? null : (
|
||||
<Select
|
||||
isDisabled={formDisabled}
|
||||
onChange={handleClaimerChange}
|
||||
disabled={formDisabled}
|
||||
onValueChange={handleClaimerChange}
|
||||
value={dropType}
|
||||
w={{ base: "100%", md: "50%" }}
|
||||
>
|
||||
<option value="any">Any wallet</option>
|
||||
<option value="overrides">Any wallet (with overrides)</option>
|
||||
<option value="specific">Only specific wallets</option>
|
||||
<SelectTrigger className="w-full md:w-1/2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">Any wallet</SelectItem>
|
||||
<SelectItem value="overrides">
|
||||
Any wallet (with overrides)
|
||||
</SelectItem>
|
||||
<SelectItem value="specific">Only specific wallets</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* Edit or See Snapshot */}
|
||||
{field.snapshot ? (
|
||||
<Flex
|
||||
direction={{
|
||||
base: "column",
|
||||
md: isColumn ? "column" : "row",
|
||||
}}
|
||||
gap={1.5}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* disable the "Edit" button when form is disabled, but not when it's a "See" button */}
|
||||
<Button
|
||||
className="gap-2 rounded-md"
|
||||
disabled={disabledSnapshotButton}
|
||||
onClick={() => setOpenIndex(phaseIndex)}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
{isAdmin ? "Edit" : "See"} Claimer Snapshot
|
||||
<UploadIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
<Flex
|
||||
_light={{
|
||||
color: field.snapshot?.length === 0 ? "red.500" : "green.500",
|
||||
}}
|
||||
align="center"
|
||||
color={field.snapshot?.length === 0 ? "red.400" : "green.400"}
|
||||
direction="row"
|
||||
gap={2}
|
||||
justify="center"
|
||||
ml={2}
|
||||
opacity={disabledSnapshotButton ? 0.5 : 1}
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 items-center",
|
||||
field.snapshot?.length === 0
|
||||
? "text-muted-foreground"
|
||||
: "text-green-600 dark:text-green-500",
|
||||
disabledSnapshotButton ? "opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
●{" "}
|
||||
<strong>
|
||||
{field.snapshot?.length} address
|
||||
{field.snapshot?.length === 1 ? "" : "es"}
|
||||
</strong>{" "}
|
||||
in snapshot
|
||||
</p>
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
<Box
|
||||
display={{ base: "none", md: "block" }}
|
||||
w={{ base: "100%", md: "50%" }}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</CustomFormControl>
|
||||
<div className="size-2 bg-current rounded-full" />
|
||||
<span className="text-sm">
|
||||
{field.snapshot?.length}{" "}
|
||||
{field.snapshot?.length === 1 ? "address" : "addresses"} in
|
||||
snapshot
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
};
|
||||
|
||||
+12
-7
@@ -1,6 +1,6 @@
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CustomFormControl } from "../common";
|
||||
import { useClaimConditionsFormContext } from "../index";
|
||||
|
||||
/**
|
||||
@@ -14,8 +14,7 @@ interface CreatorInputProps {
|
||||
export const CreatorInput: React.FC<CreatorInputProps> = ({
|
||||
creatorAddress,
|
||||
}) => {
|
||||
const { formDisabled, claimConditionType, isAdmin } =
|
||||
useClaimConditionsFormContext();
|
||||
const { claimConditionType, isAdmin } = useClaimConditionsFormContext();
|
||||
const walletAddress = useActiveAccount()?.address;
|
||||
|
||||
if (claimConditionType !== "creator") {
|
||||
@@ -23,8 +22,9 @@ export const CreatorInput: React.FC<CreatorInputProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomFormControl
|
||||
disabled={formDisabled}
|
||||
<FormFieldSetup
|
||||
isRequired={false}
|
||||
errorMessage={undefined}
|
||||
helperText={
|
||||
<>
|
||||
This wallet address will be able to indefinitely claim.{" "}
|
||||
@@ -34,7 +34,12 @@ export const CreatorInput: React.FC<CreatorInputProps> = ({
|
||||
}
|
||||
label="Creator address"
|
||||
>
|
||||
<Input disabled readOnly value={creatorAddress || walletAddress} />
|
||||
</CustomFormControl>
|
||||
<Input
|
||||
disabled
|
||||
readOnly
|
||||
value={creatorAddress || walletAddress}
|
||||
className="disabled:opacity-100 max-w-sm"
|
||||
/>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
};
|
||||
|
||||
+9
-10
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { QuantityInputWithUnlimited } from "../../quantity-input-with-unlimited";
|
||||
import { CustomFormControl } from "../common";
|
||||
import { useClaimConditionsFormContext } from "../index";
|
||||
|
||||
/**
|
||||
@@ -23,13 +23,13 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomFormControl
|
||||
disabled={formDisabled}
|
||||
error={
|
||||
<FormFieldSetup
|
||||
isRequired={true}
|
||||
errorMessage={
|
||||
form.getFieldState(
|
||||
`phases.${phaseIndex}.maxClaimablePerWallet`,
|
||||
form.formState,
|
||||
).error
|
||||
).error?.message
|
||||
}
|
||||
helperText={
|
||||
<>
|
||||
@@ -39,14 +39,13 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
|
||||
: ". "}
|
||||
Limits are set per wallets and not per user, sophisticated actors
|
||||
could get around wallet restrictions.{" "}
|
||||
<Link
|
||||
className="text-blue-500"
|
||||
<UnderlineLink
|
||||
href="https://portal.thirdweb.com/contracts/design/Drop#sybil-attacks"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</UnderlineLink>
|
||||
.
|
||||
</>
|
||||
}
|
||||
@@ -66,6 +65,6 @@ export const MaxClaimablePerWalletInput: React.FC = () => {
|
||||
}
|
||||
value={field?.maxClaimablePerWallet?.toString() || "0"}
|
||||
/>
|
||||
</CustomFormControl>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { QuantityInputWithUnlimited } from "../../quantity-input-with-unlimited";
|
||||
import { useClaimConditionsFormContext } from "..";
|
||||
import { CustomFormControl } from "../common";
|
||||
|
||||
/**
|
||||
* Allows the user to select how many NFTs will be dropped in a phase
|
||||
@@ -21,13 +21,13 @@ export const MaxClaimableSupplyInput: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomFormControl
|
||||
disabled={formDisabled}
|
||||
error={
|
||||
<FormFieldSetup
|
||||
isRequired={true}
|
||||
errorMessage={
|
||||
form.getFieldState(
|
||||
`phases.${phaseIndex}.maxClaimableSupply`,
|
||||
form.formState,
|
||||
).error
|
||||
).error?.message
|
||||
}
|
||||
label={`How many ${
|
||||
isErc20 ? "tokens" : "NFTs"
|
||||
@@ -45,6 +45,6 @@ export const MaxClaimableSupplyInput: React.FC = () => {
|
||||
}
|
||||
value={field.maxClaimableSupply?.toString() || "0"}
|
||||
/>
|
||||
</CustomFormControl>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-4
@@ -1,5 +1,5 @@
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CustomFormControl } from "../common";
|
||||
import { useClaimConditionsFormContext } from "../index";
|
||||
|
||||
/**
|
||||
@@ -12,13 +12,15 @@ export const PhaseNameInput: React.FC = () => {
|
||||
const inputValue = field.metadata?.name;
|
||||
|
||||
return (
|
||||
<CustomFormControl
|
||||
disabled={formDisabled}
|
||||
<FormFieldSetup
|
||||
isRequired={false}
|
||||
errorMessage={undefined}
|
||||
helperText="This does not affect how your claim phase functions and is for organizational purposes only."
|
||||
label="Name"
|
||||
>
|
||||
<Input
|
||||
disabled={formDisabled}
|
||||
className="max-w-xs"
|
||||
onChange={(e) => {
|
||||
form.setValue(`phases.${phaseIndex}.metadata.name`, e.target.value);
|
||||
}}
|
||||
@@ -26,6 +28,6 @@ export const PhaseNameInput: React.FC = () => {
|
||||
type="text"
|
||||
value={inputValue}
|
||||
/>
|
||||
</CustomFormControl>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
};
|
||||
|
||||
+9
-7
@@ -1,6 +1,6 @@
|
||||
import { Input } from "@chakra-ui/react";
|
||||
import { FormFieldSetup } from "@/components/blocks/FormFieldSetup";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toDateTimeLocal } from "@/utils/date-utils";
|
||||
import { CustomFormControl } from "../common";
|
||||
import { useClaimConditionsFormContext } from "../index";
|
||||
|
||||
/**
|
||||
@@ -10,16 +10,18 @@ export const PhaseStartTimeInput: React.FC = () => {
|
||||
const { form, phaseIndex, field, formDisabled } =
|
||||
useClaimConditionsFormContext();
|
||||
return (
|
||||
<CustomFormControl
|
||||
disabled={formDisabled}
|
||||
error={
|
||||
<FormFieldSetup
|
||||
isRequired={true}
|
||||
errorMessage={
|
||||
form.getFieldState(`phases.${phaseIndex}.startTime`, form.formState)
|
||||
.error
|
||||
.error?.message
|
||||
}
|
||||
helperText="This time is in your local timezone."
|
||||
label="When will this phase start?"
|
||||
>
|
||||
<Input
|
||||
disabled={formDisabled}
|
||||
className="max-w-sm"
|
||||
onChange={(e) =>
|
||||
form.setValue(
|
||||
`phases.${phaseIndex}.startTime`,
|
||||
@@ -29,6 +31,6 @@ export const PhaseStartTimeInput: React.FC = () => {
|
||||
type="datetime-local"
|
||||
value={toDateTimeLocal(field.startTime)}
|
||||
/>
|
||||
</CustomFormControl>
|
||||
</FormFieldSetup>
|
||||
);
|
||||
};
|
||||
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
import { Flex, FormControl } from "@chakra-ui/react";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
|
||||
import type { FieldError } from "react-hook-form";
|
||||
import type { ComponentWithChildren } from "@/types/component-with-children";
|
||||
import { useClaimConditionsFormContext } from ".";
|
||||
|
||||
interface CustomFormControlProps {
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
error?: FieldError;
|
||||
helperText?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CustomFormControl: ComponentWithChildren<
|
||||
CustomFormControlProps
|
||||
> = (props) => {
|
||||
return (
|
||||
<FormControl isDisabled={props.disabled} isInvalid={!!props.error}>
|
||||
{/* label */}
|
||||
<FormLabel className="font-bold">{props.label}</FormLabel>
|
||||
|
||||
{/* input */}
|
||||
{props.children}
|
||||
|
||||
{/* error message */}
|
||||
{props.error && (
|
||||
<FormErrorMessage>{props.error.message}</FormErrorMessage>
|
||||
)}
|
||||
|
||||
{/* helper text */}
|
||||
{props.helperText && <FormHelperText>{props.helperText}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomFormGroup: ComponentWithChildren = ({ children }) => {
|
||||
const { isColumn } = useClaimConditionsFormContext();
|
||||
return (
|
||||
<Flex
|
||||
direction={{
|
||||
base: "column",
|
||||
md: isColumn ? "column" : "row",
|
||||
}}
|
||||
gap={4}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
+99
-146
@@ -1,21 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
Box,
|
||||
Flex,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
} from "@chakra-ui/react";
|
||||
import { Button } from "chakra/button";
|
||||
import { Heading } from "chakra/heading";
|
||||
import { Text } from "chakra/text";
|
||||
import { CircleHelpIcon, PlusIcon } from "lucide-react";
|
||||
ArrowDownToLineIcon,
|
||||
CircleAlertIcon,
|
||||
CircleHelpIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { createContext, Fragment, useContext, useMemo, useState } from "react";
|
||||
import {
|
||||
type UseFieldArrayReturn,
|
||||
@@ -38,8 +28,16 @@ import {
|
||||
import invariant from "tiny-invariant";
|
||||
import * as z from "zod";
|
||||
import { ZodError } from "zod";
|
||||
import { AdminOnly } from "@/components/contracts/roles/admin-only";
|
||||
import { TransactionButton } from "@/components/tx-button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useIsAdmin } from "@/hooks/useContractRoles";
|
||||
@@ -286,7 +284,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
values: { phases: transformedQueryData },
|
||||
});
|
||||
|
||||
const { append, remove, fields } = useFieldArray({
|
||||
const formFields = useFieldArray({
|
||||
control: form.control,
|
||||
name: "phases",
|
||||
});
|
||||
@@ -296,14 +294,14 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
|
||||
switch (type) {
|
||||
case "public":
|
||||
append({
|
||||
formFields.append({
|
||||
...DEFAULT_PHASE,
|
||||
metadata: { name },
|
||||
});
|
||||
break;
|
||||
|
||||
case "specific":
|
||||
append({
|
||||
formFields.append({
|
||||
...DEFAULT_PHASE,
|
||||
maxClaimablePerWallet: "0",
|
||||
metadata: { name },
|
||||
@@ -312,7 +310,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
break;
|
||||
|
||||
case "overrides":
|
||||
append({
|
||||
formFields.append({
|
||||
...DEFAULT_PHASE,
|
||||
maxClaimablePerWallet: "1",
|
||||
metadata: { name },
|
||||
@@ -321,7 +319,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
break;
|
||||
|
||||
case "creator":
|
||||
append({
|
||||
formFields.append({
|
||||
...DEFAULT_PHASE,
|
||||
maxClaimablePerWallet: "0",
|
||||
maxClaimableSupply: "unlimited",
|
||||
@@ -340,19 +338,15 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
break;
|
||||
|
||||
default:
|
||||
append({
|
||||
formFields.append({
|
||||
...DEFAULT_PHASE,
|
||||
metadata: { name },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removePhase = (index: number) => {
|
||||
remove(index);
|
||||
};
|
||||
|
||||
const phases = form.watch("phases");
|
||||
const controlledFields = fields.map((field, index) => {
|
||||
const controlledFields = formFields.fields.map((field, index) => {
|
||||
return {
|
||||
...field,
|
||||
...phases[index],
|
||||
@@ -428,25 +422,6 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
return phaseId;
|
||||
}, [controlledFields]);
|
||||
|
||||
const { hasAddedPhases, hasRemovedPhases } = useMemo(() => {
|
||||
const initialPhases = claimConditionsQuery.data || [];
|
||||
const currentPhases = controlledFields;
|
||||
|
||||
const _hasAddedPhases =
|
||||
currentPhases.length > initialPhases.length &&
|
||||
claimConditionsQuery?.data?.length === 0 &&
|
||||
controlledFields?.length > 0;
|
||||
const _hasRemovedPhases =
|
||||
currentPhases.length < initialPhases.length &&
|
||||
isMultiPhase &&
|
||||
controlledFields?.length === 0;
|
||||
|
||||
return {
|
||||
hasAddedPhases: _hasAddedPhases,
|
||||
hasRemovedPhases: _hasRemovedPhases,
|
||||
};
|
||||
}, [claimConditionsQuery.data, controlledFields, isMultiPhase]);
|
||||
|
||||
if (claimConditionsQuery.isPending) {
|
||||
return (
|
||||
<div className="flex h-[400px] w-full items-center justify-center rounded-lg border border-border">
|
||||
@@ -465,12 +440,11 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex as="form" direction="column" gap={10} onSubmit={handleFormSubmit}>
|
||||
<Flex direction="column" gap={6}>
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleFormSubmit} className="space-y-5">
|
||||
{/* Show the reason why the form is disabled */}
|
||||
{!isAdmin && (
|
||||
<Text>Connect with admin wallet to edit claim conditions.</Text>
|
||||
)}
|
||||
{!isAdmin && <p>Connect with admin wallet to edit claim conditions.</p>}
|
||||
|
||||
{controlledFields.map((field, index) => {
|
||||
const dropType: DropType = field.snapshot
|
||||
? field.maxClaimablePerWallet?.toString() === "0"
|
||||
@@ -535,7 +509,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
contract={contract}
|
||||
isPending={sendTx.isPending}
|
||||
onRemove={() => {
|
||||
removePhase(index);
|
||||
formFields.remove(index);
|
||||
}}
|
||||
/>
|
||||
</ClaimsConditionFormContext.Provider>
|
||||
@@ -544,45 +518,42 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
})}
|
||||
|
||||
{phases?.length === 0 && (
|
||||
<Alert borderRadius="md" status="warning">
|
||||
<AlertIcon />
|
||||
<div className="flex flex-col">
|
||||
<AlertTitle as={Heading} size="label.lg">
|
||||
{isMultiPhase
|
||||
? "Missing Claim Phases"
|
||||
: "Missing Claim Conditions"}
|
||||
</AlertTitle>
|
||||
<AlertDescription as={Text}>
|
||||
{isMultiPhase
|
||||
? "You need to set at least one claim phase for people to claim this drop."
|
||||
: "You need to set claim conditions for people to claim this drop."}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>
|
||||
{isMultiPhase
|
||||
? "Missing Claim Phases"
|
||||
: "Missing Claim Conditions"}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{isMultiPhase
|
||||
? "You need to set at least one claim phase for people to claim this drop."
|
||||
: "You need to set claim conditions for people to claim this drop."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Flex
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
gap={2}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<div className="flex flex-row gap-2">
|
||||
<AdminOnly contract={contract}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
isDisabled={
|
||||
sendTx.isPending || (!isMultiPhase && phases?.length > 0)
|
||||
}
|
||||
leftIcon={<PlusIcon className="size-5" />}
|
||||
size="sm"
|
||||
variant={phases?.length > 0 ? "outline" : "solid"}
|
||||
{isAdmin && (
|
||||
<div className="flex gap-3 justify-between flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={
|
||||
sendTx.isPending || (!isMultiPhase && phases?.length > 0)
|
||||
}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-card"
|
||||
>
|
||||
<PlusIcon className="size-3.5 mr-2" />
|
||||
Add {isMultiPhase ? "Phase" : "Claim Conditions"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="rounded-lg w-56"
|
||||
sideOffset={10}
|
||||
>
|
||||
Add {isMultiPhase ? "Phase" : "Claim Conditions"}
|
||||
</MenuButton>
|
||||
<MenuList borderRadius="lg" overflow="hidden" zIndex="overlay">
|
||||
{Object.keys(ClaimConditionTypeData).map((key) => {
|
||||
const type = key as ClaimConditionType;
|
||||
|
||||
@@ -591,7 +562,7 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
onClick={() => {
|
||||
addPhase(type);
|
||||
@@ -600,72 +571,54 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{ClaimConditionTypeData[type].name}
|
||||
<TooltipBox
|
||||
content={
|
||||
<Text size="body.md">
|
||||
<ToolTipLabel
|
||||
label={
|
||||
<p className="text-sm">
|
||||
{ClaimConditionTypeData[type].description}
|
||||
</Text>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<CircleHelpIcon className="size-4 text-muted-foreground" />
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</AdminOnly>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{controlledFields.some((field) => field.fromSdk) && (
|
||||
<ResetClaimEligibility
|
||||
contract={contract}
|
||||
isErc20={isErc20}
|
||||
<div className="flex flex-row gap-3">
|
||||
<TransactionButton
|
||||
size="sm"
|
||||
variant="default"
|
||||
client={contract.client}
|
||||
disabled={claimConditionsQuery.isPending}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isMultiphase={isMultiPhase}
|
||||
tokenId={tokenId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
isPending={sendTx.isPending}
|
||||
transactionCount={undefined}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
<ArrowDownToLineIcon className="size-3.5" />
|
||||
{claimConditionsQuery.isPending
|
||||
? "Saving Phases"
|
||||
: "Save Phases"}
|
||||
</TransactionButton>
|
||||
|
||||
<div className="flex flex-row">
|
||||
<AdminOnly contract={contract} fallback={<Box pb={5} />}>
|
||||
<Flex alignItems="center" gap={3} justifyContent="center">
|
||||
{(hasRemovedPhases || hasAddedPhases) && (
|
||||
<Text color="red.500" fontWeight="bold">
|
||||
You have unsaved changes
|
||||
</Text>
|
||||
)}
|
||||
{controlledFields.length > 0 ||
|
||||
hasRemovedPhases ||
|
||||
!isMultiPhase ? (
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
disabled={claimConditionsQuery.isPending}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendTx.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
{claimConditionsQuery.isPending
|
||||
? "Saving Phases"
|
||||
: "Save Phases"}
|
||||
</TransactionButton>
|
||||
) : null}
|
||||
</Flex>
|
||||
</AdminOnly>
|
||||
{controlledFields.some((field) => field.fromSdk) && (
|
||||
<ResetClaimEligibility
|
||||
contract={contract}
|
||||
isErc20={isErc20}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isMultiphase={isMultiPhase}
|
||||
tokenId={tokenId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const TooltipBox: React.FC<{
|
||||
content: React.ReactNode;
|
||||
}> = ({ content }) => {
|
||||
return (
|
||||
<ToolTipLabel label={<div>{content}</div>}>
|
||||
<CircleHelpIcon className="size-4 text-muted-foreground" />
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
+122
-106
@@ -1,12 +1,12 @@
|
||||
import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react";
|
||||
import { formatDate } from "date-fns";
|
||||
import { ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { AdminOnly } from "@/components/contracts/roles/admin-only";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PricePreview } from "../price-preview";
|
||||
import { ClaimConditionTypeData, useClaimConditionsFormContext } from ".";
|
||||
import { CustomFormGroup } from "./common";
|
||||
import { ClaimerSelection } from "./Inputs/ClaimerSelection";
|
||||
import { ClaimPriceInput } from "./Inputs/ClaimPriceInput";
|
||||
import { CreatorInput } from "./Inputs/CreatorInput";
|
||||
@@ -42,114 +42,130 @@ export const ClaimConditionsPhase: React.FC<ClaimConditionsPhaseProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="relative flex flex-col gap-8 p-8">
|
||||
<div className="absolute top-3 right-3 flex flex-row items-start justify-between gap-1">
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={toggleEditing}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{field.isEditing ? "Collapse" : isAdmin ? "Edit" : "See Phase"}
|
||||
{field.isEditing ? (
|
||||
<ChevronUpIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<AdminOnly contract={contract}>
|
||||
<Button
|
||||
className="gap-2 text-red-300"
|
||||
disabled={isPending}
|
||||
onClick={onRemove}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
Remove <XIcon className="size-4" />
|
||||
</Button>
|
||||
</AdminOnly>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2 md:mt-0">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<p className="font-bold text-lg text-muted-foreground">
|
||||
{ClaimConditionTypeData[claimConditionType].name}
|
||||
</p>
|
||||
{isActive && (
|
||||
<Badge className="rounded-lg p-1.5" variant="success">
|
||||
Currently active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground">
|
||||
{ClaimConditionTypeData[claimConditionType].description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!field.isEditing ? (
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
<div className="flex flex-col">
|
||||
<p className="font-bold text-muted-foreground">Phase start</p>
|
||||
<p className="text-muted-foreground">
|
||||
{field.startTime?.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="font-bold text-muted-foreground">
|
||||
{isErc20 ? "Tokens" : "NFTs"} to drop
|
||||
</p>
|
||||
<p className="text-muted-foreground capitalize">
|
||||
{field.maxClaimableSupply}
|
||||
</p>
|
||||
</div>
|
||||
<PricePreview
|
||||
contractChainId={contract.chain.id}
|
||||
currencyAddress={field.currencyAddress}
|
||||
price={field.price}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<p className="font-bold text-muted-foreground">Limit per wallet</p>
|
||||
{claimConditionType === "specific" ? (
|
||||
<p>Set in the snapshot</p>
|
||||
) : claimConditionType === "creator" ? (
|
||||
<p>Unlimited</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground capitalize">
|
||||
{field.maxClaimablePerWallet}
|
||||
</p>
|
||||
<DynamicHeight transition="height 0.3s ease">
|
||||
<div className="border border-border rounded-lg bg-card relative">
|
||||
<div className="p-4 lg:p-6">
|
||||
<div className="mb-4">
|
||||
{isActive && (
|
||||
<div className="lg:absolute lg:right-6 lg:top-6 mb-4 border-b pb-4 border-dashed lg:border-none lg:pb-0">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-auto py-1.5 border border-border bg-background gap-1.5"
|
||||
>
|
||||
<div className="size-2 bg-primary rounded-full" />
|
||||
Currently Active
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="font-semibold text-lg tracking-tight text-foreground mb-0.5">
|
||||
{ClaimConditionTypeData[claimConditionType].name}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm max-w-xl">
|
||||
{ClaimConditionTypeData[claimConditionType].description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CustomFormGroup>
|
||||
{/* Phase Name Input / Form Title */}
|
||||
{isMultiPhase ? <PhaseNameInput /> : null}
|
||||
<PhaseStartTimeInput />
|
||||
</CustomFormGroup>
|
||||
|
||||
<CreatorInput
|
||||
creatorAddress={
|
||||
(field.snapshot?.[0] as { address: string })?.address
|
||||
}
|
||||
/>
|
||||
{!field.isEditing && (
|
||||
<div className="flex flex-col gap-4 md:flex-row md:gap-12 text-sm">
|
||||
{/* Phase start */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-foreground">Phase start</p>
|
||||
<p className="text-muted-foreground">
|
||||
{field.startTime
|
||||
? formatDate(field.startTime, "MMM d, yyyy hh:mm a")
|
||||
: "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CustomFormGroup>
|
||||
<MaxClaimableSupplyInput />
|
||||
<ClaimPriceInput contractChainId={contract.chain.id} />
|
||||
</CustomFormGroup>
|
||||
{/* Tokens to drop */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-foreground">
|
||||
{isErc20 ? "Tokens" : "NFTs"} to drop
|
||||
</p>
|
||||
<p className="text-muted-foreground capitalize">
|
||||
{field.maxClaimableSupply}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{claimConditionType === "specific" ||
|
||||
claimConditionType === "creator" ? null : (
|
||||
<CustomFormGroup>
|
||||
<MaxClaimablePerWalletInput />
|
||||
</CustomFormGroup>
|
||||
{/* Price */}
|
||||
<PricePreview
|
||||
contractChainId={contract.chain.id}
|
||||
currencyAddress={field.currencyAddress}
|
||||
price={field.price}
|
||||
/>
|
||||
|
||||
{/* Limit per wallet */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-medium text-foreground">Limit per wallet</p>
|
||||
{claimConditionType === "specific" ? (
|
||||
<p className="text-muted-foreground"> Set in the snapshot</p>
|
||||
) : claimConditionType === "creator" ? (
|
||||
<p className="text-muted-foreground">Unlimited</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground capitalize">
|
||||
{field.maxClaimablePerWallet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ClaimerSelection />
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
{field.isEditing && (
|
||||
<div className="space-y-6">
|
||||
{/* Phase Name Input / Form Title */}
|
||||
{isMultiPhase ? <PhaseNameInput /> : null}
|
||||
<PhaseStartTimeInput />
|
||||
|
||||
<CreatorInput
|
||||
creatorAddress={
|
||||
(field.snapshot?.[0] as { address: string })?.address
|
||||
}
|
||||
/>
|
||||
|
||||
<MaxClaimableSupplyInput />
|
||||
<ClaimPriceInput contractChainId={contract.chain.id} />
|
||||
|
||||
{claimConditionType === "specific" ||
|
||||
claimConditionType === "creator" ? null : (
|
||||
<MaxClaimablePerWalletInput />
|
||||
)}
|
||||
|
||||
<ClaimerSelection />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-start justify-end gap-3 border-t border-dashed p-4 lg:px-6 py-5">
|
||||
<Button
|
||||
className="gap-2 rounded-full"
|
||||
onClick={toggleEditing}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 transition-transform text-muted-foreground",
|
||||
field.isEditing && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
{field.isEditing ? "Collapse" : isAdmin ? "Edit" : "Expand"}
|
||||
</Button>
|
||||
|
||||
{isAdmin && (
|
||||
<Button
|
||||
className="gap-2 rounded-full"
|
||||
disabled={isPending}
|
||||
onClick={onRemove}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<XIcon className="size-3.5 text-muted-foreground" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DynamicHeight>
|
||||
);
|
||||
};
|
||||
|
||||
+3
-3
@@ -20,14 +20,14 @@ export const ClaimConditions: React.FC<ClaimConditionsProps> = ({
|
||||
isMultiphase,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="space-y-5">
|
||||
<section>
|
||||
<h2 className="mb-1 font-semibold text-xl tracking-tight">
|
||||
<h2 className="mb-0.5 font-semibold text-2xl tracking-tight">
|
||||
Set Claim Conditions
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Control when the {isERC20 ? "tokens" : "NFTs"} get dropped, how much
|
||||
they cost, and more.
|
||||
they cost, and more
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
+14
-22
@@ -1,15 +1,9 @@
|
||||
import {
|
||||
InputGroup,
|
||||
NumberInput,
|
||||
NumberInputField,
|
||||
type NumberInputProps,
|
||||
} from "@chakra-ui/react";
|
||||
import { DecimalInput } from "@/components/ui/decimal-input";
|
||||
|
||||
type InputProps = React.ComponentProps<typeof DecimalInput>;
|
||||
|
||||
interface PriceInputProps
|
||||
extends Omit<
|
||||
NumberInputProps,
|
||||
"onChange" | "value" | "onBlur" | "max" | "min"
|
||||
> {
|
||||
extends Omit<InputProps, "onChange" | "value" | "onBlur" | "max" | "min"> {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
@@ -20,17 +14,15 @@ export const PriceInput: React.FC<PriceInputProps> = ({
|
||||
...restInputProps
|
||||
}) => {
|
||||
return (
|
||||
<InputGroup>
|
||||
<NumberInput defaultValue={0} min={0} value={value} {...restInputProps}>
|
||||
<NumberInputField
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "" || Number(e.target.value) < 0) {
|
||||
return onChange("0");
|
||||
}
|
||||
onChange(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</NumberInput>
|
||||
</InputGroup>
|
||||
<DecimalInput
|
||||
{...restInputProps}
|
||||
onChange={(value) => {
|
||||
if (value === "" || Number(value) < 0) {
|
||||
return onChange("0");
|
||||
}
|
||||
onChange(value);
|
||||
}}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-4
@@ -24,12 +24,12 @@ export const PricePreview: React.FC<PricePreviewProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col text-muted-foreground">
|
||||
<p className="font-bold">Default price</p>
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<p className="font-medium text-foreground">Default price</p>
|
||||
{Number(price) === 0 ? (
|
||||
<p>Free</p>
|
||||
<p className="text-muted-foreground text-sm">Free</p>
|
||||
) : (
|
||||
<p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{price}{" "}
|
||||
{foundCurrency
|
||||
? foundCurrency.symbol
|
||||
|
||||
+13
-15
@@ -2,25 +2,23 @@ import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface QuantityInputWithUnlimitedProps {
|
||||
export function QuantityInputWithUnlimited(props: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
hideMaxButton?: true;
|
||||
decimals?: number;
|
||||
isDisabled: boolean;
|
||||
isRequired: boolean;
|
||||
}
|
||||
}) {
|
||||
const {
|
||||
value = "0",
|
||||
onChange,
|
||||
hideMaxButton,
|
||||
isDisabled,
|
||||
isRequired,
|
||||
decimals,
|
||||
} = props;
|
||||
|
||||
export const QuantityInputWithUnlimited: React.FC<
|
||||
QuantityInputWithUnlimitedProps
|
||||
> = ({
|
||||
value = "0",
|
||||
onChange,
|
||||
hideMaxButton,
|
||||
isDisabled,
|
||||
isRequired,
|
||||
decimals,
|
||||
}) => {
|
||||
const [stringValue, setStringValue] = useState<string>(
|
||||
Number.isNaN(Number(value)) ? "0" : value.toString(),
|
||||
);
|
||||
@@ -45,7 +43,7 @@ export const QuantityInputWithUnlimited: React.FC<
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center rounded-md border border-border">
|
||||
<div className="flex flex-row items-center rounded-md border border-border pr-1 bg-background max-w-sm">
|
||||
<Input
|
||||
className="border-none"
|
||||
disabled={isDisabled}
|
||||
@@ -64,7 +62,7 @@ export const QuantityInputWithUnlimited: React.FC<
|
||||
/>
|
||||
{hideMaxButton ? null : (
|
||||
<Button
|
||||
className="mr-1 text-primary"
|
||||
className="text-muted-foreground bg-transparent hover:accent"
|
||||
disabled={isDisabled}
|
||||
onClick={() => {
|
||||
updateValue("unlimited");
|
||||
@@ -77,4 +75,4 @@ export const QuantityInputWithUnlimited: React.FC<
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+44
-46
@@ -1,31 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { CircleHelpIcon } from "lucide-react";
|
||||
import { CircleHelpIcon, RefreshCcwIcon } from "lucide-react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import * as ERC20Ext from "thirdweb/extensions/erc20";
|
||||
import * as ERC721Ext from "thirdweb/extensions/erc721";
|
||||
import * as ERC1155Ext from "thirdweb/extensions/erc1155";
|
||||
import { useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import { AdminOnly } from "@/components/contracts/roles/admin-only";
|
||||
import { TransactionButton } from "@/components/tx-button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useTxNotifications } from "@/hooks/useTxNotifications";
|
||||
|
||||
interface ResetClaimEligibilityProps {
|
||||
isErc20: boolean;
|
||||
contract: ThirdwebContract;
|
||||
tokenId?: string;
|
||||
isLoggedIn: boolean;
|
||||
isMultiphase: boolean;
|
||||
}
|
||||
|
||||
export const ResetClaimEligibility: React.FC<ResetClaimEligibilityProps> = ({
|
||||
export function ResetClaimEligibility({
|
||||
contract,
|
||||
tokenId,
|
||||
isErc20,
|
||||
isLoggedIn,
|
||||
isMultiphase,
|
||||
}) => {
|
||||
}: {
|
||||
isErc20: boolean;
|
||||
contract: ThirdwebContract;
|
||||
tokenId?: string;
|
||||
isLoggedIn: boolean;
|
||||
isMultiphase: boolean;
|
||||
}) {
|
||||
const sendTxMutation = useSendAndConfirmTransaction();
|
||||
|
||||
const txNotification = useTxNotifications(
|
||||
@@ -70,38 +67,39 @@ export const ResetClaimEligibility: React.FC<ResetClaimEligibilityProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminOnly contract={contract} fallback={<div className="pb-5" />}>
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendTxMutation.isPending}
|
||||
onClick={handleResetClaimEligibility}
|
||||
size="sm"
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="button"
|
||||
>
|
||||
{sendTxMutation.isPending ? (
|
||||
"Resetting Eligibility"
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
Reset Eligibility
|
||||
<ToolTipLabel
|
||||
label={
|
||||
<span className="text-left">
|
||||
This {`contract's`} claim eligibility stores who has already
|
||||
claimed {isErc20 ? "tokens" : "NFTs"} from this contract and
|
||||
carries across claim phases. Resetting claim eligibility will
|
||||
reset this state permanently, and wallets that have already
|
||||
claimed to their limit will be able to claim again.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<CircleHelpIcon className="size-4" />
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
)}
|
||||
</TransactionButton>
|
||||
</AdminOnly>
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendTxMutation.isPending}
|
||||
onClick={handleResetClaimEligibility}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
transactionCount={undefined}
|
||||
txChainID={contract.chain.id}
|
||||
type="button"
|
||||
className="text-destructive-text bg-card"
|
||||
>
|
||||
{sendTxMutation.isPending ? (
|
||||
"Resetting Eligibility"
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshCcwIcon className="size-3.5" />
|
||||
Reset Eligibility
|
||||
<ToolTipLabel
|
||||
label={
|
||||
<span className="text-left">
|
||||
This {`contract's`} claim eligibility stores who has already
|
||||
claimed {isErc20 ? "tokens" : "NFTs"} from this contract and
|
||||
carries across claim phases. Resetting claim eligibility will
|
||||
reset this state permanently, and wallets that have already
|
||||
claimed to their limit will be able to claim again.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<CircleHelpIcon className="size-3.5" />
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
)}
|
||||
</TransactionButton>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
+205
-211
@@ -1,11 +1,14 @@
|
||||
import { CircleAlertIcon, DownloadIcon, UploadIcon } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import type { Column } from "react-table";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CircleAlertIcon,
|
||||
CircleSlashIcon,
|
||||
RotateCcwIcon,
|
||||
} from "lucide-react";
|
||||
import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb";
|
||||
import { DownloadableCode } from "@/components/blocks/code/downloadable-code";
|
||||
import { DropZone } from "@/components/blocks/drop-zone/drop-zone";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import { UnorderedList } from "@/components/ui/List/List";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import {
|
||||
Sheet,
|
||||
@@ -13,10 +16,17 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useCsvUpload } from "@/hooks/useCsvUpload";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CsvDataTable } from "../csv-data-table";
|
||||
|
||||
interface SnapshotAddressInput {
|
||||
address: string;
|
||||
@@ -45,6 +55,72 @@ const csvParser = (items: SnapshotAddressInput[]): SnapshotAddressInput[] => {
|
||||
.filter(({ address }) => address !== "");
|
||||
};
|
||||
|
||||
function SnapshotDataTable({ data }: { data: SnapshotAddressInput[] }) {
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Address</TableHead>
|
||||
<TableHead>Max claimable</TableHead>
|
||||
<TableHead>Price</TableHead>
|
||||
<TableHead>Currency Address</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.map((item) => (
|
||||
<TableRow key={item.address}>
|
||||
<TableCell>
|
||||
{item.isValid ? (
|
||||
item.address
|
||||
) : (
|
||||
<ToolTipLabel
|
||||
label={
|
||||
item.address === ZERO_ADDRESS
|
||||
? "Cannot send tokens to ZERO_ADDRESS"
|
||||
: item.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">
|
||||
{item.address}
|
||||
</div>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.maxClaimable === "0" || !item.maxClaimable
|
||||
? "Default"
|
||||
: item.maxClaimable === "unlimited"
|
||||
? "Unlimited"
|
||||
: item.maxClaimable}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.price === "0"
|
||||
? "Free"
|
||||
: !item.price || item.price === "unlimited"
|
||||
? "Default"
|
||||
: item.price}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.currencyAddress ===
|
||||
"0x0000000000000000000000000000000000000000" ||
|
||||
!item.currencyAddress
|
||||
? "Default"
|
||||
: item.currencyAddress}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
setSnapshot,
|
||||
dropType,
|
||||
@@ -59,11 +135,6 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
defaultRawData: value,
|
||||
});
|
||||
|
||||
const dropzone = useDropzone({
|
||||
onDrop: csvUpload.setFiles,
|
||||
});
|
||||
|
||||
const paginationPortalRef = useRef<HTMLDivElement>(null);
|
||||
const normalizeData = csvUpload.normalizeQuery.data;
|
||||
|
||||
if (!normalizeData) {
|
||||
@@ -88,174 +159,122 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
onClose();
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
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: "Address",
|
||||
},
|
||||
{
|
||||
accessor: ({ maxClaimable }) => {
|
||||
return maxClaimable === "0" || !maxClaimable
|
||||
? "Default"
|
||||
: maxClaimable === "unlimited"
|
||||
? "Unlimited"
|
||||
: maxClaimable;
|
||||
},
|
||||
Header: "Max claimable",
|
||||
},
|
||||
{
|
||||
accessor: ({ price }) => {
|
||||
return price === "0"
|
||||
? "Free"
|
||||
: !price || price === "unlimited"
|
||||
? "Default"
|
||||
: price;
|
||||
},
|
||||
Header: "Price",
|
||||
},
|
||||
{
|
||||
accessor: ({ currencyAddress }) => {
|
||||
return currencyAddress ===
|
||||
"0x0000000000000000000000000000000000000000" || !currencyAddress
|
||||
? "Default"
|
||||
: currencyAddress;
|
||||
},
|
||||
Header: "Currency Address",
|
||||
},
|
||||
] as Column<SnapshotAddressInput>[];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="space-y-6">
|
||||
{csvUpload.rawData.length > 0 ? (
|
||||
<div>
|
||||
<CsvDataTable<SnapshotAddressInput>
|
||||
columns={columns}
|
||||
data={csvUpload.normalizeQuery.data.result}
|
||||
portalRef={paginationPortalRef}
|
||||
/>
|
||||
<SnapshotDataTable data={csvUpload.normalizeQuery.data.result} />
|
||||
<div className="flex justify-end gap-3 border-t py-6 border-dashed">
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
onClick={() => {
|
||||
csvUpload.reset();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<RotateCcwIcon className="size-4" />
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
{csvUpload.normalizeQuery.data?.invalidFound ? (
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
onClick={() => {
|
||||
csvUpload.removeInvalid();
|
||||
}}
|
||||
>
|
||||
<CircleSlashIcon className="size-4" />
|
||||
Remove invalid
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
onClick={onSave}
|
||||
>
|
||||
Next
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex grow flex-col items-center gap-8 overflow-auto">
|
||||
<div className="relative aspect-[21/9] w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full cursor-pointer rounded-md border border-border hover:border-primary",
|
||||
csvUpload.noCsv ? "bg-red-200" : "bg-background",
|
||||
)}
|
||||
{...dropzone.getRootProps()}
|
||||
>
|
||||
<input {...dropzone.getInputProps()} />
|
||||
<div className="!m-auto flex flex-col">
|
||||
<UploadIcon
|
||||
className={cn(
|
||||
"mx-auto mb-2 size-8",
|
||||
csvUpload.noCsv ? "text-red-500" : "text-gray-600",
|
||||
)}
|
||||
/>
|
||||
{dropzone.isDragActive ? (
|
||||
<p>Drop the files here</p>
|
||||
) : (
|
||||
<p
|
||||
className={
|
||||
csvUpload.noCsv ? "text-red-500" : "text-gray-600"
|
||||
}
|
||||
>
|
||||
{csvUpload.noCsv
|
||||
? `No valid CSV file found, make sure your CSV includes the "address" column.`
|
||||
: "Drag & Drop a CSV file here"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-lg">Requirements</p>
|
||||
<UnorderedList>
|
||||
<div>
|
||||
<DropZone
|
||||
className="py-20"
|
||||
onDrop={csvUpload.setFiles}
|
||||
isError={csvUpload.noCsv}
|
||||
title={
|
||||
csvUpload.noCsv
|
||||
? "Invalid CSV file"
|
||||
: "Drag & Drop a CSV file here"
|
||||
}
|
||||
description={
|
||||
"The CSV file must follow the format specified in the requirements below"
|
||||
}
|
||||
resetButton={{ label: "Reset", onClick: () => csvUpload.reset() }}
|
||||
accept=".csv"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-6">
|
||||
<p className="text-base font-medium">Requirements</p>
|
||||
<div className="space-y-2 text-muted-foreground text-sm">
|
||||
{dropType === "specific" ? (
|
||||
<>
|
||||
<li>
|
||||
<p>
|
||||
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 />
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/snapshot-with-maxclaimable.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
</p>
|
||||
|
||||
<DownloadableCode
|
||||
code={snapshotWithMaxClaimable}
|
||||
lang="csv"
|
||||
fileNameWithExtension="snapshot-with-maxclaimable.csv"
|
||||
/>
|
||||
|
||||
<p>
|
||||
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 />
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/snapshot-with-overrides.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</a>
|
||||
</li>
|
||||
</p>
|
||||
|
||||
<DownloadableCode
|
||||
code={snapshotWithOverrides}
|
||||
lang="csv"
|
||||
fileNameWithExtension="snapshot-with-overrides.csv"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>
|
||||
<p>
|
||||
Files <em>must</em> contain one .csv file with a list of
|
||||
addresses.
|
||||
<br />
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/snapshot.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
</p>
|
||||
|
||||
<DownloadableCode
|
||||
code={snapshotCSV}
|
||||
lang="csv"
|
||||
fileNameWithExtension="snapshot.csv"
|
||||
/>
|
||||
|
||||
<p>
|
||||
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 />
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/snapshot-with-maxclaimable.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
</p>
|
||||
|
||||
<DownloadableCode
|
||||
code={snapshotWithMaxClaimable}
|
||||
lang="csv"
|
||||
fileNameWithExtension="snapshot-with-maxclaimable.csv"
|
||||
/>
|
||||
|
||||
<p>
|
||||
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
|
||||
@@ -264,67 +283,27 @@ const SnapshotViewerSheetContent: React.FC<SnapshotUploadProps> = ({
|
||||
When defining a custom currency address, you must also
|
||||
define a price override.
|
||||
</strong>
|
||||
<br />
|
||||
<a
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/snapshot-with-overrides.csv"
|
||||
>
|
||||
<DownloadIcon className="inline size-3" /> Example
|
||||
snapshot
|
||||
</a>
|
||||
</li>
|
||||
</p>
|
||||
|
||||
<DownloadableCode
|
||||
code={snapshotWithOverrides}
|
||||
lang="csv"
|
||||
fileNameWithExtension="snapshot-with-overrides.csv"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<li>
|
||||
<p>
|
||||
Repeated addresses will be removed and only the first found will
|
||||
be kept.
|
||||
</li>
|
||||
<li>
|
||||
</p>
|
||||
<p>
|
||||
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>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex flex-col items-center justify-between border-t p-0 md:mt-0 md:flex-row md:p-4">
|
||||
<div ref={paginationPortalRef} />
|
||||
{!isDisabled && (
|
||||
<div className="mt-4 flex w-full flex-row items-center gap-2 md:mt-0 md:w-auto">
|
||||
<Button
|
||||
className="w-full rounded-md md:w-auto"
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
onClick={() => {
|
||||
csvUpload.reset();
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{csvUpload.normalizeQuery.data?.invalidFound ? (
|
||||
<Button
|
||||
className="w-full rounded-md md:w-auto"
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
onClick={() => {
|
||||
csvUpload.removeInvalid();
|
||||
}}
|
||||
variant="primary"
|
||||
>
|
||||
Remove invalid
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full rounded-md md:w-auto"
|
||||
disabled={isDisabled || csvUpload.rawData.length === 0}
|
||||
onClick={onSave}
|
||||
variant="primary"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -343,8 +322,8 @@ export function SnapshotViewerSheet(
|
||||
}}
|
||||
open={props.isOpen}
|
||||
>
|
||||
<SheetContent className="flex w-full flex-col overflow-y-auto sm:min-w-[540px] lg:min-w-[900px]">
|
||||
<SheetHeader>
|
||||
<SheetContent className="!w-full !max-w-4xl overflow-y-auto">
|
||||
<SheetHeader className="mb-3">
|
||||
<SheetTitle className="text-left">Snapshot</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SnapshotViewerSheetContent {...props} />
|
||||
@@ -352,3 +331,18 @@ export function SnapshotViewerSheet(
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
const snapshotWithMaxClaimable = `\
|
||||
address,maxClaimable
|
||||
0x0000000000000000000000000000000000000000,2
|
||||
0x000000000000000000000000000000000000dEaD,5`;
|
||||
|
||||
const snapshotWithOverrides = `\
|
||||
address,maxClaimable,price,currencyAddress
|
||||
0x0000000000000000000000000000000000000000,2,0.1,0x0000000000000000000000000000000000000000
|
||||
0x000000000000000000000000000000000000dEaD,5,2.5,0x0000000000000000000000000000000000000000`;
|
||||
|
||||
const snapshotCSV = `\
|
||||
address
|
||||
0x0000000000000000000000000000000000000000
|
||||
0x000000000000000000000000000000000000dEaD`;
|
||||
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
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 { TableContainer } from "@/components/ui/table";
|
||||
|
||||
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: {
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
},
|
||||
},
|
||||
// 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}
|
||||
>
|
||||
<p className="text-muted-foreground">
|
||||
{column.render("Header")}
|
||||
</p>
|
||||
</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
|
||||
aria-label="first page"
|
||||
icon={<ChevronFirstIcon className="size-4" />}
|
||||
isDisabled={!canPreviousPage}
|
||||
onClick={() => gotoPage(0)}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="previous page"
|
||||
icon={<ChevronLeftIcon className="size-4" />}
|
||||
isDisabled={!canPreviousPage}
|
||||
onClick={() => previousPage()}
|
||||
/>
|
||||
<p className="my-auto whitespace-nowrap">
|
||||
Page <strong>{pageIndex + 1}</strong> of{" "}
|
||||
<strong>{pageOptions.length}</strong>
|
||||
</p>
|
||||
<IconButton
|
||||
aria-label="next page"
|
||||
icon={<ChevronRightIcon className="size-4" />}
|
||||
isDisabled={!canNextPage}
|
||||
onClick={() => nextPage()}
|
||||
/>
|
||||
<IconButton
|
||||
aria-label="last page"
|
||||
icon={<ChevronLastIcon className="size-4" />}
|
||||
isDisabled={!canNextPage}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+2
-3
@@ -1,5 +1,4 @@
|
||||
import { DeprecatedAlert } from "@app/(dashboard)/(chain)/[chain_id]/[contractAddress]/_layout/DeprecatedAlert";
|
||||
import { ChakraProviderSetup } from "chakra/ChakraProviderSetup";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import type { SidebarLink } from "@/components/blocks/Sidebar";
|
||||
@@ -36,7 +35,7 @@ export function ContractPageLayout(props: {
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<ChakraProviderSetup>
|
||||
<div>
|
||||
<div className="border-border border-b py-8">
|
||||
<div className="container flex flex-col gap-4">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row">
|
||||
@@ -67,6 +66,6 @@ export function ContractPageLayout(props: {
|
||||
{props.children}
|
||||
<div className="h-20" />
|
||||
</SidebarLayout>
|
||||
</ChakraProviderSetup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user