Compare commits
162
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ad015effb | ||
|
|
b86d520f3c | ||
|
|
2df891f380 | ||
|
|
58787a28f3 | ||
|
|
e4ba47bd1e | ||
|
|
710ac32b2f | ||
|
|
6258f7429c | ||
|
|
943cd1bbcf | ||
|
|
ed65f4cbe7 | ||
|
|
0483776825 | ||
|
|
cd142f9ff5 | ||
|
|
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 | ||
|
|
aac187a76c | ||
|
|
03390c1e25 | ||
|
|
8d3b9c6a33 | ||
|
|
ddef88866f | ||
|
|
8133fab9e4 | ||
|
|
37414eb54a | ||
|
|
22bf68a6f0 | ||
|
|
68dd4dc17e | ||
|
|
ecf6dbf2aa | ||
|
|
01daaceb21 | ||
|
|
a5345808ec | ||
|
|
bfa0a42219 | ||
|
|
426bb8bbb2 | ||
|
|
823af013ae | ||
|
|
98aea1e0b9 | ||
|
|
620e294f8c | ||
|
|
84eaded152 | ||
|
|
31d7173432 | ||
|
|
a48329b697 | ||
|
|
6e62a3aa5c | ||
|
|
746b0c9d8b | ||
|
|
0f38a1287a | ||
|
|
61bfebf5d8 | ||
|
|
170c377445 | ||
|
|
4cbf67181d | ||
|
|
6bf6108beb | ||
|
|
ce85be3346 | ||
|
|
e125e743a3 | ||
|
|
05da842b7a | ||
|
|
216b70475e | ||
|
|
9d259d0836 | ||
|
|
ca52cdd3bf | ||
|
|
cf68002922 | ||
|
|
495c7f925d | ||
|
|
32add5f602 | ||
|
|
5f74b3ca3e | ||
|
|
761cde6a32 | ||
|
|
9546b927d9 | ||
|
|
f82f885dad | ||
|
|
34d2fddf49 | ||
|
|
a7544f75ce | ||
|
|
bd3d645be6 | ||
|
|
5fedc2c040 | ||
|
|
6c8a7cf2ba | ||
|
|
4f02f6eeb7 | ||
|
|
72f75ea40b | ||
|
|
8b6a953933 | ||
|
|
33b0b6dcb4 | ||
|
|
cdc7d95db2 | ||
|
|
592bf6f1fd | ||
|
|
8973174ad5 | ||
|
|
c160e91c08 | ||
|
|
f33e31ad6e | ||
|
|
4111d0e638 | ||
|
|
ffb7b6487b | ||
|
|
77063e5c85 | ||
|
|
585eac2a6f | ||
|
|
c54db522b3 | ||
|
|
d62e9971ee | ||
|
|
7d796f5aa5 | ||
|
|
a5bb36141d | ||
|
|
f3234c3c41 | ||
|
|
91e3781e83 | ||
|
|
86e3aa8c04 | ||
|
|
aa1035d16c | ||
|
|
bd395a3cff | ||
|
|
446a69b1d1 | ||
|
|
826d9c80a8 | ||
|
|
772eb0cc53 | ||
|
|
5c6dd874e0 | ||
|
|
a9e47e4759 | ||
|
|
bfd728a9d1 | ||
|
|
381a2823d7 | ||
|
|
2111b8b12c |
@@ -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)
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ NEXT_PUBLIC_TYPESENSE_CONTRACT_API_KEY=
|
||||
|
||||
# posthog API key
|
||||
# - not required for prod/staging
|
||||
NEXT_PUBLIC_POSTHOG_API_KEY="ignored"
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
|
||||
# Stripe Customer portal
|
||||
NEXT_PUBLIC_STRIPE_KEY=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,7 +9,7 @@ module.exports = [
|
||||
// -- build category
|
||||
"/wallets",
|
||||
"/account-abstraction",
|
||||
"/universal-bridge",
|
||||
"/payments",
|
||||
"/auth",
|
||||
"/in-app-wallets",
|
||||
"/transactions",
|
||||
@@ -62,4 +62,6 @@ module.exports = [
|
||||
// -- universal bridge landing pages --
|
||||
"/universal-bridge-regions/:region_slug",
|
||||
"/enterprise",
|
||||
"/token",
|
||||
"/vault",
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
@@ -55,8 +47,8 @@
|
||||
"papaparse": "^5.5.3",
|
||||
"pluralize": "^8.0.0",
|
||||
"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",
|
||||
@@ -65,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",
|
||||
@@ -85,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",
|
||||
@@ -98,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",
|
||||
@@ -128,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",
|
||||
@@ -141,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: "/",
|
||||
@@ -416,7 +431,7 @@ async function redirects() {
|
||||
source: "/connect/account-abstraction",
|
||||
},
|
||||
{
|
||||
destination: "/universal-bridge",
|
||||
destination: "/payments",
|
||||
permanent: false,
|
||||
source: "/connect/universal-bridge",
|
||||
},
|
||||
@@ -440,9 +455,19 @@ async function redirects() {
|
||||
permanent: false,
|
||||
source: "/rpc-edge",
|
||||
},
|
||||
{
|
||||
destination: "/payments",
|
||||
permanent: false,
|
||||
source: "/universal-bridge",
|
||||
},
|
||||
...legacyDashboardToTeamRedirects,
|
||||
...projectPageRedirects,
|
||||
...teamPageRedirects,
|
||||
{
|
||||
source: "/support/:path*",
|
||||
destination: "/team/~/~/support",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import { isVercel } from "@/utils/vercel";
|
||||
import { verifyTurnstileToken } from "./verifyTurnstileToken";
|
||||
import { verifyTurnstileToken } from "../../app/login/verifyTurnstileToken";
|
||||
|
||||
export async function getLoginPayload(
|
||||
params: GenerateLoginPayloadParams,
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import "server-only";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { PostHog } from "posthog-node";
|
||||
|
||||
let _posthogClient: PostHog | null = null;
|
||||
|
||||
function getPostHogServer(): PostHog | null {
|
||||
if (!_posthogClient && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||
_posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
host: "https://us.i.posthog.com",
|
||||
});
|
||||
}
|
||||
return _posthogClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature flag is enabled for a specific user
|
||||
* @param flagKey - The feature flag key
|
||||
* @param userEmail - The user's email address for filtering
|
||||
*/
|
||||
export const isFeatureFlagEnabled = unstable_cache(
|
||||
async (params: {
|
||||
flagKey: string;
|
||||
accountId: string;
|
||||
email: string | undefined;
|
||||
}): Promise<boolean> => {
|
||||
const posthogClient = getPostHogServer();
|
||||
if (!posthogClient) {
|
||||
console.warn("Posthog client not set");
|
||||
return true;
|
||||
}
|
||||
|
||||
const { flagKey, accountId, email } = params;
|
||||
|
||||
try {
|
||||
if (posthogClient && accountId) {
|
||||
const isEnabled = await posthogClient.isFeatureEnabled(
|
||||
flagKey,
|
||||
accountId,
|
||||
{
|
||||
personProperties: email ? { email } : undefined,
|
||||
},
|
||||
);
|
||||
if (isEnabled !== undefined) {
|
||||
return isEnabled;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking feature flag ${flagKey}:`, error);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
["is-feature-flag-enabled"],
|
||||
{
|
||||
revalidate: 3600, // 1 hour
|
||||
},
|
||||
);
|
||||
@@ -413,3 +413,46 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track payment link usage
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @greg
|
||||
*/
|
||||
export function reportPaymentLinkVisited(properties: {
|
||||
linkId: string;
|
||||
clientId: string;
|
||||
}) {
|
||||
posthog.capture("payment link visited", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track payment link usage
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @greg
|
||||
*/
|
||||
export function reportPaymentLinkCompleted(properties: {
|
||||
linkId: string;
|
||||
clientId: string;
|
||||
}) {
|
||||
posthog.capture("payment link completed", properties);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import type {
|
||||
UserOpStats,
|
||||
WalletStats,
|
||||
WalletUserStats,
|
||||
WebhookLatencyStats,
|
||||
WebhookRequestStats,
|
||||
WebhookSummaryStats,
|
||||
} from "@/types/analytics";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
import { getChains } from "./chain";
|
||||
@@ -482,6 +485,60 @@ export async function getEngineCloudMethodUsage(
|
||||
return json.data as EngineCloudStats[];
|
||||
}
|
||||
|
||||
export async function getWebhookSummary(
|
||||
params: AnalyticsQueryParams & { webhookId: string },
|
||||
): Promise<{ data: WebhookSummaryStats[] } | { error: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
searchParams.append("webhookId", params.webhookId);
|
||||
|
||||
const res = await fetchAnalytics(
|
||||
`v2/webhook/summary?${searchParams.toString()}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const reason = await res.text();
|
||||
return { error: reason };
|
||||
}
|
||||
|
||||
return (await res.json()) as { data: WebhookSummaryStats[] };
|
||||
}
|
||||
|
||||
export async function getWebhookRequests(
|
||||
params: AnalyticsQueryParams & { webhookId?: string },
|
||||
): Promise<{ data: WebhookRequestStats[] } | { error: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
if (params.webhookId) {
|
||||
searchParams.append("webhookId", params.webhookId);
|
||||
}
|
||||
|
||||
const res = await fetchAnalytics(
|
||||
`v2/webhook/requests?${searchParams.toString()}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const reason = await res.text();
|
||||
return { error: reason };
|
||||
}
|
||||
|
||||
return (await res.json()) as { data: WebhookRequestStats[] };
|
||||
}
|
||||
|
||||
export async function getWebhookLatency(
|
||||
params: AnalyticsQueryParams & { webhookId?: string },
|
||||
): Promise<{ data: WebhookLatencyStats[] } | { error: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
if (params.webhookId) {
|
||||
searchParams.append("webhookId", params.webhookId);
|
||||
}
|
||||
const res = await fetchAnalytics(
|
||||
`v2/webhook/latency?${searchParams.toString()}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const reason = await res.text();
|
||||
return { error: reason };
|
||||
}
|
||||
|
||||
return (await res.json()) as { data: WebhookLatencyStats[] };
|
||||
}
|
||||
|
||||
export async function getInsightChainUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<{ data: InsightChainStats[] } | { errorMessage: string }> {
|
||||
|
||||
@@ -1,44 +1,8 @@
|
||||
import "server-only";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import type { ChainService } from "@/types/chain";
|
||||
|
||||
export async function getGasSponsoredChains() {
|
||||
if (!API_SERVER_SECRET) {
|
||||
throw new Error("API_SERVER_SECRET is not set");
|
||||
}
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains/gas-sponsored`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-service-api-key": API_SERVER_SECRET,
|
||||
},
|
||||
next: {
|
||||
revalidate: 15 * 60, //15 minutes
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
"Failed to fetch gas sponsored chains",
|
||||
res.status,
|
||||
res.statusText,
|
||||
);
|
||||
res.body?.cancel();
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return (await res.json()).data as number[];
|
||||
} catch (e) {
|
||||
console.error("Failed to parse gas sponsored chains", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getChains() {
|
||||
return fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains`,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"use server";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { SupportTicket } from "../../app/(app)/team/[team_slug]/(team)/~/support/types/tickets";
|
||||
import { getAuthToken, getAuthTokenWalletAddress } from "./auth-token";
|
||||
|
||||
const ESCALATION_FEEDBACK_RATING = 9999;
|
||||
|
||||
export async function createSupportTicket(params: {
|
||||
message: string;
|
||||
teamSlug: string;
|
||||
teamId: string;
|
||||
title: string;
|
||||
conversationId?: string;
|
||||
}): Promise<{ data: SupportTicket } | { error: string }> {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return { error: "No auth token available" };
|
||||
}
|
||||
|
||||
try {
|
||||
const walletAddress = await getAuthTokenWalletAddress();
|
||||
|
||||
const encodedTeamSlug = encodeURIComponent(params.teamSlug);
|
||||
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations`;
|
||||
|
||||
// Build the payload for creating a conversation
|
||||
// If the message does not already include wallet address, prepend it
|
||||
let message = params.message;
|
||||
if (!message.includes("Wallet address:")) {
|
||||
message = `Wallet address: ${String(walletAddress || "-")}\n${message}`;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
markdown: message.trim(),
|
||||
title: params.title,
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"Accept-Encoding": "identity",
|
||||
};
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
body,
|
||||
headers,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { error: `API Server error: ${response.status} - ${errorText}` };
|
||||
}
|
||||
|
||||
const createdConversation: SupportTicket = await response.json();
|
||||
|
||||
// Escalate to SIWA feedback endpoint if conversationId is provided
|
||||
if (params.conversationId) {
|
||||
try {
|
||||
const siwaUrl = process.env.NEXT_PUBLIC_SIWA_URL;
|
||||
if (siwaUrl) {
|
||||
const res = await fetch(`${siwaUrl}/v1/chat/feedback`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(params.teamId ? { "x-team-id": params.teamId } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: params.conversationId,
|
||||
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
|
||||
console.error("Failed to escalate to SIWA feedback:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return { data: createdConversation };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to create support ticket: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMessageToTicket(request: {
|
||||
ticketId: string;
|
||||
teamSlug: string;
|
||||
teamId: string;
|
||||
message: string;
|
||||
}): Promise<{ success: true } | { error: string }> {
|
||||
if (!request.ticketId || !request.teamSlug) {
|
||||
return { error: "Ticket ID and team slug are required" };
|
||||
}
|
||||
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return { error: "No auth token available" };
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedTeamSlug = encodeURIComponent(request.teamSlug);
|
||||
const encodedTicketId = encodeURIComponent(request.ticketId);
|
||||
const apiUrl = `${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${encodedTeamSlug}/support-conversations/${encodedTicketId}/messages`;
|
||||
|
||||
// Append /unthread send for customer messages to ensure proper routing
|
||||
const messageWithUnthread = `${request.message.trim()}\n/unthread send`;
|
||||
const payload = {
|
||||
markdown: messageWithUnthread,
|
||||
};
|
||||
|
||||
const body = JSON.stringify(payload);
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"Accept-Encoding": "identity",
|
||||
...(request.teamId ? { "x-team-id": request.teamId } : {}),
|
||||
};
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
body,
|
||||
headers,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return { error: `API Server error: ${response.status} - ${errorText}` };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to send message: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export async function service_getTeamBySlug(slug: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getTeamById(id: string) {
|
||||
function getTeamById(id: string) {
|
||||
return getTeamBySlug(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
|
||||
export interface WebhookConfig {
|
||||
id: string;
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
destinationUrl: string;
|
||||
description: string;
|
||||
pausedAt: string | null;
|
||||
webhookSecret: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
topics: {
|
||||
id: string;
|
||||
serviceName: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
filters: object | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
type WebhookConfigsResponse =
|
||||
| {
|
||||
data: WebhookConfig[];
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
interface CreateWebhookConfigRequest {
|
||||
topicIdsWithFilters: { id: string; filters: object | null }[];
|
||||
destinationUrl: string;
|
||||
description: string;
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
type CreateWebhookConfigResponse =
|
||||
| {
|
||||
data: WebhookConfig;
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
export interface Topic {
|
||||
id: string;
|
||||
serviceName: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
type TopicsResponse =
|
||||
| {
|
||||
data: Topic[];
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
interface UpdateWebhookConfigRequest {
|
||||
destinationUrl?: string;
|
||||
topicIdsWithFilters?: { id: string; filters: object | null }[];
|
||||
description?: string;
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
type UpdateWebhookConfigResponse =
|
||||
| {
|
||||
data: WebhookConfig;
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
type DeleteWebhookConfigResponse =
|
||||
| {
|
||||
data: WebhookConfig;
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
export async function getWebhookConfigs(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
}): Promise<WebhookConfigsResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWebhookConfig(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
config: CreateWebhookConfigRequest;
|
||||
}): Promise<CreateWebhookConfigResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`,
|
||||
{
|
||||
body: JSON.stringify(props.config),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAvailableTopics(): Promise<TopicsResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/webhook-topics`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateWebhookConfig(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
webhookConfigId: string;
|
||||
config: UpdateWebhookConfigRequest;
|
||||
}): Promise<UpdateWebhookConfigResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`,
|
||||
{
|
||||
body: JSON.stringify(props.config),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PATCH",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteWebhookConfig(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
webhookConfigId: string;
|
||||
}): Promise<DeleteWebhookConfigResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
type TestDestinationUrlResponse =
|
||||
| {
|
||||
result: {
|
||||
httpStatusCode: number;
|
||||
httpResponseBody: string;
|
||||
};
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
export async function testDestinationUrl(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
destinationUrl: string;
|
||||
}): Promise<TestDestinationUrlResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const resp = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/test-destination-url`,
|
||||
{
|
||||
body: JSON.stringify({ destinationUrl: props.destinationUrl }),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
return {
|
||||
body: await resp.text(),
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
return await resp.json();
|
||||
}
|
||||
@@ -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,45 +1,18 @@
|
||||
import { formatDistance } from "date-fns";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Account, BillingCredit } from "@/hooks/useApi";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import type { BillingCredit } from "@/hooks/useApi";
|
||||
import { formatToDollars } from "./formatToDollars";
|
||||
|
||||
interface CreditsItemProps {
|
||||
credit?: BillingCredit;
|
||||
isOpCreditDefault?: boolean;
|
||||
onClickApply?: () => void;
|
||||
twAccount: Account;
|
||||
client: ThirdwebClient;
|
||||
teamSlug: string;
|
||||
}
|
||||
|
||||
export const CreditsItem: React.FC<CreditsItemProps> = ({
|
||||
credit,
|
||||
isOpCreditDefault,
|
||||
onClickApply,
|
||||
twAccount,
|
||||
client,
|
||||
teamSlug,
|
||||
}) => {
|
||||
const [hasAppliedForOpGrant] = useLocalStorage(
|
||||
`appliedForOpGrant-${twAccount.id}`,
|
||||
false,
|
||||
);
|
||||
|
||||
const isOpCredit = credit?.name.startsWith("OP -") || isOpCreditDefault;
|
||||
export const CreditsItem: React.FC<CreditsItemProps> = ({ credit }) => {
|
||||
const isTwCredit = credit?.name.startsWith("TW -");
|
||||
const isStartupCredit = credit?.name.startsWith("SU -");
|
||||
|
||||
let creditTitle = credit?.name ?? "thirdweb credits";
|
||||
if (isOpCredit) {
|
||||
creditTitle = "Optimism sponsorship credits";
|
||||
} else if (isTwCredit) {
|
||||
if (isTwCredit) {
|
||||
creditTitle = "thirdweb credits";
|
||||
} else if (isStartupCredit) {
|
||||
creditTitle = "Startup grant credits";
|
||||
@@ -50,13 +23,7 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
|
||||
<div className="flex flex-col gap-3 p-4 lg:p-6">
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 right-0">
|
||||
{isOpCredit ? (
|
||||
<ChainIconClient
|
||||
className="size-6"
|
||||
client={client}
|
||||
src="ipfs://QmcxZHpyJa8T4i63xqjPYrZ6tKrt55tZJpbXcjSDKuKaf9/optimism/512.png"
|
||||
/>
|
||||
) : isTwCredit ? (
|
||||
{isTwCredit ? (
|
||||
<Image
|
||||
alt="tw-credit"
|
||||
className="size-6"
|
||||
@@ -112,35 +79,7 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasAppliedForOpGrant && !credit && isOpCredit && (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>Grant application pending approval</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will receive an email once your application's status
|
||||
changes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasAppliedForOpGrant && isOpCredit && (
|
||||
<div className="mt-2 flex justify-end border-t px-4 py-4 lg:px-6">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/settings/credits`}
|
||||
onClick={() => {
|
||||
if (onClickApply) {
|
||||
onClickApply();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Apply Now
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,41 +2,26 @@
|
||||
|
||||
// TODO - convert to RSC
|
||||
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { type Account, useAccountCredits } from "@/hooks/useApi";
|
||||
import { useAccountCredits } from "@/hooks/useApi";
|
||||
import { CreditsItem } from "./CreditsItem";
|
||||
|
||||
export const CreditsInfoCard = (props: {
|
||||
twAccount: Account;
|
||||
client: ThirdwebClient;
|
||||
teamSlug: string;
|
||||
}) => {
|
||||
export const CreditsInfoCard = () => {
|
||||
const { data: credits } = useAccountCredits();
|
||||
|
||||
if (!credits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opCredit = credits.find((crd) => crd.name.startsWith("OP -"));
|
||||
const restCredits = credits.filter((crd) => !crd.name.startsWith("OP -"));
|
||||
|
||||
if (restCredits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<CreditsItem
|
||||
client={props.client}
|
||||
credit={opCredit}
|
||||
isOpCreditDefault={true}
|
||||
teamSlug={props.teamSlug}
|
||||
twAccount={props.twAccount}
|
||||
/>
|
||||
{restCredits?.map((credit) => (
|
||||
<CreditsItem
|
||||
client={props.client}
|
||||
credit={credit}
|
||||
key={credit.couponId}
|
||||
teamSlug={props.teamSlug}
|
||||
twAccount={props.twAccount}
|
||||
/>
|
||||
{restCredits.map((credit) => (
|
||||
<CreditsItem credit={credit} key={credit.couponId} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -29,6 +29,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
title: string;
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
headerClassName?: string;
|
||||
};
|
||||
customHeader?: React.ReactNode;
|
||||
// chart config
|
||||
@@ -52,6 +53,12 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
|
||||
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
|
||||
emptyChartState?: React.ReactElement;
|
||||
margin?: {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
@@ -62,7 +69,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
return (
|
||||
<Card className={props.className}>
|
||||
{props.header && (
|
||||
<CardHeader>
|
||||
<CardHeader className={props.header.headerClassName}>
|
||||
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
|
||||
{props.header.title}
|
||||
</CardTitle>
|
||||
@@ -85,7 +92,16 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
{props.emptyChartState}
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<AreaChart accessibilityLayer data={props.data}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={props.data}
|
||||
margin={{
|
||||
right: props.margin?.right ?? 0,
|
||||
left: props.margin?.left ?? 0,
|
||||
bottom: props.margin?.bottom ?? 10,
|
||||
top: props.margin?.top ?? 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
{props.yAxis && <YAxis axisLine={false} tickLine={false} />}
|
||||
<XAxis
|
||||
|
||||
@@ -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?: (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { CrownIcon, LockIcon, SparklesIcon } from "lucide-react";
|
||||
import { CrownIcon, LockIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import type { Team } from "@/api/team";
|
||||
@@ -111,18 +111,15 @@ export function UpsellContent(props: {
|
||||
<CardContent className="space-y-6">
|
||||
{props.benefits && props.benefits.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-muted-foreground text-sm uppercase tracking-wide">
|
||||
What you'll get:
|
||||
<h4 className="font-semibold text-foreground text-sm capitalize text-center">
|
||||
What you'll get
|
||||
</h4>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1.5">
|
||||
{props.benefits.map((benefit) => (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
className="flex items-center justify-center gap-3 text-center text-balance"
|
||||
key={benefit.description}
|
||||
>
|
||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-accent">
|
||||
<SparklesIcon className="h-3 w-3 text-success-text" />
|
||||
</div>
|
||||
<span className="text-sm">{benefit.description}</span>
|
||||
{benefit.status === "soon" && (
|
||||
<Badge className="text-xs" variant="secondary">
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -116,13 +116,12 @@ export function ChatBar(props: {
|
||||
) : (
|
||||
<Button
|
||||
aria-label="Send"
|
||||
className="!h-auto w-auto border border-nebula-pink-foreground p-2 disabled:opacity-100"
|
||||
className="!h-auto w-auto p-2 disabled:opacity-100"
|
||||
disabled={message.trim() === "" || props.isConnectingWallet}
|
||||
onClick={() => {
|
||||
if (message.trim() === "") return;
|
||||
handleSubmit(message);
|
||||
}}
|
||||
variant="pink"
|
||||
>
|
||||
<ArrowUpIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { MessageCircleIcon, XIcon } from "lucide-react";
|
||||
import { useSelectedLayoutSegments } from "next/navigation";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { createThirdwebClient } from "thirdweb";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -14,22 +16,22 @@ const client = createThirdwebClient({
|
||||
});
|
||||
|
||||
export function CustomChatButton(props: {
|
||||
isLoggedIn: boolean;
|
||||
networks: "mainnet" | "testnet" | "all" | null;
|
||||
isFloating: boolean;
|
||||
pageType: "chain" | "contract" | "support";
|
||||
label: string;
|
||||
examplePrompts: string[];
|
||||
authToken: string | undefined;
|
||||
teamId: string | undefined;
|
||||
authToken: string;
|
||||
team: Team;
|
||||
clientId: string | undefined;
|
||||
requireLogin?: boolean;
|
||||
}) {
|
||||
const layoutSegments = useSelectedLayoutSegments();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasBeenOpened, setHasBeenOpened] = useState(false);
|
||||
const closeModal = useCallback(() => setIsOpen(false), []);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (layoutSegments[0] === "~" && layoutSegments[1] === "support") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Inline Button (not floating) */}
|
||||
@@ -54,14 +56,14 @@ export function CustomChatButton(props: {
|
||||
ref={ref}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex items-center justify-between border-b px-4 py-4">
|
||||
<div className="flex items-center gap-2 font-semibold text-lg">
|
||||
<MessageCircleIcon className="size-5 text-muted-foreground" />
|
||||
{props.label}
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close chat"
|
||||
className="h-auto w-auto p-1 text-muted-foreground"
|
||||
className="h-auto w-auto p-1 text-muted-foreground rounded-full"
|
||||
onClick={closeModal}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -80,9 +82,7 @@ export function CustomChatButton(props: {
|
||||
message: prompt,
|
||||
title: prompt,
|
||||
}))}
|
||||
networks={props.networks}
|
||||
requireLogin={props.requireLogin}
|
||||
teamId={props.teamId}
|
||||
team={props.team}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,36 @@
|
||||
"use client";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWalletConnectionStatus } from "thirdweb/react";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NebulaIcon } from "@/icons/NebulaIcon";
|
||||
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
|
||||
import { ChatBar } from "./ChatBar";
|
||||
import type { UserMessage, UserMessageContent } from "./CustomChats";
|
||||
import { type CustomChatMessage, CustomChats } from "./CustomChats";
|
||||
import type { ExamplePrompt, NebulaContext } from "./types";
|
||||
import type { ExamplePrompt } from "./types";
|
||||
|
||||
export default function CustomChatContent(props: {
|
||||
authToken: string | undefined;
|
||||
teamId: string | undefined;
|
||||
team: Team;
|
||||
clientId: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
examplePrompts: ExamplePrompt[];
|
||||
networks: NebulaContext["networks"];
|
||||
requireLogin?: boolean;
|
||||
}) {
|
||||
if (props.requireLogin !== false && !props.authToken) {
|
||||
return <LoggedOutStateChatContent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomChatContentLoggedIn
|
||||
authToken={props.authToken || ""}
|
||||
client={props.client}
|
||||
clientId={props.clientId}
|
||||
examplePrompts={props.examplePrompts}
|
||||
teamId={props.teamId}
|
||||
team={props.team}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomChatContentLoggedIn(props: {
|
||||
authToken: string;
|
||||
teamId: string | undefined;
|
||||
team: Team;
|
||||
clientId: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
examplePrompts: ExamplePrompt[];
|
||||
@@ -55,6 +47,9 @@ function CustomChatContentLoggedIn(props: {
|
||||
const [enableAutoScroll, setEnableAutoScroll] = useState(false);
|
||||
const connectionStatus = useActiveWalletConnectionStatus();
|
||||
|
||||
const [showSupportForm, setShowSupportForm] = useState(false);
|
||||
const [productLabel, setProductLabel] = useState("");
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (userMessage: UserMessage) => {
|
||||
const abortController = new AbortController();
|
||||
@@ -96,7 +91,7 @@ function CustomChatContentLoggedIn(props: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${props.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(props.teamId ? { "x-team-id": props.teamId } : {}),
|
||||
"x-team-id": props.team.id,
|
||||
...(props.clientId ? { "x-client-id": props.clientId } : {}),
|
||||
},
|
||||
method: "POST",
|
||||
@@ -132,7 +127,7 @@ function CustomChatContentLoggedIn(props: {
|
||||
setEnableAutoScroll(false);
|
||||
}
|
||||
},
|
||||
[props.authToken, props.clientId, props.teamId, sessionId],
|
||||
[props.authToken, props.clientId, props.team.id, sessionId],
|
||||
);
|
||||
|
||||
const handleFeedback = useCallback(
|
||||
@@ -165,7 +160,7 @@ function CustomChatContentLoggedIn(props: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${props.authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(props.teamId ? { "x-team-id": props.teamId } : {}),
|
||||
"x-team-id": props.team.id,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
@@ -188,9 +183,20 @@ function CustomChatContentLoggedIn(props: {
|
||||
// Consider implementing retry logic here
|
||||
}
|
||||
},
|
||||
[sessionId, props.authToken, props.teamId, messages],
|
||||
[sessionId, props.authToken, props.team.id, messages],
|
||||
);
|
||||
|
||||
const handleAddSuccessMessage = useCallback((message: string) => {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
type: "assistant" as const,
|
||||
text: message,
|
||||
request_id: undefined,
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const showEmptyState = !userHasSubmittedMessage && messages.length === 0;
|
||||
return (
|
||||
<div className="flex grow flex-col overflow-hidden">
|
||||
@@ -212,6 +218,12 @@ function CustomChatContentLoggedIn(props: {
|
||||
sessionId={sessionId}
|
||||
setEnableAutoScroll={setEnableAutoScroll}
|
||||
useSmallText
|
||||
showSupportForm={showSupportForm}
|
||||
setShowSupportForm={setShowSupportForm}
|
||||
productLabel={productLabel}
|
||||
setProductLabel={setProductLabel}
|
||||
team={props.team}
|
||||
addSuccessMessage={handleAddSuccessMessage}
|
||||
/>
|
||||
)}
|
||||
<ChatBar
|
||||
@@ -245,42 +257,6 @@ function CustomChatContentLoggedIn(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function LoggedOutStateChatContent() {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div className="flex grow flex-col items-center justify-center p-4">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full border p-1">
|
||||
<div className="rounded-full border bg-card p-2">
|
||||
<NebulaIcon className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="px-4 text-center font-semibold text-3xl tracking-tight md:text-4xl">
|
||||
How can I help you <br className="max-sm:hidden" />
|
||||
today?
|
||||
</h1>
|
||||
|
||||
<div className="h-3" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
Sign in to use AI Assistant
|
||||
</p>
|
||||
<div className="h-5" />
|
||||
|
||||
<Button asChild>
|
||||
<Link
|
||||
className="w-full max-w-96 gap-2"
|
||||
href={`/login?next=${encodeURIComponent(pathname)}`}
|
||||
>
|
||||
Sign in
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyStateChatPageContent(props: {
|
||||
sendMessage: (message: UserMessage) => void;
|
||||
examplePrompts: { title: string; message: string }[];
|
||||
@@ -288,9 +264,12 @@ function EmptyStateChatPageContent(props: {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center overflow-auto p-4 ">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div className="rounded-full border-[1.5px] border-nebula-pink-foreground/20 bg-[hsl(var(--nebula-pink-foreground)/5%)] p-1">
|
||||
<div className="rounded-full border-[1.5px] border-nebula-pink-foreground/40 bg-[hsl(var(--nebula-pink-foreground)/5%)] p-2">
|
||||
<NebulaIcon className="size-7 text-nebula-pink-foreground" />
|
||||
<div className="rounded-full border p-1 bg-muted/20">
|
||||
<div className="rounded-full border p-2 bg-inverted">
|
||||
<ThirdwebMiniLogo
|
||||
isMonoChrome
|
||||
className="size-7 text-inverted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -301,11 +280,12 @@ function EmptyStateChatPageContent(props: {
|
||||
</h1>
|
||||
|
||||
<div className="h-6" />
|
||||
<div className="flex max-w-lg flex-col flex-wrap justify-center gap-2.5">
|
||||
<div className="flex flex-col items-center justify-center gap-2.5 overflow-hidden">
|
||||
{props.examplePrompts.map((prompt) => (
|
||||
<Button
|
||||
disabled={false}
|
||||
key={prompt.title}
|
||||
className="rounded-full text-xs sm:text-sm truncate bg-card w-fit h-auto py-1.5 whitespace-pre-wrap"
|
||||
onClick={() =>
|
||||
props.sendMessage({
|
||||
content: [
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
MessageCircleIcon,
|
||||
ArrowRightIcon,
|
||||
ThumbsDownIcon,
|
||||
ThumbsUpIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Team } from "@/api/team";
|
||||
import { MarkdownRenderer } from "@/components/blocks/markdown-renderer";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Reasoning } from "./Reasoning";
|
||||
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
|
||||
import { SupportTicketForm } from "../../../app/(app)/team/[team_slug]/(team)/~/support/_components/SupportTicketForm";
|
||||
import { Button } from "../ui/button";
|
||||
import { TextShimmer } from "../ui/text-shimmer";
|
||||
|
||||
// Define local types
|
||||
export type UserMessageContent = { type: "text"; text: string };
|
||||
@@ -46,11 +57,18 @@ export function CustomChats(props: {
|
||||
enableAutoScroll: boolean;
|
||||
useSmallText?: boolean;
|
||||
sendMessage: (message: UserMessage) => void;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => void;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
|
||||
showSupportForm: boolean;
|
||||
setShowSupportForm: (v: boolean) => void;
|
||||
productLabel: string;
|
||||
setProductLabel: (v: string) => void;
|
||||
team: Team;
|
||||
addSuccessMessage?: (message: string) => void;
|
||||
}) {
|
||||
const { messages, setEnableAutoScroll, enableAutoScroll } = props;
|
||||
const scrollAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [supportTicketCreated, setSupportTicketCreated] = useState(false);
|
||||
|
||||
// auto scroll to bottom when messages change
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
@@ -86,6 +104,8 @@ export function CustomChats(props: {
|
||||
chatScrollContainer.addEventListener("wheel", disableScroll);
|
||||
}, [setEnableAutoScroll, enableAutoScroll]);
|
||||
|
||||
const [showSupportFormDialog, setShowSupportFormDialog] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex max-h-full flex-1 flex-col overflow-hidden"
|
||||
@@ -123,6 +143,66 @@ export function CustomChats(props: {
|
||||
sendMessage={props.sendMessage}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
{/* Support Case Button/Form in last assistant message */}
|
||||
{message.type === "assistant" &&
|
||||
index === props.messages.length - 1 && (
|
||||
<>
|
||||
{/* Only show button/form if ticket not created */}
|
||||
{!supportTicketCreated && (
|
||||
<div className="mt-3 pl-12">
|
||||
<Dialog
|
||||
open={showSupportFormDialog}
|
||||
onOpenChange={setShowSupportFormDialog}
|
||||
>
|
||||
<Button
|
||||
onClick={() => setShowSupportFormDialog(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-full bg-card"
|
||||
>
|
||||
Create Support Case
|
||||
<ArrowRightIcon className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
|
||||
<DialogContent className="p-0 bg-card">
|
||||
<DynamicHeight>
|
||||
<DialogHeader className="p-4 lg:p-6 border-b border-dashed space-y-1">
|
||||
<DialogTitle className="text-xl font-semibold text-foreground tracking-tight">
|
||||
Create Support Case
|
||||
</DialogTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Let's create a detailed support case for
|
||||
our technical team.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<SupportTicketForm
|
||||
team={props.team}
|
||||
productLabel={props.productLabel}
|
||||
setProductLabel={props.setProductLabel}
|
||||
conversationId={props.sessionId}
|
||||
closeForm={() => {
|
||||
setShowSupportFormDialog(false);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
props.setShowSupportForm(false);
|
||||
props.setProductLabel("");
|
||||
setSupportTicketCreated(true);
|
||||
// Add success message as a regular assistant message
|
||||
if (props.addSuccessMessage) {
|
||||
const supportPortalUrl = `/team/${props.team.slug}/~/support`;
|
||||
props.addSuccessMessage(
|
||||
`Your support ticket has been created! Our team will get back to you soon. You can also visit the [support portal](${supportPortalUrl}) to track your case.`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DynamicHeight>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -143,7 +223,7 @@ function RenderMessage(props: {
|
||||
nextMessage: CustomChatMessage | undefined;
|
||||
authToken: string;
|
||||
sessionId: string | undefined;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => void;
|
||||
onFeedback?: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
|
||||
}) {
|
||||
const { message } = props;
|
||||
|
||||
@@ -179,13 +259,16 @@ function RenderMessage(props: {
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-full",
|
||||
message.type === "assistant" && "border bg-card",
|
||||
message.type === "assistant" && "border bg-inverted",
|
||||
message.type === "error" && "border",
|
||||
message.type === "presence" && "border bg-card",
|
||||
message.type === "presence" && "border bg-inverted",
|
||||
)}
|
||||
>
|
||||
{(message.type === "presence" || message.type === "assistant") && (
|
||||
<MessageCircleIcon className="size-5 text-muted-foreground" />
|
||||
<ThirdwebMiniLogo
|
||||
className="size-4 text-inverted-foreground"
|
||||
isMonoChrome
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.type === "error" && (
|
||||
@@ -227,7 +310,7 @@ function RenderMessage(props: {
|
||||
function CustomFeedbackButtons(props: {
|
||||
message: CustomChatMessage & { type: "assistant" };
|
||||
messageIndex: number;
|
||||
onFeedback: (messageIndex: number, feedback: 1 | -1) => void;
|
||||
onFeedback: (messageIndex: number, feedback: 1 | -1) => Promise<void>;
|
||||
className?: string;
|
||||
}) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -240,9 +323,9 @@ function CustomFeedbackButtons(props: {
|
||||
await props.onFeedback(props.messageIndex, feedback);
|
||||
} catch (_e) {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
// Don't show buttons if feedback already given
|
||||
@@ -251,25 +334,26 @@ function CustomFeedbackButtons(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-2", props.className)}>
|
||||
<button
|
||||
aria-label="Thumbs up"
|
||||
className="text-muted-foreground transition-colors hover:text-green-500 disabled:opacity-50"
|
||||
<div className={cn("flex gap-1.5", props.className)}>
|
||||
<Button
|
||||
aria-label="Upvote"
|
||||
variant="outline"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => handleFeedback(1)}
|
||||
type="button"
|
||||
className="size-8 p-0 rounded-lg bg-card"
|
||||
>
|
||||
<ThumbsUpIcon className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Thumbs down"
|
||||
className="text-muted-foreground transition-colors hover:text-red-500 disabled:opacity-50"
|
||||
<ThumbsUpIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Downvote"
|
||||
variant="outline"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => handleFeedback(-1)}
|
||||
type="button"
|
||||
className="size-8 p-0 rounded-lg bg-card"
|
||||
>
|
||||
<ThumbsDownIcon className="size-5" />
|
||||
</button>
|
||||
<ThumbsDownIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -296,7 +380,11 @@ function RenderResponse(props: {
|
||||
);
|
||||
|
||||
case "presence":
|
||||
return <Reasoning isPending={isMessagePending} texts={message.texts} />;
|
||||
return (
|
||||
<div className="h-8 flex items-center">
|
||||
<TextShimmer text="Reasoning..." />
|
||||
</div>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
@@ -325,19 +413,15 @@ function StyledMarkdownRenderer(props: {
|
||||
}) {
|
||||
return (
|
||||
<MarkdownRenderer
|
||||
className="text-foreground [&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
|
||||
className="[&>*:first-child]:mt-0 [&>*:first-child]:border-none [&>*:first-child]:pb-0 [&>*:last-child]:mb-0"
|
||||
code={{
|
||||
className: "bg-transparent",
|
||||
className: "bg-card",
|
||||
ignoreFormattingErrors: true,
|
||||
}}
|
||||
inlineCode={{ className: "border-none" }}
|
||||
li={{ className: "text-foreground" }}
|
||||
markdownText={props.text}
|
||||
p={{
|
||||
className:
|
||||
props.type === "assistant"
|
||||
? "text-foreground"
|
||||
: "text-foreground leading-normal",
|
||||
className: props.type === "assistant" ? "" : "leading-normal",
|
||||
}}
|
||||
skipHtml
|
||||
/>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { TextShimmer } from "@/components/ui/text-shimmer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Reasoning(props: { isPending: boolean; texts: string[] }) {
|
||||
const [_isOpen, setIsOpen] = useState(false);
|
||||
const isOpen = props.isPending ? true : _isOpen;
|
||||
const showAll = !props.isPending;
|
||||
const lastText = props.texts[props.texts.length - 1];
|
||||
|
||||
return (
|
||||
<DynamicHeight>
|
||||
<Button
|
||||
className="h-auto w-auto items-center gap-1 px-0 py-1 pb-2 font-normal text-base text-muted-foreground hover:text-foreground hover:no-underline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
size="sm"
|
||||
variant="link"
|
||||
>
|
||||
{props.isPending ? (
|
||||
<TextShimmer text="Reasoning..." />
|
||||
) : (
|
||||
<span className="text-foreground">Reasoning</span>
|
||||
)}
|
||||
|
||||
{!props.isPending && (
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{isOpen && (
|
||||
<>
|
||||
{showAll && props.texts.length > 0 && (
|
||||
<ul className="list-none space-y-1.5">
|
||||
{props.texts.map((text) => (
|
||||
<li
|
||||
className="fade-in-0 animate-in text-muted-foreground text-sm leading-relaxed duration-300"
|
||||
key={text}
|
||||
>
|
||||
{text.trim()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!showAll && lastText && (
|
||||
<div className="text-muted-foreground text-sm leading-relaxed">
|
||||
{lastText.trim()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DynamicHeight>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,6 @@ export type ExamplePrompt = {
|
||||
|
||||
// TODO - remove "Nebula" wording and simplify types
|
||||
|
||||
export type NebulaContext = {
|
||||
chainIds: string[] | null;
|
||||
walletAddress: string | null;
|
||||
networks: "mainnet" | "testnet" | "all" | null;
|
||||
};
|
||||
|
||||
type NebulaUserMessageContentItem =
|
||||
| {
|
||||
type: "image";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useFavoriteChainIds } from "@app/(dashboard)/(chain)/components/client/star-button";
|
||||
import { doLogout } from "@app/login/auth-actions";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
useActiveAccount,
|
||||
useConnectModal,
|
||||
} from "thirdweb/react";
|
||||
import { doLogout } from "@/actions/auth-actions";
|
||||
import { resetAnalytics } from "@/analytics/reset";
|
||||
import { CustomChainRenderer } from "@/components/misc/CustomChainRenderer";
|
||||
import { LazyConfigureNetworkModal } from "@/components/misc/configure-networks/LazyConfigureNetworkModal";
|
||||
|
||||
+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"
|
||||
>
|
||||
Connect 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>
|
||||
);
|
||||
};
|
||||
|
||||
+117
-137
@@ -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,18 @@ 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 +117,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 +361,71 @@ 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>
|
||||
<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)}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Error</h3>
|
||||
<PlainTextCodeBlock
|
||||
className="text-red-500 bg-background"
|
||||
code={formatError(error)}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
) : 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 +435,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 +477,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 +533,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,
|
||||
|
||||
@@ -47,7 +47,7 @@ export function AnnouncementBanner() {
|
||||
return (
|
||||
<AnnouncementBannerUI
|
||||
href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
|
||||
label="We have re-branded our Engine, Universal Bridge, and Connect products. Please read the full blog post for details on changes"
|
||||
label="We have re-branded our Engine, Payments, and Connect products. Please read the full blog post for details on changes"
|
||||
trackingLabel="product-rebrand"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}</>;
|
||||
};
|
||||
@@ -38,6 +38,7 @@ import { createProjectClient } from "@/hooks/useApi";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
|
||||
import { toArrFromList } from "@/utils/string";
|
||||
import { createVaultAccountAndAccessToken } from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
|
||||
|
||||
const ALL_PROJECT_SERVICES = SERVICES.filter(
|
||||
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
|
||||
@@ -63,6 +64,16 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
|
||||
<CreateProjectDialogUI
|
||||
createProject={async (params) => {
|
||||
const res = await createProjectClient(props.teamId, params);
|
||||
await createVaultAccountAndAccessToken({
|
||||
project: res.project,
|
||||
projectSecretKey: res.secret,
|
||||
}).catch((error) => {
|
||||
console.error(
|
||||
"Failed to create vault account and access token",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
return {
|
||||
project: res.project,
|
||||
secret: res.secret,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,7 @@ export function CopyAddressButton(props: {
|
||||
address: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
tooltip?: string;
|
||||
variant?:
|
||||
| "primary"
|
||||
| "default"
|
||||
@@ -24,7 +25,7 @@ export function CopyAddressButton(props: {
|
||||
copyIconPosition={props.copyIconPosition}
|
||||
textToCopy={props.address}
|
||||
textToShow={shortenedAddress}
|
||||
tooltip="Copy Address"
|
||||
tooltip={props.tooltip || "Copy Address"}
|
||||
variant={props.variant}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DotsBackgroundPattern(props: { className?: string }) {
|
||||
@@ -16,3 +17,70 @@ export function DotsBackgroundPattern(props: { className?: string }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface GridPatternProps extends React.SVGProps<SVGSVGElement> {
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
squares?: Array<[x: number, y: number]>;
|
||||
strokeDasharray?: string;
|
||||
className?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function GridPattern({
|
||||
width = 40,
|
||||
height = 40,
|
||||
x = -1,
|
||||
y = -1,
|
||||
strokeDasharray = "0",
|
||||
squares,
|
||||
className,
|
||||
...props
|
||||
}: GridPatternProps) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full fill-current stroke-current",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={id}
|
||||
width={width}
|
||||
height={height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<path
|
||||
d={`M.5 ${height}V.5H${width}`}
|
||||
fill="none"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
|
||||
{squares && (
|
||||
<svg x={x} y={y} className="overflow-visible" role="presentation">
|
||||
{squares.map(([x, y]) => (
|
||||
<rect
|
||||
strokeWidth="0"
|
||||
key={`${x}-${y}`}
|
||||
width={width - 1}
|
||||
height={height - 1}
|
||||
x={x * width + 1}
|
||||
y={y * height + 1}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ const buttonVariants = cva(
|
||||
link: "underline-offset-4 hover:underline",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
pink: "border border-nebula-pink-foreground !text-nebula-pink-foreground bg-[hsl(var(--nebula-pink-foreground)/5%)] hover:bg-nebula-pink-foreground/10 dark:!text-foreground dark:bg-nebula-pink-foreground/10 dark:hover:bg-nebula-pink-foreground/20",
|
||||
primary: "bg-primary hover:bg-primary/90 text-primary-foreground ",
|
||||
secondary:
|
||||
"bg-secondary hover:bg-secondary/80 text-secondary-foreground ",
|
||||
|
||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-lg border border-border bg-card text-card-foreground",
|
||||
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
|
||||
|
||||
@@ -150,9 +150,13 @@ export const ErrorProvider: ComponentWithChildren = ({ children }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button asChild className="py-2 text-sm" variant="primary">
|
||||
<Link href="/support" rel="noopener noreferrer" target="_blank">
|
||||
Visit support site
|
||||
<Button asChild className="py-2 text-sm" variant="default">
|
||||
<Link
|
||||
href="/team/~/~/support"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Contact Support
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { rotateVaultAccountAndAccessToken } from "../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
|
||||
import { accountKeys, authorizedWallets } from "../query-keys/cache-keys";
|
||||
|
||||
// FIXME: We keep repeating types, API server should provide them
|
||||
@@ -311,26 +312,40 @@ export type RotateSecretKeyAPIReturnType = {
|
||||
data: {
|
||||
secret: string;
|
||||
secretMasked: string;
|
||||
secretHash: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function rotateSecretKeyClient(params: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
export async function rotateSecretKeyClient(params: { project: Project }) {
|
||||
const res = await apiServerProxy<RotateSecretKeyAPIReturnType>({
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/rotate-secret-key`,
|
||||
pathname: `/v1/teams/${params.project.teamId}/projects/${params.project.id}/rotate-secret-key`,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
try {
|
||||
// if the project has an encrypted vault admin key, rotate it as well
|
||||
const service = params.project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
if (service?.encryptedAdminKey) {
|
||||
await rotateVaultAccountAndAccessToken({
|
||||
project: params.project,
|
||||
projectSecretKey: res.data.data.secret,
|
||||
projectSecretHash: res.data.data.secretHash,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to rotate vault admin key", error);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,22 +7,34 @@ import { embeddedWalletsKeys } from "../query-keys/cache-keys";
|
||||
const fetchAccountList = ({
|
||||
jwt,
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
pageNumber,
|
||||
}: {
|
||||
jwt: string;
|
||||
clientId: string;
|
||||
clientId?: string;
|
||||
ecosystemSlug?: string;
|
||||
teamId: string;
|
||||
pageNumber: number;
|
||||
}) => {
|
||||
return async () => {
|
||||
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
|
||||
url.searchParams.append("clientId", clientId);
|
||||
|
||||
// Add clientId or ecosystemSlug parameter
|
||||
if (ecosystemSlug) {
|
||||
url.searchParams.append("ecosystemSlug", `ecosystem.${ecosystemSlug}`);
|
||||
} else if (clientId) {
|
||||
url.searchParams.append("clientId", clientId);
|
||||
}
|
||||
|
||||
url.searchParams.append("page", pageNumber.toString());
|
||||
|
||||
const res = await fetch(url.href, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": clientId,
|
||||
"x-thirdweb-team-id": teamId,
|
||||
...(clientId && { "x-client-id": clientId }),
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
@@ -38,23 +50,27 @@ const fetchAccountList = ({
|
||||
};
|
||||
|
||||
export function useEmbeddedWallets(params: {
|
||||
clientId: string;
|
||||
clientId?: string;
|
||||
ecosystemSlug?: string;
|
||||
teamId: string;
|
||||
page: number;
|
||||
authToken: string;
|
||||
}) {
|
||||
const { clientId, page, authToken } = params;
|
||||
const { clientId, ecosystemSlug, teamId, page, authToken } = params;
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
return useQuery({
|
||||
enabled: !!address && !!clientId,
|
||||
enabled: !!address && !!(clientId || ecosystemSlug),
|
||||
queryFn: fetchAccountList({
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
jwt: authToken,
|
||||
pageNumber: page,
|
||||
}),
|
||||
queryKey: embeddedWalletsKeys.embeddedWallets(
|
||||
address || "",
|
||||
clientId,
|
||||
clientId || ecosystemSlug || "",
|
||||
page,
|
||||
),
|
||||
});
|
||||
@@ -67,7 +83,15 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ clientId }: { clientId: string }) => {
|
||||
mutationFn: async ({
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
}: {
|
||||
clientId?: string;
|
||||
ecosystemSlug?: string;
|
||||
teamId: string;
|
||||
}) => {
|
||||
const responses: WalletUser[] = [];
|
||||
let page = 1;
|
||||
|
||||
@@ -77,12 +101,14 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
|
||||
}>({
|
||||
queryFn: fetchAccountList({
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
jwt: authToken,
|
||||
pageNumber: page,
|
||||
}),
|
||||
queryKey: embeddedWalletsKeys.embeddedWallets(
|
||||
address || "",
|
||||
clientId,
|
||||
clientId || ecosystemSlug || "",
|
||||
page,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export function TokenIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="presentation"
|
||||
className={props.className}
|
||||
>
|
||||
<path
|
||||
d="M15.9377 15.9377C19.3603 15.4795 22 12.548 22 9C22 5.13401 18.866 2 15 2C11.452 2 8.52049 4.63967 8.06227 8.06227M16 15C16 18.866 12.866 22 9 22C5.13401 22 2 18.866 2 15C2 11.134 5.13401 8 9 8C12.866 8 16 11.134 16 15Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function WalletProductIcon(props: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="presentation"
|
||||
className={props.className}
|
||||
>
|
||||
<path
|
||||
d="M16 7.99995V4.5006C16 3.66887 16 3.253 15.8248 2.99742C15.6717 2.77413 15.4346 2.62245 15.1678 2.57703C14.8623 2.52505 14.4847 2.69932 13.7295 3.04787L4.85901 7.14194C4.18551 7.45279 3.84875 7.60822 3.60211 7.84927C3.38406 8.06237 3.21762 8.3225 3.1155 8.60978C3 8.93474 3 9.30563 3 10.0474V14.9999M16.5 14.4999H16.51M3 11.2L3 17.7999C3 18.9201 3 19.4801 3.21799 19.9079C3.40973 20.2843 3.71569 20.5902 4.09202 20.782C4.51984 20.9999 5.07989 20.9999 6.2 20.9999H17.8C18.9201 20.9999 19.4802 21 19.908 20.782C20.2843 20.5902 20.5903 20.2843 20.782 19.9079C21 19.4801 21 18.9201 21 17.7999V11.2C21 10.0798 21 9.51979 20.782 9.09197C20.5903 8.71565 20.2843 8.40969 19.908 8.21794C19.4802 7.99995 18.9201 7.99995 17.8 7.99995L6.2 7.99995C5.0799 7.99995 4.51984 7.99995 4.09202 8.21794C3.7157 8.40968 3.40973 8.71564 3.21799 9.09197C3 9.51979 3 10.0798 3 11.2ZM17 14.4999C17 14.7761 16.7761 14.9999 16.5 14.9999C16.2239 14.9999 16 14.7761 16 14.4999C16 14.2238 16.2239 13.9999 16.5 13.9999C16.7761 13.9999 17 14.2238 17 14.4999Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import type { SVGProps } from "react";
|
||||
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
enable-background="new 0 0 100 100"
|
||||
enableBackground="new 0 0 100 100"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user