From 36731096b9fe7d9cd2cabbd03db71b0578009188 Mon Sep 17 00:00:00 2001 From: MananTank Date: Fri, 25 Jul 2025 01:13:50 +0000 Subject: [PATCH] Dashboard: Migrate /tokens contract page from chakra to tailwind (#7695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ## PR-Codex overview This PR focuses on improving the functionality and user experience of token management features in a dashboard application, including enhancements to file downloads, airdrop processes, and form validations. ### Detailed summary - Removed unused `supportedERCs` from `ContractTokensPage`. - Added redirects for unsupported ERC20 tokens. - Improved the `LazyMintNftForm` with validation on image uploads. - Introduced `DownloadableCode` component for code downloads. - Added `AirdropCSVTable` for better CSV data handling. - Enhanced `TokenAirdropForm` with improved transaction handling and notifications. - Refactored various token management buttons to use consistent form structures and validation. - Updated airdrop upload process to handle CSV files more effectively with user feedback. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` ## Summary by CodeRabbit * **New Features** * Introduced a new DownloadableCode component for displaying and downloading code examples. * Added a paginated CSV table for airdrop address and quantity previews. * **Refactor** * Simplified and unified token action forms (airdrop, burn, claim, mint, transfer) with improved validation, streamlined UI, and consistent notifications. * Replaced custom code example components with the new DownloadableCode component across relevant views. * Updated CSV upload experience with clearer instructions and reusable components. * Improved conditional navigation for unsupported token standards. * **Bug Fixes** * Enhanced form validation and error handling for token-related actions. * **Chores** * Updated import paths for consistency and maintainability. --- .../@/components/batch-upload/upload-step.tsx | 42 +-- .../blocks/code/downloadable-code.tsx | 36 +++ .../blocks}/download-file-button.tsx | 0 .../nfts/[tokenId]/components/airdrop-tab.tsx | 1 - .../nfts/components/lazy-mint-form.tsx | 8 +- .../tokens/ContractTokensPage.client.tsx | 3 +- .../tokens/ContractTokensPage.tsx | 35 +-- .../tokens/components/airdrop-button.tsx | 43 +-- .../tokens/components/airdrop-csv-table.tsx | 69 +++++ .../tokens/components/airdrop-form.tsx | 184 +++++------- .../tokens/components/airdrop-upload.tsx | 253 ++++++---------- .../tokens/components/burn-button.tsx | 208 +++++++------ .../tokens/components/claim-button.tsx | 278 +++++++++--------- .../tokens/components/mint-button.tsx | 190 ++++++------ .../tokens/components/transfer-button.tsx | 241 ++++++++------- .../[contractAddress]/tokens/shared-page.tsx | 9 +- .../batch-upload-instructions.tsx | 44 +-- .../token/distribution/token-airdrop.tsx | 2 +- 18 files changed, 795 insertions(+), 851 deletions(-) create mode 100644 apps/dashboard/src/@/components/blocks/code/downloadable-code.tsx rename apps/dashboard/src/{app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common => @/components/blocks}/download-file-button.tsx (100%) create mode 100644 apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-csv-table.tsx diff --git a/apps/dashboard/src/@/components/batch-upload/upload-step.tsx b/apps/dashboard/src/@/components/batch-upload/upload-step.tsx index 47d2c0b07a7..6f3ab2780a7 100644 --- a/apps/dashboard/src/@/components/batch-upload/upload-step.tsx +++ b/apps/dashboard/src/@/components/batch-upload/upload-step.tsx @@ -1,10 +1,7 @@ -import { ArrowDownToLineIcon } from "lucide-react"; import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { CodeClient } from "@/components/ui/code/code.client"; import { InlineCode } from "@/components/ui/inline-code"; import { TabButtons } from "@/components/ui/tabs"; -import { handleDownload } from "../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/download-file-button"; +import { DownloadableCode } from "../blocks/code/downloadable-code"; import { DropZone } from "../blocks/drop-zone/drop-zone"; interface UploadStepProps { @@ -105,7 +102,7 @@ export function UploadStep(props: UploadStepProps) { "All other columns will be treated as Attributes. For example: See 'eyes', 'nose' below."}

- and{" "} {" "} {tab === "csv" ? "columns" : "properties"}.{" "} - - - - - - ); -} - const csv_example_basic = `\ name,description,background_color,eyes,nose Token 0 Name,Token 0 Description,#0098EE,red,green diff --git a/apps/dashboard/src/@/components/blocks/code/downloadable-code.tsx b/apps/dashboard/src/@/components/blocks/code/downloadable-code.tsx new file mode 100644 index 00000000000..bd25fb51343 --- /dev/null +++ b/apps/dashboard/src/@/components/blocks/code/downloadable-code.tsx @@ -0,0 +1,36 @@ +"use client"; +import { ArrowDownToLineIcon } 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 ( +
+ + + +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/download-file-button.tsx b/apps/dashboard/src/@/components/blocks/download-file-button.tsx similarity index 100% rename from apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/download-file-button.tsx rename to apps/dashboard/src/@/components/blocks/download-file-button.tsx diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/airdrop-tab.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/airdrop-tab.tsx index 07d2310ab50..3bb2c95e988 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/airdrop-tab.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/airdrop-tab.tsx @@ -110,7 +110,6 @@ const AirdropTab: React.FC = ({ setOpen(false)} setAirdrop={(value) => setValue("addresses", value, { shouldDirty: true }) } diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/lazy-mint-form.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/lazy-mint-form.tsx index 13f9dee6b35..14081cf882b 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/lazy-mint-form.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/components/lazy-mint-form.tsx @@ -160,7 +160,7 @@ export function LazyMintNftForm({ ( + render={() => ( Cover Image @@ -169,7 +169,11 @@ export function LazyMintNftForm({ className="rounded-lg bg-card border border-border transition-all" client={contract.client} previewMaxWidth="200px" - setValue={(file) => setValue("image", file)} + setValue={(file) => + setValue("image", file, { + shouldValidate: true, + }) + } showUploadButton value={image} /> diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/ContractTokensPage.client.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/ContractTokensPage.client.tsx index b0852cbe3e4..5fcc0c4c88d 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/ContractTokensPage.client.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/ContractTokensPage.client.tsx @@ -22,13 +22,12 @@ export function ContractTokensPageClient(props: { return ; } - const { supportedERCs, functionSelectors } = metadataQuery.data; + const { functionSelectors } = metadataQuery.data; return ( diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/ContractTokensPage.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/ContractTokensPage.tsx index b09c9acddda..4c0ea010304 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/ContractTokensPage.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/ContractTokensPage.tsx @@ -1,8 +1,5 @@ "use client"; -import { LinkButton } from "chakra/button"; -import { Card } from "chakra/card"; -import { Heading } from "chakra/heading"; -import { Text } from "chakra/text"; + import type { ThirdwebContract } from "thirdweb"; import { TokenAirdropButton } from "./components/airdrop-button"; import { TokenBurnButton } from "./components/burn-button"; @@ -13,41 +10,17 @@ import { TokenTransferButton } from "./components/transfer-button"; interface ContractTokenPageProps { contract: ThirdwebContract; - isERC20: boolean; isMintToSupported: boolean; isClaimToSupported: boolean; isLoggedIn: boolean; } -export const ContractTokensPage: React.FC = ({ +export function ContractTokensPage({ contract, - isERC20, isMintToSupported, isClaimToSupported, isLoggedIn, -}) => { - if (!isERC20) { - return ( - - {/* TODO extract this out into it's own component and make it better */} - No Token extension enabled - - To enable Token features you will have to extend an ERC20 interface in - your contract. - -
- - Learn more - -
-
- ); - } - +}: ContractTokenPageProps) { return (
@@ -68,4 +41,4 @@ export const ContractTokensPage: React.FC = ({
); -}; +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-button.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-button.tsx index 8f25d0c7a96..8ba7ec5343f 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-button.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-button.tsx @@ -1,10 +1,7 @@ "use client"; import { DropletIcon } from "lucide-react"; -import { useState } from "react"; import type { ThirdwebContract } from "thirdweb"; -import { balanceOf } from "thirdweb/extensions/erc20"; -import { useActiveAccount, useReadContract } from "thirdweb/react"; import { Button } from "@/components/ui/button"; import { Sheet, @@ -15,42 +12,26 @@ import { } from "@/components/ui/sheet"; import { TokenAirdropForm } from "./airdrop-form"; -interface TokenAirdropButtonProps { +export function TokenAirdropButton(props: { contract: ThirdwebContract; isLoggedIn: boolean; -} - -export const TokenAirdropButton: React.FC = ({ - contract, - isLoggedIn, - ...restButtonProps -}) => { - const address = useActiveAccount()?.address; - const tokenBalanceQuery = useReadContract(balanceOf, { - address: address || "", - contract, - queryOptions: { enabled: !!address }, - }); - const hasBalance = tokenBalanceQuery.data && tokenBalanceQuery.data > 0n; - const [open, setOpen] = useState(false); +}) { return ( - + - - - + + Airdrop tokens - + ); -}; +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-csv-table.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-csv-table.tsx new file mode 100644 index 00000000000..97ffe92827a --- /dev/null +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-csv-table.tsx @@ -0,0 +1,69 @@ +import { AlertCircleIcon } from "lucide-react"; +import { useState } from "react"; +import { PaginationButtons } from "@/components/blocks/pagination-buttons"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { cn } from "@/lib/utils"; + +export function AirdropCSVTable(props: { + data: { address: string; quantity: string; isValid?: boolean }[]; +}) { + const pageSize = 10; + const [page, setPage] = useState(1); + const totalPages = Math.ceil(props.data.length / pageSize); + const paginatedData = props.data.slice( + (page - 1) * pageSize, + page * pageSize, + ); + + return ( +
+ + + + + Address + Quantity + + + + {paginatedData.map((row) => ( + + +
+ {row.address} + {!row.isValid && ( + + )} +
+
+ {row.quantity} +
+ ))} +
+
+
+ {totalPages > 1 && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx index 29a25c98779..e3b609b6916 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx @@ -1,134 +1,98 @@ "use client"; -import { Button } from "chakra/button"; -import { Text } from "chakra/text"; -import { CircleCheckIcon, UploadIcon } from "lucide-react"; -import { type Dispatch, type SetStateAction, useState } from "react"; import { useForm } from "react-hook-form"; +import { toast } from "sonner"; import type { ThirdwebContract } from "thirdweb"; import { transferBatch } from "thirdweb/extensions/erc20"; import { useSendAndConfirmTransaction } from "thirdweb/react"; import { TransactionButton } from "@/components/tx-button"; -import { useTxNotifications } from "@/hooks/useTxNotifications"; +import { parseError } from "@/utils/errorParser"; import { type AirdropAddressInput, AirdropUpload } from "./airdrop-upload"; -interface TokenAirdropFormProps { - contract: ThirdwebContract; - toggle?: Dispatch>; - isLoggedIn: boolean; -} const GAS_COST_PER_ERC20_TRANSFER = 21000; -export const TokenAirdropForm: React.FC = ({ - contract, - toggle, - isLoggedIn, -}) => { - const { handleSubmit, setValue, watch } = useForm<{ - addresses: AirdropAddressInput[]; - }>({ +type FormValues = { + addresses: AirdropAddressInput[]; +}; + +export function TokenAirdropForm(props: { + contract: ThirdwebContract; + isLoggedIn: boolean; +}) { + const { contract, isLoggedIn } = props; + const form = useForm({ defaultValues: { addresses: [] }, }); const sendTransaction = useSendAndConfirmTransaction(); - const addresses = watch("addresses"); - const [airdropFormOpen, setAirdropFormOpen] = useState(false); + const addresses = form.watch("addresses"); // The real number should be slightly higher since there's a lil bit of overhead cost const estimateGasCost = GAS_COST_PER_ERC20_TRANSFER * (addresses || []).length; - const airdropNotifications = useTxNotifications( - "Tokens airdropped successfully", - "Failed to airdrop tokens", - ); + async function onSubmit(data: FormValues) { + const tx = transferBatch({ + batch: data.addresses + .filter((address) => address.quantity !== undefined) + .map((address) => ({ + amount: address.quantity, + to: address.address, + })), + contract, + }); + + const sendTxPromise = sendTransaction.mutateAsync(tx); + toast.promise(sendTxPromise, { + success: "Tokens airdropped successfully", + error: (err) => ({ + message: "Failed to airdrop tokens", + description: parseError(err), + }), + }); + + await sendTxPromise; + } + + if (addresses.length === 0) { + return ( + + form.setValue("addresses", value, { shouldDirty: true }) + } + /> + ); + } return ( -
-
{ - try { - const tx = transferBatch({ - batch: data.addresses - .filter((address) => address.quantity !== undefined) - .map((address) => ({ - amount: address.quantity, - to: address.address, - })), - contract, - }); - await sendTransaction.mutateAsync(tx, { - onError: (error) => { - console.error(error); - }, - onSuccess: () => { - // Close the sheet/modal on success - if (toggle) { - toggle(false); - } - }, - }); - airdropNotifications.onSuccess(); - } catch (err) { - airdropNotifications.onError(err); - console.error(err); - } - })} - > -
- {airdropFormOpen ? ( - setAirdropFormOpen(false)} - setAirdrop={(value) => - setValue("addresses", value, { shouldDirty: true }) - } - /> - ) : ( -
- - {addresses.length > 0 && ( -
- - - {addresses.length} addresses ready to be - airdropped - -
- )} -
+ +
+
+

+ {addresses.length} addresses ready to be airdropped +

+ + {estimateGasCost && ( +

+ This transaction requires at least {estimateGasCost} gas. Since + each chain has a different gas limit, please split this operation + into multiple transactions if necessary. Usually under 10M gas is + safe. +

)}
- {addresses?.length > 0 && !airdropFormOpen && ( - <> - {estimateGasCost && ( - - This transaction requires at least {estimateGasCost} gas. Since - each chain has a different gas limit, please split this - operation into multiple transactions if necessary. Usually under - 10M gas is safe. - - )} - - Airdrop - - - )} - -
+ + + Airdrop + +
+ ); -}; +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx index 45fdbaff54b..ab16732526d 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx @@ -1,90 +1,28 @@ -import { Link } from "@chakra-ui/react"; -import { Button } from "chakra/button"; -import { Heading } from "chakra/heading"; -import { Text } from "chakra/text"; -import { CircleAlertIcon, UploadIcon } from "lucide-react"; -import { useMemo, useRef } from "react"; -import { useDropzone } from "react-dropzone"; -import type { Column } from "react-table"; -import { type ThirdwebClient, ZERO_ADDRESS } from "thirdweb"; -import { UnorderedList } from "@/components/ui/List/List"; +import { ArrowRightIcon, RefreshCcwIcon, TrashIcon } from "lucide-react"; +import type { ThirdwebClient } from "thirdweb"; +import { DownloadableCode } from "@/components/blocks/code/downloadable-code"; +import { DropZone } from "@/components/blocks/drop-zone/drop-zone"; +import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/Spinner/Spinner"; -import { ToolTipLabel } from "@/components/ui/tooltip"; import { useCsvUpload } from "@/hooks/useCsvUpload"; -import { cn } from "@/lib/utils"; -import { CsvDataTable } from "../../_components/csv-data-table"; +import { AirdropCSVTable } from "./airdrop-csv-table"; -export interface AirdropAddressInput { +export type AirdropAddressInput = { address: string; quantity: string; isValid?: boolean; -} -interface AirdropUploadProps { - setAirdrop: (airdrop: AirdropAddressInput[]) => void; - onClose: () => void; - client: ThirdwebClient; -} - -const csvParser = (items: AirdropAddressInput[]): AirdropAddressInput[] => { - return items - .map(({ address, quantity }) => ({ - address: (address || "").trim(), - quantity: (quantity || "1").trim(), - })) - .filter(({ address }) => address !== ""); }; -export const AirdropUpload: React.FC = ({ - setAirdrop, - onClose, - client, -}) => { - const csvUpload = useCsvUpload({ client, csvParser }); - const dropzone = useDropzone({ - onDrop: csvUpload.setFiles, +export function AirdropUpload(props: { + setAirdrop: (airdrop: AirdropAddressInput[]) => void; + client: ThirdwebClient; +}) { + const csvUpload = useCsvUpload({ + client: props.client, + csvParser, }); - - const paginationPortalRef = useRef(null); - const normalizeData = csvUpload.normalizeQuery.data; - const columns = useMemo(() => { - return [ - { - accessor: ({ address, isValid }) => { - if (isValid) { - return address; - } - return ( - -
- -
- {address} -
-
-
- ); - }, - Header: "Address", - }, - { - accessor: ({ quantity }: { quantity: string }) => { - return quantity || "1"; - }, - Header: "Quantity", - }, - ] as Column[]; - }, []); - if (!normalizeData) { return (
@@ -94,121 +32,114 @@ export const AirdropUpload: React.FC = ({ } const onSave = () => { - setAirdrop( + props.setAirdrop( normalizeData.result.map((o) => ({ address: o.resolvedAddress, quantity: o.quantity, })), ); - onClose(); }; return ( -
+
{normalizeData.result.length && csvUpload.rawData.length > 0 ? ( - <> - - columns={columns} - data={csvUpload.normalizeQuery.data.result} - portalRef={paginationPortalRef} +
+ ({ + address: row.address ?? row.resolvedAddress, + quantity: row.quantity, + isValid: row.isValid, + }))} /> -
-
-
+
+ + {csvUpload.normalizeQuery.data.invalidFound ? ( + ) : ( + - {csvUpload.normalizeQuery.data.invalidFound ? ( - - ) : ( - - )} -
+ )}
- +
) : (
-
-
- -
- - {dropzone.isDragActive ? ( - - Drop the files here - - ) : ( - - {csvUpload.noCsv - ? `No valid CSV file found, make sure your CSV includes the "address" & "quantity" column.` - : "Drag & Drop a CSV file here"} - - )} -
-
-
-
- Requirements - + + +
+

Requirements

+
  • Files must contain one .csv file with an address and quantity column, if the quantity column is not provided, that - record will be flagged as invalid. - - Download an example CSV - + record will be flagged as invalid.{" "}
  • Repeated addresses will be removed and only the first found will be kept.
  • - +
+
+ +
+

CSV Example

+
)}
); +} + +const csvParser = (items: AirdropAddressInput[]): AirdropAddressInput[] => { + return items + .map(({ address, quantity }) => ({ + address: address.trim(), + quantity: (quantity || "1").trim(), + })) + .filter(({ address }) => address !== ""); }; + +const exampleAirdropCSV = `address,quantity +0xEb0effdFB4dC5b3d5d3aC6ce29F3ED213E95d675,10 +0x000000000000000000000000000000000000dEaD,1`; diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/burn-button.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/burn-button.tsx index 134a45c7c9f..566bb5323c4 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/burn-button.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/burn-button.tsx @@ -1,140 +1,138 @@ "use client"; -import { FormControl, Input } from "@chakra-ui/react"; -import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form"; -import { Text } from "chakra/text"; +import { zodResolver } from "@hookform/resolvers/zod"; import { FlameIcon } from "lucide-react"; -import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { type ThirdwebContract, toUnits } from "thirdweb"; import * as ERC20Ext from "thirdweb/extensions/erc20"; -import { - useActiveAccount, - useReadContract, - useSendAndConfirmTransaction, -} from "thirdweb/react"; +import { useSendAndConfirmTransaction } from "thirdweb/react"; +import { z } from "zod"; import { TransactionButton } from "@/components/tx-button"; import { Button } from "@/components/ui/button"; +import { DecimalInput } from "@/components/ui/decimal-input"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Sheet, SheetContent, - SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; +import { parseError } from "@/utils/errorParser"; + +const burnSchema = z.object({ + amount: z.string().refine( + (val) => { + const num = Number(val); + return !Number.isNaN(num) && num > 0; + }, + { + message: "Amount must be greater than 0", + }, + ), +}); -interface TokenBurnButtonProps { +export function TokenBurnButton(props: { contract: ThirdwebContract; isLoggedIn: boolean; -} +}) { + const sendConfirmation = useSendAndConfirmTransaction(); + const form = useForm>({ + defaultValues: { amount: "0" }, + resolver: zodResolver(burnSchema), + }); -const BURN_FORM_ID = "token-burn-form"; + async function onSubmit(data: z.infer) { + // TODO: burn should be updated to take amount / amountWei (v6?) + const burnTokensTx = ERC20Ext.burn({ + asyncParams: async () => { + return { + amount: toUnits( + data.amount, + await ERC20Ext.decimals({ contract: props.contract }), + ), + }; + }, + contract: props.contract, + }); -export const TokenBurnButton: React.FC = ({ - contract, - isLoggedIn, - ...restButtonProps -}) => { - const address = useActiveAccount()?.address; + const txPromise = sendConfirmation.mutateAsync(burnTokensTx); - const tokenBalanceQuery = useReadContract(ERC20Ext.balanceOf, { - address: address || "", - contract, - queryOptions: { enabled: !!address }, - }); + toast.promise(txPromise, { + error: (error) => ({ + message: "Failed to burn tokens", + description: parseError(error), + }), + success: "Tokens burned successfully", + }); - const hasBalance = tokenBalanceQuery.data && tokenBalanceQuery.data > 0n; - const [open, setOpen] = useState(false); - const sendConfirmation = useSendAndConfirmTransaction(); - const form = useForm({ defaultValues: { amount: "0" } }); - const decimalsQuery = useReadContract(ERC20Ext.decimals, { contract }); + await txPromise; + } return ( - + - - - - Burn tokens + + + + + Burn tokens + -
-
- - Amount - + +
+ ( + + Amount + + + + + How many would you like to burn? + + + + )} /> - How many would you like to burn? - - {form.formState.errors.amount?.message} - - -
- - Burning these{" "} - {`${Number.parseInt(form.watch("amount")) > 1 ? form.watch("amount") : ""} `} - tokens will remove them from the total circulating supply. This - action is irreversible. - - - - { - if (address) { - // TODO: burn should be updated to take amount / amountWei (v6?) - const tx = ERC20Ext.burn({ - asyncParams: async () => { - return { - amount: toUnits( - data.amount, - await ERC20Ext.decimals({ contract }), - ), - }; - }, - contract, - }); +
+ +

+ Burning these tokens will remove them from the total circulating + supply. This action is irreversible. +

- const promise = sendConfirmation.mutateAsync(tx, { - onError: (error) => { - console.error(error); - }, - onSuccess: () => { - form.reset({ amount: "0" }); - setOpen(false); - }, - }); - toast.promise(promise, { - error: "Failed to burn tokens", - loading: `Burning ${data.amount} token(s)`, - success: "Tokens burned successfully", - }); - } - })} - transactionCount={1} - txChainID={contract.chain.id} - type="submit" - > - Burn Tokens - - + + Burn Tokens + + +
); -}; +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/claim-button.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/claim-button.tsx index 4e00a03c672..147621e90d0 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/claim-button.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/claim-button.tsx @@ -1,165 +1,179 @@ "use client"; -import { FormControl, Input } from "@chakra-ui/react"; -import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { GemIcon } from "lucide-react"; -import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb"; +import { isAddress, type ThirdwebContract } from "thirdweb"; import * as ERC20Ext from "thirdweb/extensions/erc20"; -import { - useActiveAccount, - useReadContract, - useSendAndConfirmTransaction, -} from "thirdweb/react"; +import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react"; +import { z } from "zod"; import { TransactionButton } from "@/components/tx-button"; import { Button } from "@/components/ui/button"; +import { DecimalInput } from "@/components/ui/decimal-input"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; import { Sheet, SheetContent, - SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; -import { useTxNotifications } from "@/hooks/useTxNotifications"; +import { parseError } from "@/utils/errorParser"; -interface TokenClaimButtonProps { - contract: ThirdwebContract; - isLoggedIn: boolean; -} +const claimFormSchema = z.object({ + to: z.string().refine( + (val) => { + if (isAddress(val)) return true; + return false; + }, + { message: "Invalid address" }, + ), + amount: z.string().refine( + (val) => { + // must be a positive number + const num = Number(val); + return !Number.isNaN(num) && num > 0; + }, + { message: "Amount must be a positive number" }, + ), +}); -const CLAIM_FORM_ID = "token-claim-form"; +type ClaimFormValues = z.infer; -export const TokenClaimButton: React.FC = ({ - contract, - isLoggedIn, - ...restButtonProps -}) => { - const [open, setOpen] = useState(false); +export function TokenClaimButton(props: { + contract: ThirdwebContract; + isLoggedIn: boolean; +}) { const sendAndConfirmTransaction = useSendAndConfirmTransaction(); const account = useActiveAccount(); - const form = useForm({ - defaultValues: { amount: "0", to: account?.address }, - }); - const { data: _decimals, isPending } = useReadContract(ERC20Ext.decimals, { - contract, + + const form = useForm({ + defaultValues: { amount: "0", to: account?.address || "" }, + resolver: zodResolver(claimFormSchema), }); - const claimTokensNotifications = useTxNotifications( - "Tokens claimed successfully", - "Failed to claim tokens", - ); + + async function onSubmit(d: ClaimFormValues) { + if (!account) { + return toast.error("Wallet is not connected"); + } + + const transaction = ERC20Ext.claimTo({ + contract: props.contract, + from: account.address, + quantity: d.amount, + to: d.to, + }); + + const approveTx = await ERC20Ext.getApprovalForTransaction({ + account, + transaction, + }); + + if (approveTx) { + const approveTxPromise = sendAndConfirmTransaction.mutateAsync(approveTx); + toast.promise(approveTxPromise, { + error: (error) => ({ + loading: "Approve Spending for claiming tokens", + message: "Failed to approve tokens", + description: parseError(error), + }), + success: "Tokens approved successfully", + }); + + await approveTxPromise; + } + + const claimTxPromise = sendAndConfirmTransaction.mutateAsync(transaction); + + toast.promise(claimTxPromise, { + error: (error) => ({ + loading: "Claiming tokens", + message: "Failed to claim tokens", + description: parseError(error), + }), + success: "Tokens claimed successfully", + }); + + await claimTxPromise; + } + return ( - + - - - + + Claim tokens -
-
- - To Address - + +
+ ( + + To Address + + + + + The wallet address of the recipient + + + + )} /> - Enter the address to claim to. - - {form.formState.errors.to?.message} - - - - Amount - ( + + Amount + + + + + The amount of tokens to claim + + + + )} /> - How many would you like to claim? - - {form.formState.errors.amount?.message} - - -
- - - { - try { - if (!d.to) { - return toast.error( - "Need to specify an address to receive tokens", - ); - } +
- if (!account) { - return toast.error("No account detected"); - } - const transaction = ERC20Ext.claimTo({ - contract, - from: account.address, - quantity: d.amount, - to: d.to, - }); - - const approveTx = await ERC20Ext.getApprovalForTransaction({ - account, - transaction, - }); - - if (approveTx) { - const promise = sendAndConfirmTransaction.mutateAsync( - approveTx, - { - onError: (error) => { - console.error(error); - }, - }, - ); - toast.promise(promise, { - error: "Failed to approve token", - loading: "Approving ERC20 tokens for this claim", - success: "Tokens approved successfully", - }); - - await promise; - } - - await sendAndConfirmTransaction.mutateAsync(transaction, { - onError: (error) => { - console.error(error); - }, - onSuccess: () => { - form.reset({ amount: "0", to: account?.address }); - setOpen(false); - }, - }); - - claimTokensNotifications.onSuccess(); - } catch (error) { - console.error(error); - claimTokensNotifications.onError(error); - } - })} - transactionCount={1} - txChainID={contract.chain.id} - type="submit" - > - Claim Tokens - - +
+ + Claim Tokens + +
+ +
); -}; +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/mint-button.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/mint-button.tsx index 83b0ebbee42..e757fb3b07d 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/mint-button.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/mint-button.tsx @@ -1,125 +1,135 @@ "use client"; -import { FormControl, Input } from "@chakra-ui/react"; -import { FormErrorMessage, FormLabel } from "chakra/form"; +import { zodResolver } from "@hookform/resolvers/zod"; import { PlusIcon } from "lucide-react"; -import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import type { ThirdwebContract } from "thirdweb"; import * as ERC20Ext from "thirdweb/extensions/erc20"; -import { - useActiveAccount, - useReadContract, - useSendAndConfirmTransaction, -} from "thirdweb/react"; +import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react"; +import { z } from "zod"; import { MinterOnly } from "@/components/contracts/roles/minter-only"; import { TransactionButton } from "@/components/tx-button"; import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; import { Sheet, SheetContent, - SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; +import { parseError } from "@/utils/errorParser"; -interface TokenMintButtonProps { - contract: ThirdwebContract; - isLoggedIn: boolean; -} +const mintFormSchema = z.object({ + amount: z + .string() + .min(1, "Amount is required") + .refine((val) => { + const num = Number(val); + return !Number.isNaN(num) && num > 0; + }, "Amount must be a positive number"), +}); -const MINT_FORM_ID = "token-mint-form"; +type MintFormValues = z.infer; -/** - * This component is for minting tokens to a TokenERC20 contract (NOT DropERC20) - */ -export const TokenMintButton: React.FC = ({ - contract, - isLoggedIn, - ...restButtonProps -}) => { - const [open, setOpen] = useState(false); +export function TokenMintButton(props: { + contract: ThirdwebContract; + isLoggedIn: boolean; +}) { const address = useActiveAccount()?.address; - const { data: tokenDecimals } = useReadContract(ERC20Ext.decimals, { - contract, - }); const sendAndConfirmTransaction = useSendAndConfirmTransaction(); - const form = useForm({ defaultValues: { amount: "0" } }); + const form = useForm({ + resolver: zodResolver(mintFormSchema), + defaultValues: { amount: "" }, + mode: "onChange", + }); + + async function handleSubmit(values: MintFormValues) { + if (!address) { + toast.error("No wallet connected"); + return; + } + + const mintTx = ERC20Ext.mintTo({ + amount: values.amount, + contract: props.contract, + to: address, + }); + + const mintTxPromise = sendAndConfirmTransaction.mutateAsync(mintTx); + toast.promise(mintTxPromise, { + error: (err) => ({ + message: "Failed to mint tokens", + description: parseError(err), + }), + success: "Tokens minted successfully", + }); + + await mintTxPromise; + } + return ( - - + + - - - + + + Mint additional tokens -
{ - if (!address) { - return toast.error("No wallet connected"); - } - const transaction = ERC20Ext.mintTo({ - amount: d.amount, - contract, - to: address, - }); - const promise = sendAndConfirmTransaction.mutateAsync( - transaction, - { - onError: (error) => { - console.error(error); - }, - onSuccess: () => { - form.reset({ amount: "0" }); - setOpen(false); - }, - }, - ); - toast.promise(promise, { - error: "Failed to mint tokens", - loading: "Minting tokens", - success: "Tokens minted successfully", - }); - })} - > - - Additional Supply - + + ( + + Additional Supply + + + + + The amount of tokens to mint + + + + )} /> - - {form.formState.errors?.amount?.message} - - -
- - - Mint Tokens - - + +
+ + Mint Tokens + +
+ +
); -}; +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/transfer-button.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/transfer-button.tsx index 604948016fe..1fa5613834d 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/transfer-button.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/transfer-button.tsx @@ -1,138 +1,165 @@ "use client"; -import { FormControl, Input } from "@chakra-ui/react"; -import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form"; -import { SendIcon } from "lucide-react"; -import { useState } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ForwardIcon } from "lucide-react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb"; +import { isAddress, type ThirdwebContract, ZERO_ADDRESS } from "thirdweb"; import * as ERC20Ext from "thirdweb/extensions/erc20"; -import { - useActiveAccount, - useReadContract, - useSendAndConfirmTransaction, -} from "thirdweb/react"; +import { useSendAndConfirmTransaction } from "thirdweb/react"; +import { z } from "zod"; import { SolidityInput } from "@/components/solidity-inputs"; import { TransactionButton } from "@/components/tx-button"; import { Button } from "@/components/ui/button"; +import { DecimalInput } from "@/components/ui/decimal-input"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Sheet, SheetContent, - SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; +import { parseError } from "@/utils/errorParser"; + +const transferFormSchema = z.object({ + to: z.string().refine( + (val) => { + if (isAddress(val)) { + return true; + } + return false; + }, + { + message: "Invalid address", + }, + ), + amount: z.string().refine( + (val) => { + // must be a positive number + const num = Number(val); + return !Number.isNaN(num) && num > 0; + }, + { message: "Amount must be a positive number" }, + ), +}); -interface TokenTransferButtonProps { +type TransferFormValues = z.infer; + +export function TokenTransferButton(props: { contract: ThirdwebContract; isLoggedIn: boolean; -} +}) { + const form = useForm({ + defaultValues: { amount: "0", to: "" }, + resolver: zodResolver(transferFormSchema), + }); + const sendAndConfirmTransaction = useSendAndConfirmTransaction(); -const TRANSFER_FORM_ID = "token-transfer-form"; + async function onSubmit(values: TransferFormValues) { + const transferTx = ERC20Ext.transfer({ + amount: values.amount, + contract: props.contract, + to: values.to, + }); -export const TokenTransferButton: React.FC = ({ - contract, - isLoggedIn, - ...restButtonProps -}) => { - const address = useActiveAccount()?.address; - const tokenBalanceQuery = useReadContract(ERC20Ext.balanceOf, { - address: address || "", - contract, - queryOptions: { enabled: !!address }, - }); - const form = useForm({ defaultValues: { amount: "0", to: "" } }); - const hasBalance = tokenBalanceQuery.data && tokenBalanceQuery.data > 0n; - const decimalsQuery = useReadContract(ERC20Ext.decimals, { contract }); - const sendConfirmation = useSendAndConfirmTransaction(); - const [open, setOpen] = useState(false); + const transferTxPromise = sendAndConfirmTransaction.mutateAsync(transferTx); + + toast.promise(transferTxPromise, { + error: (error) => ({ + message: "Failed to transfer tokens", + description: parseError(error), + }), + success: "Successfully transferred tokens", + }); + + await transferTxPromise; + } return ( - + - - - - Transfer tokens + + + + Transfer tokens + -
-
- - To Address - + +
+ ( + + Address + + + + + The wallet address of the recipient + + + + )} /> - Enter the address to transfer to. - - {form.formState.errors.to?.message} - - - - Amount - ( + + Amount + + + + + Number of tokens to transfer + + + + )} /> - - How many would you like to transfer? - - - {form.formState.errors.amount?.message} - - -
- - - { - const transaction = ERC20Ext.transfer({ - amount: d.amount, - contract, - to: d.to, - }); - const promise = sendConfirmation.mutateAsync(transaction, { - onError: (error) => { - console.error(error); - }, - onSuccess: () => { - form.reset({ amount: "0", to: "" }); - setOpen(false); - }, - }); - toast.promise(promise, { - error: "Failed to transfer tokens", - loading: "Transferring tokens", - success: "Successfully transferred tokens", - }); - })} - transactionCount={1} - txChainID={contract.chain.id} - type="submit" - > - Transfer Tokens - - +
+
+ + Transfer Tokens + +
+ +
); -}; +} diff --git a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/shared-page.tsx b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/shared-page.tsx index 81aa34ab0bb..5d97ac60ead 100644 --- a/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/shared-page.tsx +++ b/apps/dashboard/src/app/(app)/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/shared-page.tsx @@ -52,11 +52,18 @@ export async function SharedContractTokensPage(props: { info.serverContract, ); + if (!supportedERCs.isERC20) { + redirectToContractLandingPage({ + chainIdOrSlug: props.chainIdOrSlug, + contractAddress: props.contractAddress, + projectMeta: props.projectMeta, + }); + } + return ( diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/upload-nfts/batch-upload/batch-upload-instructions.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/upload-nfts/batch-upload/batch-upload-instructions.tsx index 87abd977c2a..932af01f773 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/upload-nfts/batch-upload/batch-upload-instructions.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/nft/upload-nfts/batch-upload/batch-upload-instructions.tsx @@ -1,10 +1,7 @@ -import { ArrowDownToLineIcon } from "lucide-react"; import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { CodeClient } from "@/components/ui/code/code.client"; +import { DownloadableCode } from "@/components/blocks/code/downloadable-code"; import { InlineCode } from "@/components/ui/inline-code"; import { TabButtons } from "@/components/ui/tabs"; -import { handleDownload } from "../../../_common/download-file-button"; export function BatchUploadInstructions() { const [tab, setTab] = useState<"csv" | "json">("csv"); @@ -75,7 +72,7 @@ export function BatchUploadInstructions() { "All other columns will be treated as Attributes. For example: See 'foo', 'bar' and 'bazz' below."}

- and{" "} {" "} {tab === "csv" ? "columns" : "properties"}.{" "} - column.{" "} - - - - -
- ); -} diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/distribution/token-airdrop.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/distribution/token-airdrop.tsx index dbd83f8253c..e715c313f9d 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/distribution/token-airdrop.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/token/distribution/token-airdrop.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { useState } from "react"; import type { ThirdwebClient } from "thirdweb"; +import { DownloadFileButton } from "@/components/blocks/download-file-button"; import { DropZone } from "@/components/blocks/drop-zone/drop-zone"; import { Button } from "@/components/ui/button"; import { DynamicHeight } from "@/components/ui/DynamicHeight"; @@ -37,7 +38,6 @@ import { import { Textarea } from "@/components/ui/textarea"; import { useCsvUpload } from "@/hooks/useCsvUpload"; import { cn } from "@/lib/utils"; -import { DownloadFileButton } from "../../_common/download-file-button"; import type { TokenDistributionForm } from "../_common/form"; type AirdropAddressInput = {