Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53d0d74d9b | ||
|
|
c0948fb9fe | ||
|
|
9ae2991ad7 | ||
|
|
bab4ff5e14 | ||
|
|
621c1de62e | ||
|
|
8bb2b0a227 | ||
|
|
5f82df1652 | ||
|
|
fd4414b451 | ||
|
|
d10436d3d2 | ||
|
|
56b12c6094 | ||
|
|
abbdb0f113 | ||
|
|
d05dbcb5ac | ||
|
|
3a52f9d486 | ||
|
|
9a3ebe1cb1 | ||
|
|
ad2d22558b | ||
|
|
0aac9a8593 | ||
|
|
fa7a81260d | ||
|
|
e5326c43a9 | ||
|
|
57862d0bd0 | ||
|
|
834bbc0883 | ||
|
|
3a9aa1a72b | ||
|
|
572368edc3 | ||
|
|
9b5ca42091 | ||
|
|
c6849a8734 | ||
|
|
7617cc84fc | ||
|
|
e1ad7daf54 | ||
|
|
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 |
@@ -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)
|
||||
|
||||
|
||||
+20
-54
@@ -1,29 +1,22 @@
|
||||
#
|
||||
# Public (client)
|
||||
#
|
||||
|
||||
# API authorized domain
|
||||
# not required to build, defaults to prod
|
||||
NEXT_PUBLIC_THIRDWEB_DOMAIN="localhost:3000"
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
# API host. For local development, please use "https://api.thirdweb-dev.com"
|
||||
# otherwise: "https://api.thirdweb.com"
|
||||
# local host - http://127.0.0.1:3005
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST="https://api.thirdweb-dev.com"
|
||||
|
||||
# Bridge API. For local development, please use "https://bridge.thirdweb-dev.com"
|
||||
# otherwise: "https://bridge.thirdweb.com"
|
||||
NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST="https://bridge.thirdweb-dev.com"
|
||||
|
||||
# Paper API host
|
||||
# in-app wallet host
|
||||
NEXT_PUBLIC_THIRDWEB_EWS_API_HOST="https://ews.thirdweb-dev.com"
|
||||
|
||||
# thirdweb local api host
|
||||
# NEXT_PUBLIC_THIRDWEB_API_HOST="http://127.0.0.1:3005"
|
||||
|
||||
# Client ID
|
||||
# Get the client id from https://thirdweb.com/create-api-key and set it here,
|
||||
# Client ID - Required
|
||||
# Get the client id from https://thirdweb.com/create-api-key and set it here, and also set DASHBOARD_SECRET_KEY below
|
||||
# make sure to allow localhost:3000 in the allowed origins
|
||||
NEXT_PUBLIC_DASHBOARD_CLIENT_ID="<replace_me_with_your_client_id>"
|
||||
NEXT_PUBLIC_DASHBOARD_CLIENT_ID=""
|
||||
|
||||
# IPFS gateway url
|
||||
NEXT_PUBLIC_IPFS_GATEWAY_URL="https://{clientId}.thirdwebstorage-dev.com/ipfs/{cid}/{path}"
|
||||
@@ -39,62 +32,35 @@ NEXT_PUBLIC_TYPESENSE_CONTRACT_API_KEY=
|
||||
# - not required for prod/staging
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
|
||||
# Stripe Customer portal
|
||||
NEXT_PUBLIC_STRIPE_KEY=
|
||||
# required for server wallet management
|
||||
NEXT_PUBLIC_THIRDWEB_VAULT_URL=""
|
||||
NEXT_PUBLIC_ENGINE_CLOUD_URL=""
|
||||
|
||||
|
||||
# Demo Engine - required for showing demo engine page
|
||||
NEXT_PUBLIC_DEMO_ENGINE_URL=""
|
||||
|
||||
NEXT_PUBLIC_STRIPE_PAYMENT_METHOD_CFG_ID=
|
||||
|
||||
#
|
||||
# Private (server)
|
||||
#
|
||||
# Get the secret key from https://thirdweb.com/create-api-key and set it here, Make sure to allow localhost:3000 in the allowed origins
|
||||
# ------------------------------------------------------------------------
|
||||
|
||||
# Get the secret key from https://thirdweb.com/create-api-key and set it here and also set NEXT_PUBLIC_DASHBOARD_CLIENT_ID above,
|
||||
# Make sure to allow localhost:3000 in the allowed origins
|
||||
DASHBOARD_SECRET_KEY="<replace_me_with_your_secret_key>"
|
||||
|
||||
# Client id for api routes
|
||||
# Client id for api routes - required for contract OG image generation
|
||||
API_ROUTES_CLIENT_ID=
|
||||
|
||||
# <moralis.io API key (used for api routes for token balances and wallet NFTs)
|
||||
# - not required to build (unless using wallet NFTs and token balances)>
|
||||
MORALIS_API_KEY=
|
||||
|
||||
# alchemy.com API key (used for wallet NFTS)
|
||||
# - cannot be restricted to IP/domain because vercel has no stable IPs and it happens during build & runtime api route call
|
||||
# - not required to build (unless testing wallet NFTs)>
|
||||
SSR_ALCHEMY_KEY=
|
||||
|
||||
|
||||
# Hubspot Access Token (used for contact us form)
|
||||
# - not required to build (unless testing contact us form)>
|
||||
HUBSPOT_ACCESS_TOKEN=
|
||||
|
||||
# Upload server url
|
||||
NEXT_PUBLIC_DASHBOARD_UPLOAD_SERVER="https://storage.thirdweb-preview.com"
|
||||
|
||||
# Unthread variables - only required for submitting the support form in /support
|
||||
UNTHREAD_API_KEY=""
|
||||
UNTHREAD_TRIAGE_CHANNEL_ID=""
|
||||
UNTHREAD_EMAIL_INBOX_ID=""
|
||||
UNTHREAD_FREE_TIER_ID=""
|
||||
UNTHREAD_GROWTH_TIER_ID=""
|
||||
UNTHREAD_PRO_TIER_ID=""
|
||||
|
||||
# Demo Engine
|
||||
NEXT_PUBLIC_DEMO_ENGINE_URL=""
|
||||
|
||||
# API server secret (required for thirdweb.com SIWE login). Copy from Vercel.
|
||||
API_SERVER_SECRET=""
|
||||
|
||||
# Used for the Faucet page (/<chain_id>)
|
||||
# Used in faucet on chain page (/<chain-id>) and login page (/login) - turnstile is disabled in localhost development
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
|
||||
TURNSTILE_SECRET_KEY=""
|
||||
|
||||
REDIS_URL=""
|
||||
|
||||
ANALYTICS_SERVICE_URL=""
|
||||
|
||||
|
||||
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
|
||||
STRIPE_SECRET_KEY=""
|
||||
|
||||
# required for server wallet management
|
||||
NEXT_PUBLIC_THIRDWEB_VAULT_URL=""
|
||||
NEXT_PUBLIC_ENGINE_CLOUD_URL=""
|
||||
@@ -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:
|
||||
@@ -157,6 +96,12 @@ module.exports = {
|
||||
name: "posthog-js",
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
group: ["**/../@/**"],
|
||||
message: "Use absolute imports instead. Example: '@/foo/bar..'",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"no-restricted-syntax": [
|
||||
|
||||
@@ -20,11 +20,6 @@ const config: StorybookConfig = {
|
||||
name: getAbsolutePath("@storybook/nextjs"),
|
||||
options: {},
|
||||
},
|
||||
refs: {
|
||||
"@chakra-ui/react": {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
staticDirs: ["../public"],
|
||||
features: {
|
||||
experimentalRSC: true,
|
||||
|
||||
@@ -9,10 +9,10 @@ const ContentSecurityPolicy = `
|
||||
img-src * data: blob:;
|
||||
media-src * data: blob:;
|
||||
object-src 'none';
|
||||
style-src 'self' 'unsafe-inline' vercel.live;
|
||||
style-src 'self' 'unsafe-inline' vercel.live us.posthog.com;
|
||||
font-src 'self' vercel.live assets.vercel.com framerusercontent.com fonts.gstatic.com;
|
||||
frame-src * data:;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com googletagmanager.com us-assets.i.posthog.com edit.framer.com;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com googletagmanager.com us-assets.i.posthog.com edit.framer.com framer.com googletagmanager.com;
|
||||
connect-src * data: blob:;
|
||||
worker-src 'self' blob:;
|
||||
block-all-mixed-content;
|
||||
@@ -115,9 +115,6 @@ const SENTRY_OPTIONS: SentryBuildOptions = {
|
||||
const FRAMER_ADDITIONAL_LANGUAGES = ["es"];
|
||||
|
||||
const baseNextConfig: NextConfig = {
|
||||
compiler: {
|
||||
emotion: true,
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@chakra-ui/styled-system": "^2.9.2",
|
||||
"@chakra-ui/theme-tools": "^2.1.2",
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/styled": "11.14.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
@@ -34,14 +29,11 @@
|
||||
"@vercel/functions": "2.2.2",
|
||||
"@vercel/og": "^0.6.8",
|
||||
"abitype": "1.0.8",
|
||||
"chakra-react-select": "^4.7.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"color": "5.0.0",
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"framer-motion": "12.23.0",
|
||||
"fuse.js": "7.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.6.1",
|
||||
@@ -57,7 +49,6 @@
|
||||
"posthog-js": "1.256.1",
|
||||
"posthog-node": "^5.4.0",
|
||||
"prettier": "3.6.2",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
@@ -86,7 +77,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "4.0.1",
|
||||
"@next/bundle-analyzer": "15.3.5",
|
||||
"@next/eslint-plugin-next": "15.3.5",
|
||||
@@ -99,7 +89,6 @@
|
||||
"@types/node": "22.14.1",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/react-table": "^7.7.20",
|
||||
@@ -129,12 +118,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",
|
||||
|
||||
@@ -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: "/",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { getRawAccount } from "../../app/(app)/account/settings/getAccount";
|
||||
import { getRawAccount } from "@/api/account/get-account";
|
||||
|
||||
export async function getRawAccountAction() {
|
||||
return getRawAccount();
|
||||
@@ -1,89 +0,0 @@
|
||||
"use server";
|
||||
import { isAddress, toTokens, ZERO_ADDRESS } from "thirdweb";
|
||||
import { getWalletBalance } from "thirdweb/wallets";
|
||||
import { MORALIS_API_KEY } from "@/constants/server-envs";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { defineDashboardChain } from "@/lib/defineDashboardChain";
|
||||
|
||||
type BalanceQueryResponse = Array<{
|
||||
balance: string;
|
||||
decimals: number;
|
||||
name?: string;
|
||||
symbol: string;
|
||||
token_address: string;
|
||||
display_balance: string;
|
||||
}>;
|
||||
|
||||
export async function getTokenBalancesFromMoralis(params: {
|
||||
contractAddress: string;
|
||||
chainId: number;
|
||||
}): Promise<
|
||||
| { data: BalanceQueryResponse; error: undefined }
|
||||
| {
|
||||
data: undefined;
|
||||
error: string;
|
||||
}
|
||||
> {
|
||||
const { contractAddress, chainId } = params;
|
||||
|
||||
if (!isAddress(contractAddress)) {
|
||||
return {
|
||||
data: undefined,
|
||||
error: "invalid address",
|
||||
};
|
||||
}
|
||||
|
||||
const getNativeBalance = async (): Promise<BalanceQueryResponse> => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineDashboardChain(chainId, undefined);
|
||||
const balance = await getWalletBalance({
|
||||
address: contractAddress,
|
||||
chain,
|
||||
client: serverThirdwebClient,
|
||||
});
|
||||
return [
|
||||
{
|
||||
balance: balance.value.toString(),
|
||||
decimals: balance.decimals,
|
||||
display_balance: toTokens(balance.value, balance.decimals),
|
||||
name: "Native Token",
|
||||
symbol: balance.symbol,
|
||||
token_address: ZERO_ADDRESS,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getTokenBalances = async (): Promise<BalanceQueryResponse> => {
|
||||
const _chain = encodeURIComponent(`0x${chainId?.toString(16)}`);
|
||||
const _address = encodeURIComponent(contractAddress);
|
||||
const tokenBalanceEndpoint = `https://deep-index.moralis.io/api/v2/${_address}/erc20?chain=${_chain}`;
|
||||
|
||||
const resp = await fetch(tokenBalanceEndpoint, {
|
||||
headers: {
|
||||
"x-api-key": MORALIS_API_KEY,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
resp.body?.cancel();
|
||||
return [];
|
||||
}
|
||||
const json = await resp.json();
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
return json.map((balance: any) => ({
|
||||
...balance,
|
||||
display_balance: toTokens(BigInt(balance.balance), balance.decimals),
|
||||
}));
|
||||
};
|
||||
|
||||
const [nativeBalance, tokenBalances] = await Promise.all([
|
||||
getNativeBalance(),
|
||||
getTokenBalances(),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: [...nativeBalance, ...tokenBalances],
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
|
||||
import { MORALIS_API_KEY } from "@/constants/server-envs";
|
||||
import {
|
||||
generateAlchemyUrl,
|
||||
transformAlchemyResponseToNFT,
|
||||
} from "@/lib/wallet/nfts/alchemy";
|
||||
import {
|
||||
generateMoralisUrl,
|
||||
transformMoralisResponseToNFT,
|
||||
} from "@/lib/wallet/nfts/moralis";
|
||||
import type { WalletNFT } from "@/lib/wallet/nfts/types";
|
||||
import { getVercelEnv } from "@/utils/vercel";
|
||||
import { isAlchemySupported } from "../lib/wallet/nfts/isAlchemySupported";
|
||||
import { isMoralisSupported } from "../lib/wallet/nfts/isMoralisSupported";
|
||||
|
||||
type WalletNFTApiReturn =
|
||||
| { result: WalletNFT[]; error?: undefined }
|
||||
| { result?: undefined; error: string };
|
||||
|
||||
export async function getWalletNFTs(params: {
|
||||
chainId: number;
|
||||
owner: string;
|
||||
isInsightSupported: boolean;
|
||||
}): Promise<WalletNFTApiReturn> {
|
||||
const { chainId, owner } = params;
|
||||
|
||||
if (params.isInsightSupported) {
|
||||
const response = await getWalletNFTsFromInsight({ chainId, owner });
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: response.error,
|
||||
};
|
||||
}
|
||||
|
||||
return { result: response.data };
|
||||
}
|
||||
|
||||
if (isAlchemySupported(chainId)) {
|
||||
const url = generateAlchemyUrl({ chainId, owner });
|
||||
|
||||
const response = await fetch(url, {
|
||||
next: {
|
||||
revalidate: 10, // cache for 10 seconds
|
||||
},
|
||||
});
|
||||
if (response.status >= 400) {
|
||||
return { error: response.statusText };
|
||||
}
|
||||
try {
|
||||
const parsedResponse = await response.json();
|
||||
const result = await transformAlchemyResponseToNFT(parsedResponse, owner);
|
||||
|
||||
return { error: undefined, result };
|
||||
} catch (err) {
|
||||
console.error("Error fetching NFTs", err);
|
||||
return { error: "error parsing response" };
|
||||
}
|
||||
}
|
||||
|
||||
if (isMoralisSupported(chainId) && MORALIS_API_KEY) {
|
||||
const url = generateMoralisUrl({ chainId, owner });
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"X-API-Key": MORALIS_API_KEY,
|
||||
},
|
||||
method: "GET",
|
||||
next: {
|
||||
revalidate: 10, // cache for 10 seconds
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
return { error: response.statusText };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedResponse = await response.json();
|
||||
const result = await transformMoralisResponseToNFT(
|
||||
await parsedResponse,
|
||||
owner,
|
||||
chainId,
|
||||
);
|
||||
|
||||
return { result };
|
||||
} catch (err) {
|
||||
console.error("Error fetching NFTs", err);
|
||||
return { error: "error parsing response" };
|
||||
}
|
||||
}
|
||||
|
||||
return { error: "unsupported chain" };
|
||||
}
|
||||
|
||||
type OwnedNFTInsightResponse = {
|
||||
name: string;
|
||||
description: string;
|
||||
image_url: string;
|
||||
background_color: string;
|
||||
external_url: string;
|
||||
metadata_url: string;
|
||||
extra_metadata: {
|
||||
customImage?: string;
|
||||
customAnimationUrl?: string;
|
||||
animation_original_url?: string;
|
||||
image_original_url?: string;
|
||||
};
|
||||
collection: {
|
||||
name: string;
|
||||
description: string;
|
||||
extra_metadata: Record<string, string>;
|
||||
};
|
||||
contract: {
|
||||
chain_id: number;
|
||||
address: string;
|
||||
type: "erc1155" | "erc721";
|
||||
name: string;
|
||||
};
|
||||
owner_addresses: string[];
|
||||
token_id: string;
|
||||
balance: string;
|
||||
token_type: "erc1155" | "erc721";
|
||||
};
|
||||
|
||||
async function getWalletNFTsFromInsight(params: {
|
||||
chainId: number;
|
||||
owner: string;
|
||||
}): Promise<
|
||||
| {
|
||||
data: WalletNFT[];
|
||||
ok: true;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
> {
|
||||
const { chainId, owner } = params;
|
||||
|
||||
const thirdwebDomain =
|
||||
getVercelEnv() === "production" ? "thirdweb" : "thirdweb-dev";
|
||||
const url = new URL(`https://insight.${thirdwebDomain}.com/v1/nfts`);
|
||||
url.searchParams.append("chain", chainId.toString());
|
||||
url.searchParams.append("limit", "10");
|
||||
url.searchParams.append("owner_address", owner);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"x-client-id": NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = await response.text();
|
||||
return {
|
||||
error: errorMessage,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
const nftsResponse = (await response.json()) as {
|
||||
data: OwnedNFTInsightResponse[];
|
||||
};
|
||||
|
||||
const isDev = getVercelEnv() !== "production";
|
||||
|
||||
// NOTE: ipfscdn.io/ to thirdwebstorage-dev.com/ replacement is temporary
|
||||
// This should be fixed in the insight dev endpoint
|
||||
|
||||
const walletNFTs = nftsResponse.data.map((nft) => {
|
||||
const walletNFT: WalletNFT = {
|
||||
chainId: nft.contract.chain_id,
|
||||
contractAddress: nft.contract.address,
|
||||
id: nft.token_id,
|
||||
metadata: {
|
||||
animation_url: isDev
|
||||
? nft.extra_metadata.animation_original_url?.replace(
|
||||
"ipfscdn.io/",
|
||||
"thirdwebstorage-dev.com/",
|
||||
)
|
||||
: nft.extra_metadata.animation_original_url,
|
||||
background_color: nft.background_color,
|
||||
description: nft.description,
|
||||
external_url: nft.external_url,
|
||||
image: isDev
|
||||
? nft.image_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
|
||||
: nft.image_url,
|
||||
name: nft.name,
|
||||
uri: isDev
|
||||
? nft.metadata_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
|
||||
: nft.metadata_url,
|
||||
},
|
||||
owner: params.owner,
|
||||
supply: nft.balance,
|
||||
tokenAddress: nft.contract.address,
|
||||
tokenURI: nft.metadata_url,
|
||||
type: nft.token_type === "erc721" ? "ERC721" : "ERC1155",
|
||||
};
|
||||
|
||||
return walletNFT;
|
||||
});
|
||||
|
||||
return {
|
||||
data: walletNFTs,
|
||||
ok: true,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import Stripe from "stripe";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { STRIPE_SECRET_KEY } from "@/constants/server-envs";
|
||||
|
||||
let existingStripe: Stripe | undefined;
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import "server-only";
|
||||
import { randomBytes } from "crypto";
|
||||
import { format } from "date-fns";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
|
||||
export async function createTeam(options?: { name?: string; slug?: string }) {
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import type { ProductSKU } from "../types/billing";
|
||||
|
||||
// ----------------------------
|
||||
@@ -269,22 +270,6 @@ export function reportAssetBuyFailed(properties: {
|
||||
|
||||
// Assets Landing Page ----------------------------
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of asset creation started from the assets page
|
||||
* - To track which asset types are being created the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetCreationStarted(properties: {
|
||||
assetType: "nft" | "coin";
|
||||
}) {
|
||||
posthog.capture("asset creation started", {
|
||||
assetType: properties.assetType,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of assets imported successfully from the assets page
|
||||
@@ -413,3 +398,108 @@ 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 create a funnel "create payment link pageview" -> "payment link created" to understand the conversion rate
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @greg
|
||||
*/
|
||||
export function reportPaymentLinkCreated(properties: {
|
||||
linkId: string;
|
||||
clientId: string;
|
||||
}) {
|
||||
posthog.capture("payment link created", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track funnel "payment link pageview" -> "payment link buy successful" to understand the conversion rate
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @greg
|
||||
*/
|
||||
export function reportPaymentLinkBuySuccessful() {
|
||||
posthog.capture("payment link buy successful");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of failed payment link buys
|
||||
* - To track what errors users encounter when trying to buy from a payment link
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @greg
|
||||
*/
|
||||
export function reportPaymentLinkBuyFailed(properties: {
|
||||
errorMessage: string;
|
||||
}) {
|
||||
posthog.capture("payment link buy failed", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To create a funnel for "asset pageview" -> "asset purchase successful" to understand the conversion rate
|
||||
* - To understand which asset types are being viewed the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetPageview(properties: {
|
||||
assetType: "nft" | "coin";
|
||||
chainId: number;
|
||||
}) {
|
||||
posthog.capture("asset pageview", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the usage of fund wallet modal
|
||||
* - To create a funnel "fund wallet modal opened" -> "fund wallet buy successful" to understand the conversion rate
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportFundWalletOpened() {
|
||||
posthog.capture("fund wallet opened");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of successful fund wallet buys
|
||||
* - To create a funnel "fund wallet modal opened" -> "fund wallet buy successful" to understand the conversion rate
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportFundWalletSuccessful() {
|
||||
posthog.capture("fund wallet successful");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of failed fund wallet buys
|
||||
* - To track the errors that users encounter when trying to buy from a fund wallet modal
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportFundWalletFailed(params: { errorMessage: string }) {
|
||||
posthog.capture("fund wallet failed", params);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { Account } from "@/hooks/useApi";
|
||||
import { isAccountOnboardingComplete } from "@/utils/account-onboarding";
|
||||
import { loginRedirect } from "@/utils/redirects";
|
||||
import { isAccountOnboardingComplete } from "../../../login/onboarding/isOnboardingRequired";
|
||||
|
||||
/**
|
||||
* Just get the account object without enforcing onboarding.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type LinkedWallet = {
|
||||
createdAt: string;
|
||||
@@ -1,6 +1,9 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { LAST_USED_TEAM_ID } from "@/constants/cookies";
|
||||
import {
|
||||
COOKIE_ACTIVE_ACCOUNT,
|
||||
COOKIE_PREFIX_TOKEN,
|
||||
LAST_USED_TEAM_ID,
|
||||
} from "@/constants/cookie";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
|
||||
export async function getAuthToken() {
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { fetchDeployMetadata as sdkFetchDeployMetadata } from "thirdweb/contract";
|
||||
import { removeUndefinedFromObjectDeep } from "@/utils/object";
|
||||
import type { ContractId } from "./types";
|
||||
import type { ContractId } from "../../components/contract-components/types";
|
||||
|
||||
// metadata PRE publish, only has the compiler output info (from CLI)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
|
||||
export async function verifyContract(contract: ThirdwebContract) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://contract.thirdweb.com/verify/contract",
|
||||
{
|
||||
body: JSON.stringify({
|
||||
chainId: contract.chain.id,
|
||||
contractAddress: contract.address,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Error verifying contract: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`Error verifying contract: ${error}`);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
getProjectContracts,
|
||||
type ProjectContract,
|
||||
} from "@/api/getProjectContracts";
|
||||
} from "@/api/project/getProjectContracts";
|
||||
import { fetchChainWithLocalOverrides } from "@/utils/fetchChainWithLocalOverrides";
|
||||
|
||||
export async function getSortedDeployedContracts(params: {
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
import type { ProjectResponse } from "@thirdweb-dev/service-utils";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type Project = ProjectResponse;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type AuditLogEntry = {
|
||||
who: {
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export async function createDedicatedSupportChannel(
|
||||
teamIdOrSlug: string,
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type AuthOption =
|
||||
| "email"
|
||||
@@ -1,11 +1,11 @@
|
||||
import "server-only";
|
||||
import type { TeamResponse } from "@thirdweb-dev/service-utils";
|
||||
import { cookies } from "next/headers";
|
||||
import { LAST_USED_TEAM_ID } from "@/constants/cookies";
|
||||
import { getValidAccount } from "@/api/account/get-account";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { LAST_USED_TEAM_ID } from "@/constants/cookie";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import { getValidAccount } from "../../app/(app)/account/settings/getAccount";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
import { getMemberByAccountId } from "./team-members";
|
||||
|
||||
export type Team = TeamResponse & { stripeCustomerId: string | null };
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type TeamInvite = {
|
||||
id: string;
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
const TeamAccountRole = {
|
||||
MEMBER: "MEMBER",
|
||||
+1
-1
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type VerifiedDomainResponse =
|
||||
| {
|
||||
@@ -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,143 @@ 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);
|
||||
}
|
||||
|
||||
const response = (await res.json()) as {
|
||||
data: PaymentLink;
|
||||
};
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
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 +333,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(), {
|
||||
|
||||
@@ -18,6 +18,7 @@ export function DateRangeSelector(props: {
|
||||
}) {
|
||||
const { range, setRange } = props;
|
||||
const daysDiff = differenceInCalendarDays(range.to, range.from);
|
||||
|
||||
const matchingRange =
|
||||
normalizeTime(range.to).getTime() === normalizeTime(new Date()).getTime()
|
||||
? durationPresets.find((preset) => preset.days === daysDiff)
|
||||
@@ -85,7 +86,7 @@ export function getLastNDaysRange(id: DurationId) {
|
||||
throw new Error(`Invalid duration id: ${id}`);
|
||||
}
|
||||
|
||||
const todayDate = new Date(Date.now() + 1000 * 60 * 60 * 24); // add 1 day to avoid timezone issues
|
||||
const todayDate = new Date(Date.now());
|
||||
|
||||
const value: Range = {
|
||||
from: subDays(todayDate, durationInfo.days),
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
DurationId,
|
||||
Range,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import {
|
||||
DateRangeSelector,
|
||||
getLastNDaysRange,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "@/components/analytics/interval-selector";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
export function RangeSelector({
|
||||
range,
|
||||
interval,
|
||||
}: {
|
||||
range?: Range;
|
||||
interval: "day" | "week";
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
const [localRange, setRange] = useState<Range>(
|
||||
range || getLastNDaysRange("last-120"),
|
||||
);
|
||||
const [localInterval, setInterval] = useState<"day" | "week">(interval);
|
||||
|
||||
useQuery({
|
||||
queryFn: async () => {
|
||||
if (range) {
|
||||
setRange(range);
|
||||
return range;
|
||||
}
|
||||
if (searchParams) {
|
||||
const fromStringified = searchParams.get("from");
|
||||
const from = fromStringified
|
||||
? new Date(fromStringified)
|
||||
: getLastNDaysRange("last-120").from;
|
||||
const toStringified = searchParams.get("to");
|
||||
const to = toStringified ? new Date(toStringified) : new Date();
|
||||
const type = (searchParams.get("type") as DurationId) || "last-120";
|
||||
setRange({ from, to, type });
|
||||
setInterval(interval);
|
||||
return { from, to, type } satisfies Range;
|
||||
}
|
||||
return getLastNDaysRange("last-120");
|
||||
},
|
||||
queryKey: ["analytics-range", searchParams?.toString(), range],
|
||||
});
|
||||
|
||||
// prefetch for each interval and default range
|
||||
useQuery({
|
||||
queryFn: async () => {
|
||||
const newSearchParams = new URLSearchParams(searchParams || {});
|
||||
for (const interval of ["day", "week"] as const) {
|
||||
newSearchParams.set("interval", interval);
|
||||
for (const type of [
|
||||
"last-120",
|
||||
"last-60",
|
||||
"last-30",
|
||||
"last-7",
|
||||
] as const) {
|
||||
const newRange = getLastNDaysRange(type);
|
||||
newSearchParams.set("from", newRange.from.toISOString());
|
||||
newSearchParams.set("to", newRange.to.toISOString());
|
||||
newSearchParams.set("type", newRange.type);
|
||||
router.prefetch(`${pathname}?${newSearchParams.toString()}`);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
queryKey: ["analytics-range", searchParams?.toString()],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-3 flex-col lg:flex-row">
|
||||
<DateRangeSelector
|
||||
className="rounded-full"
|
||||
range={localRange}
|
||||
setRange={(newRange) => {
|
||||
setRange(newRange);
|
||||
const newSearchParams = new URLSearchParams(searchParams || {});
|
||||
newSearchParams.set("from", newRange.from.toISOString());
|
||||
newSearchParams.set("to", newRange.to.toISOString());
|
||||
newSearchParams.set("type", newRange.type);
|
||||
router.push(`${pathname}?${newSearchParams.toString()}`);
|
||||
}}
|
||||
/>
|
||||
<IntervalSelector
|
||||
className="bg-card rounded-full"
|
||||
intervalType={localInterval}
|
||||
setIntervalType={(newInterval) => {
|
||||
setInterval(newInterval);
|
||||
const newSearchParams = new URLSearchParams(searchParams || {});
|
||||
newSearchParams.set("interval", newInterval);
|
||||
router.push(`${pathname}?${newSearchParams.toString()}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useResponsiveSearchParams,
|
||||
useSetResponsiveSearchParams,
|
||||
} from "responsive-rsc";
|
||||
import {
|
||||
DateRangeSelector,
|
||||
type DurationId,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "@/components/analytics/interval-selector";
|
||||
import { getFiltersFromSearchParams, normalizeTimeISOString } from "@/lib/time";
|
||||
|
||||
export function ResponsiveTimeFilters(props: { defaultRange: DurationId }) {
|
||||
const responsiveSearchParams = useResponsiveSearchParams();
|
||||
const setResponsiveSearchParams = useSetResponsiveSearchParams();
|
||||
const { range, interval } = getFiltersFromSearchParams({
|
||||
defaultRange: props.defaultRange,
|
||||
from: responsiveSearchParams.from,
|
||||
interval: responsiveSearchParams.interval,
|
||||
to: responsiveSearchParams.to,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-3 flex-col lg:flex-row">
|
||||
<DateRangeSelector
|
||||
className="rounded-full"
|
||||
range={range}
|
||||
setRange={(newRange) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
const newParams = {
|
||||
...v,
|
||||
from: normalizeTimeISOString(newRange.from),
|
||||
to: normalizeTimeISOString(newRange.to),
|
||||
};
|
||||
return newParams;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<IntervalSelector
|
||||
className="bg-card rounded-full"
|
||||
intervalType={interval}
|
||||
setIntervalType={(newInterval) => {
|
||||
setResponsiveSearchParams((v) => {
|
||||
const newParams = {
|
||||
...v,
|
||||
interval: newInterval,
|
||||
};
|
||||
return newParams;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
"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 { useForm } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { CreateDelayedRevealBatchParams } from "thirdweb/extensions/erc721";
|
||||
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 { processInputData, shuffleData } from "@/utils/batch";
|
||||
import { BatchTable } from "./batch-table";
|
||||
import { SelectOption } from "./lazy-mint-form/select-option";
|
||||
import { UploadStep } from "./upload-step";
|
||||
|
||||
type DelayedSubmit = {
|
||||
revealType: "delayed";
|
||||
data: CreateDelayedRevealBatchParams;
|
||||
};
|
||||
type InstantSubmit = {
|
||||
revealType: "instant";
|
||||
data: { metadatas: NFTInput[] };
|
||||
};
|
||||
|
||||
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
|
||||
.string()
|
||||
.min(1, "Please confirm your password.")
|
||||
.optional(),
|
||||
|
||||
// metadata
|
||||
metadatas: z.array(z.any()),
|
||||
// delayed reveal password logic
|
||||
password: z.string().min(1, "A password is required.").optional(),
|
||||
// delayed reveal placeholder
|
||||
placeHolder: z
|
||||
.object({
|
||||
description: z.string().or(z.string().length(0)).optional(),
|
||||
image: z.any().optional(),
|
||||
name: z.string().min(1, "A name is required"),
|
||||
})
|
||||
.optional(),
|
||||
revealType: z.literal("instant").or(z.literal("delayed")).optional(),
|
||||
|
||||
// shared logic
|
||||
shuffle: z.boolean().default(false),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
type BatchLazyMintFormType = z.output<typeof BatchLazyMintFormSchema> & {
|
||||
metadatas: NFTInput[];
|
||||
};
|
||||
|
||||
function useBatchLazyMintForm() {
|
||||
return useForm<BatchLazyMintFormType>({
|
||||
defaultValues: {
|
||||
metadatas: [],
|
||||
revealType: undefined,
|
||||
shuffle: false,
|
||||
},
|
||||
resolver: zodResolver(BatchLazyMintFormSchema),
|
||||
});
|
||||
}
|
||||
|
||||
export const BatchLazyMint: ComponentWithChildren<
|
||||
BatchLazyMintProps & {
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
> = (props) => {
|
||||
const [step, setStep] = useState(0);
|
||||
|
||||
const form = useBatchLazyMintForm();
|
||||
|
||||
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;
|
||||
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
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>
|
||||
</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">
|
||||
<Button
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setStep(0)}
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5 cursor-pointer" />
|
||||
</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 {
|
||||
form: ReturnType<typeof useBatchLazyMintForm>;
|
||||
canCreateDelayedRevealBatch: boolean;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
const SelectReveal: React.FC<SelectRevealProps> = ({
|
||||
form,
|
||||
client,
|
||||
canCreateDelayedRevealBatch,
|
||||
}) => {
|
||||
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>
|
||||
|
||||
<Flex
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
gap={{ base: 4, md: 0 }}
|
||||
>
|
||||
<FormControl
|
||||
isInvalid={
|
||||
!!form.getFieldState("password", form.formState).error
|
||||
}
|
||||
isRequired
|
||||
mr={4}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,237 +0,0 @@
|
||||
/** 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 type { ThirdwebClient } from "thirdweb";
|
||||
import type { NFTInput } from "thirdweb/utils";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
|
||||
interface BatchTableProps {
|
||||
data: NFTInput[];
|
||||
portalRef: React.RefObject<HTMLDivElement | null>;
|
||||
nextTokenIdToMint?: bigint;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const BatchTable: React.FC<BatchTableProps> = ({
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
prepareRow,
|
||||
// Instead of using 'rows', we'll use page,
|
||||
page,
|
||||
// which has only the rows for the active page
|
||||
|
||||
// The rest of these things are super handy, too ;)
|
||||
canPreviousPage,
|
||||
canNextPage,
|
||||
pageOptions,
|
||||
pageCount,
|
||||
gotoPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
setPageSize,
|
||||
state: { pageIndex, pageSize },
|
||||
} = useTable(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
initialState: {
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
},
|
||||
},
|
||||
// will be fixed with @tanstack/react-table v8
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
usePagination,
|
||||
);
|
||||
|
||||
// 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);
|
||||
return (
|
||||
<Tr
|
||||
{...row.getRowProps()}
|
||||
_last={{ borderBottomWidth: 0 }}
|
||||
borderBottomWidth={1}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={rowIndex}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</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>
|
||||
</div>
|
||||
</Portal>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,137 +0,0 @@
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import { UnorderedList } from "@/components/ui/List/List";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UploadStepProps {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
getRootProps: any;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
getInputProps: any;
|
||||
hasFailed: boolean;
|
||||
isDragActive: boolean;
|
||||
}
|
||||
|
||||
export const UploadStep: React.FC<UploadStepProps> = ({
|
||||
getRootProps,
|
||||
hasFailed,
|
||||
isDragActive,
|
||||
getInputProps,
|
||||
}) => {
|
||||
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",
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProductSKU } from "@/types/billing";
|
||||
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
import { CheckIcon, DollarSignIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { CheckoutButton } from "@/components/billing/billing";
|
||||
import { RenewSubscriptionButton } from "@/components/billing/renew-subscription/renew-subscription-button";
|
||||
import { RenewSubscriptionButton } from "@/components/billing/renew-subscription-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
+3
-3
@@ -5,12 +5,12 @@ import { RefreshCwIcon } from "lucide-react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { reSubscribePlan } from "@/actions/billing";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { pollWithTimeout } from "../../../utils/pollWithTimeout";
|
||||
import { tryCatch } from "../../../utils/try-catch";
|
||||
import { pollWithTimeout } from "@/utils/pollWithTimeout";
|
||||
import { tryCatch } from "@/utils/try-catch";
|
||||
|
||||
export function RenewSubscriptionButton(props: {
|
||||
teamId: string;
|
||||
@@ -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" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -9,11 +9,12 @@ import type { TokenMetadata } from "@/api/universal-bridge/tokens";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { SelectWithSearch } from "@/components/blocks/select-with-search";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { fallbackChainIcon } from "@/constants/chain";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { useTokensData } from "@/hooks/tokens";
|
||||
import { replaceIpfsUrl } from "@/lib/sdk";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { fallbackChainIcon } from "@/utils/chain-icons";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { Spinner } from "../ui/Spinner/Spinner";
|
||||
|
||||
type Option = { label: string; value: string };
|
||||
|
||||
@@ -121,7 +122,10 @@ export function TokenSelector(props: {
|
||||
return option.label;
|
||||
}
|
||||
const resolvedSrc = token.iconUri
|
||||
? replaceIpfsUrl(token.iconUri, props.client)
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: token.iconUri,
|
||||
})
|
||||
: fallbackChainIcon;
|
||||
|
||||
return (
|
||||
@@ -186,9 +190,14 @@ export function TokenSelector(props: {
|
||||
options={options}
|
||||
overrideSearchFn={searchFn}
|
||||
placeholder={
|
||||
tokensQuery.isPending
|
||||
? "Loading Tokens"
|
||||
: props.placeholder || "Select Token"
|
||||
tokensQuery.isPending ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner className="size-4" />
|
||||
<span>Loading tokens</span>
|
||||
</div>
|
||||
) : (
|
||||
props.placeholder || "Select Token"
|
||||
)
|
||||
}
|
||||
popoverContentClassName={props.popoverContentClassName}
|
||||
renderOption={renderOption}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type React from "react";
|
||||
import { type Dispatch, type SetStateAction, useMemo } from "react";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type CodeEnvironment =
|
||||
| "javascript"
|
||||
@@ -50,6 +51,7 @@ interface CodeSegmentProps {
|
||||
isInstallCommand?: boolean;
|
||||
hideTabs?: boolean;
|
||||
onlyTabs?: boolean;
|
||||
codeContainerClassName?: string;
|
||||
}
|
||||
|
||||
export const CodeSegment: React.FC<CodeSegmentProps> = ({
|
||||
@@ -59,6 +61,7 @@ export const CodeSegment: React.FC<CodeSegmentProps> = ({
|
||||
isInstallCommand,
|
||||
hideTabs,
|
||||
onlyTabs,
|
||||
codeContainerClassName,
|
||||
}) => {
|
||||
const activeEnvironment: CodeEnvironment = useMemo(() => {
|
||||
return (
|
||||
@@ -86,13 +89,12 @@ 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 && (
|
||||
<TabButtons
|
||||
hideBottomLine={!!onlyTabs}
|
||||
tabClassName="text-sm gap-2 !text-sm"
|
||||
tabContainerClassName="px-3 pt-1.5 gap-0.5"
|
||||
tabIconClassName="size-4"
|
||||
tabs={environments.map((env) => ({
|
||||
@@ -106,7 +108,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}
|
||||
/>
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import { ConnectButton, ThirdwebProvider } from "thirdweb/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { storybookThirdwebClient } from "@/storybook/utils";
|
||||
import { FundWalletModal } from "./index";
|
||||
|
||||
const meta: Meta<typeof FundWalletModal> = {
|
||||
title: "Blocks/FundWalletModal",
|
||||
component: Variant,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<ThirdwebProvider>
|
||||
<div className="p-10">
|
||||
<ConnectButton client={storybookThirdwebClient} />
|
||||
<div className="h-4" />
|
||||
<Story />
|
||||
</div>
|
||||
</ThirdwebProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Test: Story = {
|
||||
args: {},
|
||||
};
|
||||
function Variant() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button type="button" onClick={() => setIsOpen(true)}>
|
||||
Open
|
||||
</Button>
|
||||
|
||||
<FundWalletModal
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
title="This is a title"
|
||||
description="This is a description"
|
||||
recipientAddress="0x83Dd93fA5D8343094f850f90B3fb90088C1bB425"
|
||||
client={storybookThirdwebClient}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { defineChain } from "thirdweb/chains";
|
||||
import { CheckoutWidget, useActiveWalletChain } from "thirdweb/react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
reportFundWalletFailed,
|
||||
reportFundWalletSuccessful,
|
||||
} from "@/analytics/report";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { TokenSelector } from "@/components/blocks/TokenSelector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DecimalInput } from "@/components/ui/decimal-input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
import { WalletAddress } from "../wallet-address";
|
||||
|
||||
const formSchema = z.object({
|
||||
chainId: z.number({
|
||||
required_error: "Chain is required",
|
||||
}),
|
||||
token: z.object(
|
||||
{
|
||||
chainId: z.number(),
|
||||
address: z.string(),
|
||||
symbol: z.string(),
|
||||
name: z.string(),
|
||||
decimals: z.number(),
|
||||
},
|
||||
{
|
||||
required_error: "Token is required",
|
||||
},
|
||||
),
|
||||
amount: z
|
||||
.string()
|
||||
.min(1, "Amount is required")
|
||||
.refine((value) => {
|
||||
const num = Number(value);
|
||||
return !Number.isNaN(num) && num > 0;
|
||||
}, "Amount must be greater than 0"),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
type FundWalletModalProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
recipientAddress: string;
|
||||
client: ThirdwebClient;
|
||||
defaultChainId?: number;
|
||||
checkoutWidgetTitle?: string;
|
||||
};
|
||||
|
||||
export function FundWalletModal(props: FundWalletModalProps) {
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent className="p-0 gap-0 !w-full !max-w-md">
|
||||
<FundWalletModalContent
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={props.title}
|
||||
description={props.description}
|
||||
recipientAddress={props.recipientAddress}
|
||||
client={props.client}
|
||||
defaultChainId={props.defaultChainId}
|
||||
checkoutWidgetTitle={props.checkoutWidgetTitle}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function FundWalletModalContent(props: FundWalletModalProps) {
|
||||
const [step, setStep] = useState<"form" | "checkout">("form");
|
||||
const activeChain = useActiveWalletChain();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const form = useForm<FormData>({
|
||||
defaultValues: {
|
||||
chainId: props.defaultChainId || activeChain?.id || 1,
|
||||
token: undefined,
|
||||
amount: "0.1",
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(formSchema),
|
||||
});
|
||||
|
||||
const selectedChainId = form.watch("chainId");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{step === "form" ? (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(() => {
|
||||
setStep("checkout");
|
||||
})}
|
||||
>
|
||||
<DialogHeader className="p-4 lg:p-6">
|
||||
<DialogTitle>{props.title}</DialogTitle>
|
||||
<DialogDescription>{props.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 px-4 lg:px-6 pb-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium"> Recipient</h3>
|
||||
<WalletAddress
|
||||
address={props.recipientAddress}
|
||||
client={props.client}
|
||||
className="py-1 h-auto"
|
||||
iconClassName="size-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="chainId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Chain</FormLabel>
|
||||
<FormControl>
|
||||
<SingleNetworkSelector
|
||||
className="bg-card"
|
||||
chainId={field.value}
|
||||
onChange={(token) => {
|
||||
field.onChange(token);
|
||||
form.resetField("token", {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
}}
|
||||
client={props.client}
|
||||
placeholder="Select a chain"
|
||||
disableDeprecated
|
||||
disableTestnets
|
||||
disableChainId
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="token"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Token</FormLabel>
|
||||
<FormControl>
|
||||
<TokenSelector
|
||||
className="bg-card"
|
||||
disableAddress
|
||||
selectedToken={
|
||||
field.value
|
||||
? {
|
||||
chainId: field.value.chainId,
|
||||
address: field.value.address,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onChange={field.onChange}
|
||||
chainId={selectedChainId}
|
||||
client={props.client}
|
||||
placeholder="Select a token"
|
||||
enabled={!!selectedChainId}
|
||||
addNativeTokenIfMissing={true}
|
||||
showCheck={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Amount</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<DecimalInput
|
||||
className="bg-card !text-xl h-auto font-semibold"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="0.1"
|
||||
/>
|
||||
{form.watch("token") && (
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
|
||||
{form.watch("token").symbol}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-4 lg:p-6 bg-card border-t rounded-b-lg">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" className="gap-2">
|
||||
Next
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<div>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{props.title}</DialogTitle>
|
||||
<DialogDescription>{props.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div>
|
||||
<CheckoutWidget
|
||||
client={props.client}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain={defineChain(form.getValues("chainId"))}
|
||||
tokenAddress={form.getValues("token").address as `0x${string}`}
|
||||
amount={form.getValues("amount")}
|
||||
seller={props.recipientAddress as `0x${string}`}
|
||||
showThirdwebBranding={false}
|
||||
className="!w-full !max-w-full !min-w-0 !rounded-b-none !border-none"
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
name={props.checkoutWidgetTitle}
|
||||
onSuccess={() => {
|
||||
reportFundWalletSuccessful();
|
||||
}}
|
||||
onError={(error) => {
|
||||
reportFundWalletFailed({
|
||||
errorMessage: error.message,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-4 lg:p-6 bg-card border-t rounded-b-lg">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setStep("form")}
|
||||
className="gap-2"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4 text-muted-foreground" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="gap-2"
|
||||
variant="outline"
|
||||
onClick={() => props.onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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?: (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { CrownIcon, LockIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { TeamPlanBadge } from "@/components/blocks/TeamPlanBadge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
@@ -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",
|
||||
@@ -196,11 +192,11 @@ function WalletAvatar(props: {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("size-6 overflow-hidden rounded-full", props.iconClassName)}
|
||||
className={cn("size-5 overflow-hidden rounded-full", props.iconClassName)}
|
||||
>
|
||||
{resolvedAvatarSrc ? (
|
||||
<Img
|
||||
className={cn("size-6 object-cover", props.iconClassName)}
|
||||
className={cn("size-5 object-cover", props.iconClassName)}
|
||||
src={resolvedAvatarSrc}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 type { Team } from "@/api/team/get-team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWalletConnectionStatus } from "thirdweb/react";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThirdwebMiniLogo } from "../../../app/(app)/components/ThirdwebMiniLogo";
|
||||
import { ChatBar } from "./ChatBar";
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Team } from "@/api/team";
|
||||
import type { Team } from "@/api/team/get-team";
|
||||
import { MarkdownRenderer } from "@/components/blocks/markdown-renderer";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useFavoriteChainIds } from "@app/(dashboard)/(chain)/components/client/star-button";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
@@ -20,6 +19,7 @@ import { LazyConfigureNetworkModal } from "@/components/misc/configure-networks/
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { popularChains } from "@/constants/popularChains";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { useFavoriteChainIds } from "@/hooks/favorite-chains";
|
||||
import { useStore } from "@/lib/reactive";
|
||||
import {
|
||||
addRecentlyUsedChainId,
|
||||
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
import type { Abi } from "abitype";
|
||||
import { Select } from "chakra-react-select";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface AbiSelectorProps {
|
||||
abi: Abi;
|
||||
defaultValue: string;
|
||||
value: string;
|
||||
onChange: (fn: string) => void;
|
||||
}
|
||||
|
||||
export const AbiSelector: React.FC<AbiSelectorProps> = ({
|
||||
abi,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
}) => {
|
||||
const options = useMemo(() => {
|
||||
return abi
|
||||
.filter((f) => f.type === "function")
|
||||
.filter((f) => f.stateMutability !== "view")
|
||||
.map((f) => ({
|
||||
label: f.name,
|
||||
value: f.name,
|
||||
}));
|
||||
}, [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>
|
||||
);
|
||||
};
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
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 type { ThirdwebClient } from "thirdweb";
|
||||
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
|
||||
import { useCustomFactoryAbi } from "@/hooks/contract-hooks";
|
||||
import { AbiSelector } from "./abi-selector";
|
||||
|
||||
interface CustomFactoryProps {
|
||||
setCustomFactoryAbi: Dispatch<SetStateAction<Abi>>;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const CustomFactory: React.FC<CustomFactoryProps> = ({
|
||||
setCustomFactoryAbi,
|
||||
client,
|
||||
}) => {
|
||||
const form = useFormContext();
|
||||
|
||||
const customFactoryAbi = useCustomFactoryAbi(
|
||||
client,
|
||||
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(() => {
|
||||
if (customFactoryAbi?.data) {
|
||||
setCustomFactoryAbi(customFactoryAbi.data);
|
||||
}
|
||||
}, [customFactoryAbi, setCustomFactoryAbi]);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "customFactoryAddresses",
|
||||
});
|
||||
|
||||
// FIXME: all of this logic needs to be reworked
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (fields.length === 0) {
|
||||
append({ key: 1, value: "" }, { shouldFocus: false });
|
||||
}
|
||||
}, [fields, append]);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={12} pb={0} px={0}>
|
||||
<UnorderedList>
|
||||
<Text as={ListItem}>
|
||||
Use this if you want to invoke your own function with custom logic
|
||||
when users deploy your contract.
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
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)}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
leftIcon={<PlusIcon className="size-5" />}
|
||||
onClick={() => append({ key: "", value: "" })}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
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(
|
||||
"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>
|
||||
);
|
||||
};
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
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 { AbiSelector } from "./abi-selector";
|
||||
import { NetworksFieldset } from "./networks-fieldset";
|
||||
|
||||
interface DefaultFactoryProps {
|
||||
abi: Abi;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const DefaultFactory: React.FC<DefaultFactoryProps> = ({
|
||||
abi,
|
||||
client,
|
||||
}) => {
|
||||
const form = useFormContext();
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={12} pb={0} px={0}>
|
||||
<UnorderedList>
|
||||
<Text as={ListItem}>
|
||||
Default factory lets users deploy your contract to{" "}
|
||||
<strong>any EVM network</strong>.
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
The factory deploys EIP-1167 minimal proxies of your contract. This
|
||||
makes it much cheaper to deploy.
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
The factory is{" "}
|
||||
<Link
|
||||
color="primary.500"
|
||||
href="https://github.com/thirdweb-dev/contracts/blob/main/contracts/TWStatelessFactory.sol"
|
||||
isExternal
|
||||
>
|
||||
open-source
|
||||
</Link>
|
||||
, permissionless, and does not alter contract ownership.
|
||||
</Text>
|
||||
<Text as={ListItem}>
|
||||
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(
|
||||
"factoryDeploymentData.implementationInitializerFunction",
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
<NetworksFieldset client={client} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
-84
@@ -1,84 +0,0 @@
|
||||
import { TrashIcon } from "lucide-react";
|
||||
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;
|
||||
}
|
||||
|
||||
export const ExternalLinksInput: React.FC<ExternalLinksInputProps> = ({
|
||||
index,
|
||||
remove,
|
||||
}) => {
|
||||
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>
|
||||
<Separator />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
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 { CustomFactory } from "./custom-factory";
|
||||
import { DefaultFactory } from "./default-factory";
|
||||
|
||||
interface FactoryFieldsetProps {
|
||||
abi: Abi;
|
||||
setCustomFactoryAbi: Dispatch<SetStateAction<Abi>>;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const FactoryFieldset: React.FC<FactoryFieldsetProps> = ({
|
||||
abi,
|
||||
setCustomFactoryAbi,
|
||||
client,
|
||||
}) => {
|
||||
const form = useFormContext();
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,466 +0,0 @@
|
||||
"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 { useId, useMemo, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { FetchDeployMetadataResult } from "thirdweb/contract";
|
||||
import {
|
||||
getContractPublisher,
|
||||
publishContract,
|
||||
} from "thirdweb/extensions/thirdweb";
|
||||
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import { reportContractPublished } from "@/analytics/report";
|
||||
import { CustomConnectWallet } from "@/components/connect-wallet";
|
||||
import {
|
||||
DASHBOARD_ENGINE_RELAYER_URL,
|
||||
DASHBOARD_FORWARDER_ADDRESS,
|
||||
} from "@/constants/misc";
|
||||
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";
|
||||
import { LandingFieldset } from "./landing-fieldset";
|
||||
import { NetworksFieldset } from "./networks-fieldset";
|
||||
|
||||
export function ContractPublishForm(props: {
|
||||
publishMetadata: FetchDeployMetadataResult;
|
||||
onPublishSuccess: () => Promise<void>;
|
||||
isLoggedIn: boolean;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const [customFactoryAbi, setCustomFactoryAbi] = useState<Abi>([]);
|
||||
const [fieldsetToShow, setFieldsetToShow] = useState<
|
||||
"landing" | "factory" | "contractParams" | "implParams" | "networks"
|
||||
>("landing");
|
||||
|
||||
const router = useDashboardRouter();
|
||||
const { onSuccess, onError } = useTxNotifications(
|
||||
"Successfully published contract",
|
||||
"Failed to publish contract",
|
||||
);
|
||||
const account = useActiveAccount();
|
||||
const sendTx = useSendAndConfirmTransaction({
|
||||
gasless: {
|
||||
experimentalChainlessSupport: true,
|
||||
provider: "engine",
|
||||
relayerForwarderAddress: DASHBOARD_FORWARDER_ADDRESS,
|
||||
relayerUrl: DASHBOARD_ENGINE_RELAYER_URL,
|
||||
},
|
||||
});
|
||||
|
||||
const latestVersion = props.publishMetadata.version;
|
||||
|
||||
const placeholderVersion = useMemo(() => {
|
||||
if (latestVersion) {
|
||||
const versionSplit = latestVersion.split(".");
|
||||
return `${versionSplit[0]}.${versionSplit[1]}.${Number(versionSplit[2]) + 1 || 0}`;
|
||||
}
|
||||
return "1.0.0";
|
||||
}, [latestVersion]);
|
||||
|
||||
const transformedQueryData = useMemo(() => {
|
||||
return {
|
||||
...props.publishMetadata,
|
||||
changelog: "",
|
||||
constructorParams: props.publishMetadata.constructorParams || {},
|
||||
customFactoryAddresses: Object.entries(
|
||||
props.publishMetadata.factoryDeploymentData?.customFactoryInput
|
||||
?.customFactoryAddresses || {},
|
||||
).map(([key, value]) => ({ key: Number(key), value })),
|
||||
defaultExtensions: props.publishMetadata.defaultExtensions || [],
|
||||
defaultModules: props.publishMetadata.defaultModules || [],
|
||||
deployType: props.publishMetadata.deployType || "standard",
|
||||
description:
|
||||
props.publishMetadata.description ||
|
||||
props.publishMetadata.info?.notice ||
|
||||
"",
|
||||
displayName:
|
||||
props.publishMetadata.displayName ||
|
||||
props.publishMetadata.info?.title ||
|
||||
props.publishMetadata.name ||
|
||||
"",
|
||||
extensionsParamName:
|
||||
props.publishMetadata.factoryDeploymentData?.modularFactoryInput
|
||||
?.hooksParamName || "",
|
||||
factoryDeploymentData: props.publishMetadata?.factoryDeploymentData || {
|
||||
customFactoryInput: {
|
||||
customFactoryAddresses:
|
||||
props.publishMetadata.factoryDeploymentData?.customFactoryInput
|
||||
?.customFactoryAddresses || {},
|
||||
factoryFunction:
|
||||
props.publishMetadata.factoryDeploymentData?.customFactoryInput
|
||||
?.factoryFunction || "deployProxyByImplementation",
|
||||
params:
|
||||
props.publishMetadata.factoryDeploymentData?.customFactoryInput
|
||||
?.params || [],
|
||||
},
|
||||
factoryAddresses: {},
|
||||
implementationAddresses: {},
|
||||
implementationInitializerFunction: "initialize",
|
||||
},
|
||||
implConstructorParams: props.publishMetadata.implConstructorParams || {},
|
||||
name: props.publishMetadata.name || "",
|
||||
networksForDeployment: props.publishMetadata?.networksForDeployment || {
|
||||
allNetworks: true,
|
||||
},
|
||||
version: placeholderVersion,
|
||||
};
|
||||
}, [placeholderVersion, props.publishMetadata]);
|
||||
|
||||
const form = useForm<
|
||||
FetchDeployMetadataResult & { name: string; version: string }
|
||||
>({
|
||||
defaultValues: transformedQueryData,
|
||||
resetOptions: {
|
||||
keepDirty: true,
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
values: transformedQueryData,
|
||||
});
|
||||
|
||||
const disableNext =
|
||||
!form.watch("version") ||
|
||||
!form.watch("displayName") ||
|
||||
!!form.getFieldState("version", form.formState).error;
|
||||
|
||||
const ensQuery = useEns({
|
||||
addressOrEnsName: account?.address,
|
||||
client: props.client,
|
||||
});
|
||||
|
||||
const ensNameOrAddress = useMemo(() => {
|
||||
return ensQuery?.data?.ensName || ensQuery.data?.address;
|
||||
}, [ensQuery.data]);
|
||||
|
||||
const successRedirectUrl = useMemo(() => {
|
||||
if (!ensNameOrAddress || !props.publishMetadata.name) {
|
||||
return undefined;
|
||||
}
|
||||
return `/${ensNameOrAddress}/${props.publishMetadata.name}`;
|
||||
}, [ensNameOrAddress, props.publishMetadata.name]);
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() => !successRedirectUrl || !account,
|
||||
[successRedirectUrl, account],
|
||||
);
|
||||
|
||||
const constructorParams =
|
||||
props.publishMetadata.abi.find((c) => c.type === "constructor")?.inputs ||
|
||||
[];
|
||||
|
||||
const initializerParams = useFunctionParamsFromABI(
|
||||
form.watch("deployType") === "customFactory"
|
||||
? customFactoryAbi
|
||||
: props.publishMetadata.abi,
|
||||
form.watch("deployType") === "customFactory"
|
||||
? form.watch(
|
||||
"factoryDeploymentData.customFactoryInput.factoryFunction",
|
||||
) ||
|
||||
props.publishMetadata.factoryDeploymentData?.customFactoryInput
|
||||
?.factoryFunction ||
|
||||
"deployProxyByImplementation"
|
||||
: form.watch("factoryDeploymentData.implementationInitializerFunction") ||
|
||||
props.publishMetadata.factoryDeploymentData
|
||||
?.implementationInitializerFunction ||
|
||||
"initialize",
|
||||
);
|
||||
|
||||
const deployParams =
|
||||
form.watch("deployType") === "standard"
|
||||
? constructorParams
|
||||
: initializerParams;
|
||||
|
||||
const implDeployParams =
|
||||
form.watch("deployType") === "autoFactory" ? constructorParams : [];
|
||||
|
||||
// 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;
|
||||
}[])
|
||||
: [];
|
||||
|
||||
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",
|
||||
},
|
||||
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,
|
||||
});
|
||||
|
||||
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"
|
||||
>
|
||||
Back
|
||||
</IconButton>
|
||||
</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>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
-289
@@ -1,289 +0,0 @@
|
||||
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 { 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 { SolidityInput } from "@/components/solidity-inputs";
|
||||
import { MarkdownRenderer } from "../../blocks/markdown-renderer";
|
||||
import { ExternalLinksFieldset } from "./external-links-fieldset";
|
||||
|
||||
interface LandingFieldsetProps {
|
||||
latestVersion: string | undefined;
|
||||
placeholderVersion: string;
|
||||
client: ThirdwebClient;
|
||||
}
|
||||
|
||||
export const LandingFieldset: React.FC<LandingFieldsetProps> = ({
|
||||
latestVersion,
|
||||
placeholderVersion,
|
||||
client,
|
||||
}) => {
|
||||
const form = useFormContext<ExtendedMetadata>();
|
||||
const logoUrl = form.watch("logo");
|
||||
|
||||
const handleVersionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = e.target;
|
||||
|
||||
form.setValue("version", value);
|
||||
|
||||
const isValidSemver = validate(value);
|
||||
|
||||
const isValidVersion =
|
||||
latestVersion && isValidSemver
|
||||
? compare(latestVersion || "0.0.0", value || "0.0.0", "<")
|
||||
: isValidSemver;
|
||||
|
||||
if (!isValidSemver) {
|
||||
form.setError("version", {
|
||||
message: "Version must be valid semver.",
|
||||
type: "pattern",
|
||||
});
|
||||
} else if (!isValidVersion) {
|
||||
form.setError("version", {
|
||||
message: "Version must be greater than latest version.",
|
||||
type: "manual",
|
||||
});
|
||||
} else {
|
||||
form.clearErrors("version");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex as="fieldset" direction="column" gap={16} mt={{ base: 4, md: 12 }}>
|
||||
<Flex direction="column" gap={2}>
|
||||
<Heading size="title.lg">
|
||||
{!latestVersion ? "Publish" : "Edit"} your contract
|
||||
</Heading>
|
||||
<Text fontStyle="normal">
|
||||
Publishing your contract makes it shareable, discoverable, and
|
||||
deployable in a single click.{" "}
|
||||
<Link
|
||||
color="blue.500"
|
||||
href="https://portal.thirdweb.com/contracts/publish/overview"
|
||||
isExternal
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex gap={6} w="full">
|
||||
<FormControl isInvalid={!!form.formState.errors.logo} w="auto">
|
||||
<FormLabel>Image</FormLabel>
|
||||
<Box width="141px">
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
className="rounded border border-border 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
|
||||
>
|
||||
<FormLabel>Contract Name</FormLabel>
|
||||
<Input
|
||||
onChange={(e) => form.setValue("displayName", e.target.value)}
|
||||
placeholder="Ex. MyContract"
|
||||
value={form.watch("displayName")}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{form.formState.errors?.displayName?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl isInvalid={!!form.formState.errors.description}>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea
|
||||
onChange={(e) => form.setValue("description", e.target.value)}
|
||||
placeholder="Briefly describe what your contract does."
|
||||
rows={2}
|
||||
value={form.watch("description")}
|
||||
/>
|
||||
|
||||
<FormErrorMessage>
|
||||
{form.formState.errors?.description?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<div>
|
||||
<Heading mb={2} size="title.md">
|
||||
README
|
||||
</Heading>
|
||||
<Text mb={4} size="body.md">
|
||||
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>
|
||||
</div>
|
||||
<ExternalLinksFieldset />
|
||||
<div>
|
||||
<Heading mb={2} size="title.md">
|
||||
Version information
|
||||
</Heading>
|
||||
<Text mb={4} size="body.md">
|
||||
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>
|
||||
<Input
|
||||
onChange={handleVersionChange}
|
||||
placeholder={placeholderVersion}
|
||||
value={form.watch("version")}
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{form.formState.errors?.version?.message}
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
{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."
|
||||
/>
|
||||
<FormErrorMessage>
|
||||
{form.formState.errors?.changelog?.message}
|
||||
</FormErrorMessage>
|
||||
</TabPanel>
|
||||
<TabPanel pb={0} px={0}>
|
||||
<Card>
|
||||
<MarkdownRenderer
|
||||
markdownText={form.watch("changelog") || ""}
|
||||
/>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</FormControl>
|
||||
)}
|
||||
<FormControl isInvalid={!!form.formState.errors.audit}>
|
||||
<FormLabel>Audit report</FormLabel>
|
||||
<SolidityInput
|
||||
client={client}
|
||||
solidityType="string"
|
||||
{...form.register("audit")}
|
||||
placeholder="ipfs://... or https://..."
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<div>
|
||||
<Heading mb={2} size="title.md">
|
||||
Deployment options
|
||||
</Heading>
|
||||
<Text mb={4} size="body.md">
|
||||
Choose how users will deploy your published contract.
|
||||
</Text>
|
||||
<Flex flexDir="column" gap={2} width="full">
|
||||
<SelectOption
|
||||
className="w-full"
|
||||
description="Users will directly deploy the full contract."
|
||||
isActive={form.watch("deployType") === "standard"}
|
||||
name="Direct deploy"
|
||||
onClick={() => form.setValue("deployType", "standard")}
|
||||
/>
|
||||
<SelectOption
|
||||
className="w-full"
|
||||
description="Users will deploy your contract through a default or custom factory contract."
|
||||
isActive={
|
||||
form.watch("deployType") === "autoFactory" ||
|
||||
form.watch("deployType") === "customFactory"
|
||||
}
|
||||
name="Deploy via factory"
|
||||
onClick={() => form.setValue("deployType", "autoFactory")}
|
||||
/>
|
||||
</Flex>
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
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";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
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>
|
||||
{!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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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> = ({
|
||||
wallet,
|
||||
client,
|
||||
}) => {
|
||||
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">
|
||||
<AccountAvatar
|
||||
className="size-10 rounded-full border border-border border-solid object-cover"
|
||||
fallbackComponent={
|
||||
<AccountBlobbie className="size-10 rounded-full" />
|
||||
}
|
||||
loadingComponent={<Skeleton className="size-10 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>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { ThirdwebProvider } from "thirdweb/react";
|
||||
import type { ProjectContract } from "@/api/getProjectContracts";
|
||||
import type { ProjectContract } from "@/api/project/getProjectContracts";
|
||||
import { BadgeContainer, storybookThirdwebClient } from "@/storybook/utils";
|
||||
import { ContractTableUI } from "./contract-table";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import Link from "next/link";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { ProjectContract } from "@/api/getProjectContracts";
|
||||
import type { ProjectContract } from "@/api/project/getProjectContracts";
|
||||
import { PaginationButtons } from "@/components/blocks/pagination-buttons";
|
||||
import { NetworkSelectDropdown } from "@/components/contract-components/tables/NetworkSelectDropdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
@@ -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 { getContractFunctionsFromAbi } from "@/api/contract/getContractFunctionsFromAbi";
|
||||
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,305 @@ 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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { moduleToBase64 } from "app/(app)/(dashboard)/published-contract/utils/module-base-64";
|
||||
import { RocketIcon, ShieldCheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { fetchPublishedContractVersion } from "@/api/contract/fetch-contracts-with-versions";
|
||||
import { ClientOnly } from "@/components/blocks/client-only";
|
||||
import { fetchPublishedContractVersion } from "@/components/contract-components/fetch-contracts-with-versions";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -11,7 +11,7 @@ import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { replaceDeployerAddress } from "@/lib/publisher-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { ContractPublisher } from "../../../../app/(app)/(dashboard)/explore/components/publisher";
|
||||
import { ContractPublisher } from "./contract-publisher";
|
||||
|
||||
interface ContractCardProps {
|
||||
publisher: string;
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { fetchDeployMetadata } from "@/components/contract-components/fetchDeployMetadata";
|
||||
import { fetchDeployMetadata } from "@/api/contract/fetchDeployMetadata";
|
||||
import { ContractIdImage } from "@/components/contract-components/shared/contract-id-image";
|
||||
import {
|
||||
Table,
|
||||
|
||||
@@ -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,116 @@ 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"
|
||||
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 +401,8 @@ export const ContractFunctionsPanel: React.FC<ContractFunctionsPanelProps> = ({
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
)}
|
||||
</GridItem>
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -524,31 +422,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,124 +0,0 @@
|
||||
"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 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 { ContractFunctionsPanel } from "./contract-function";
|
||||
|
||||
interface ContractFunctionsOverview {
|
||||
functions?: AbiFunction[] | null;
|
||||
events?: AbiEvent[] | null;
|
||||
contract?: ThirdwebContract;
|
||||
sources?: SourceFile[];
|
||||
abi?: Abi;
|
||||
onlyFunctions?: boolean;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
+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>
|
||||
);
|
||||
};
|
||||
|
||||
+2
-97
@@ -1,14 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { CircleAlertIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { Team } from "@/api/team";
|
||||
|
||||
import { GradientAvatar } from "@/components/blocks/avatar/gradient-avatar";
|
||||
import { ProjectAvatar } from "@/components/blocks/avatar/project-avatar";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
@@ -17,98 +13,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import { Fieldset } from "./common";
|
||||
|
||||
export type MinimalTeam = Pick<Team, "id" | "name" | "image" | "slug">;
|
||||
export type MinimalProject = Pick<Project, "id" | "name" | "image" | "slug">;
|
||||
|
||||
export type TeamAndProjectSelection = {
|
||||
team: MinimalTeam | undefined;
|
||||
project: MinimalProject | undefined;
|
||||
};
|
||||
|
||||
export type MinimalTeamsAndProjects = Array<{
|
||||
team: MinimalTeam;
|
||||
projects: MinimalProject[];
|
||||
}>;
|
||||
|
||||
type TeamAndProjectSelectorProps = {
|
||||
teamsAndProjects: MinimalTeamsAndProjects;
|
||||
selection: TeamAndProjectSelection;
|
||||
enabled: boolean;
|
||||
onSelectionChange: (selection: TeamAndProjectSelection) => void;
|
||||
client: ThirdwebClient;
|
||||
onSetEnabled: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export function AddToProjectCardUI(props: TeamAndProjectSelectorProps) {
|
||||
const selectedTeam = props.teamsAndProjects.find(
|
||||
(t) => t.team.id === props.selection.team?.id,
|
||||
);
|
||||
|
||||
const isProjectSelected = props.selection.project && props.enabled;
|
||||
|
||||
return (
|
||||
<Fieldset
|
||||
description="Save the deployed contract in a project's contract list on thirdweb dashboard"
|
||||
headerChildren={
|
||||
<div className="absolute top-4 right-4 lg:top-6 lg:right-6">
|
||||
<Checkbox
|
||||
checked={props.enabled}
|
||||
className="size-5"
|
||||
onCheckedChange={(checked) => {
|
||||
props.onSetEnabled(!!checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
headerClassName="pr-14"
|
||||
legend="Add To Project"
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Team & Project selectors */}
|
||||
{props.enabled && (
|
||||
<AddToProjectSelector
|
||||
client={props.client}
|
||||
onSelectionChange={props.onSelectionChange}
|
||||
selection={props.selection}
|
||||
teamsAndProjects={props.teamsAndProjects}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No projects alert */}
|
||||
{selectedTeam?.projects.length === 0 && (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle> Selected team has no projects </AlertTitle>
|
||||
<AlertDescription>
|
||||
<UnderlineLink
|
||||
className="inline-flex items-center gap-2"
|
||||
href={`/team/${selectedTeam.team.slug}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Create Project <ExternalLinkIcon className="size-3.5" />
|
||||
</UnderlineLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* No project selected alert */}
|
||||
{!isProjectSelected && (
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>No Project Selected</AlertTitle>
|
||||
<AlertDescription>
|
||||
Deployed contract will not be saved in thirdweb dashboard
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
import type { MinimalTeamsAndProjects, TeamAndProjectSelection } from "./types";
|
||||
|
||||
function SlashSeparator() {
|
||||
return <div className="mx-2 h-5 w-[2px] rotate-[25deg] bg-foreground/20" />;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user