@@ -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
+
+ Airdrop
-
-
+
+
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 (
-
-
);
-};
+}
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 (
-
-
-
- );
- },
- 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.reset();
+ }}
+ >
+
+ Reset
+
+ {csvUpload.normalizeQuery.data.invalidFound ? (
{
- csvUpload.reset();
+ csvUpload.removeInvalid();
}}
- w={{ base: "100%", md: "auto" }}
>
- Reset
+
+ Remove invalid
+
+ ) : (
+
+ Next
+
- {csvUpload.normalizeQuery.data.invalidFound ? (
-
{
- csvUpload.removeInvalid();
- }}
- w={{ base: "100%", md: "auto" }}
- >
- Remove invalid
-
- ) : (
-
- Next
-
- )}
-
+ )}
- >
+
) : (
-
-
-
-
-
- {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
-
-
- Burn tokens
+
+
+
+
+ Burn tokens
+
-
-
+
+
+ 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
+
+ Claim
-
-
+
+
Claim 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
+
+ Mint
-
-
+
+
+
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
+
+ Transfer
-
-
- 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.{" "}
-
-
-
- {
- handleDownload({
- fileContent: props.code,
- fileFormat: props.lang === "csv" ? "text/csv" : "application/json",
- fileNameWithExtension: props.fileNameWithExtension,
- });
- }}
- size="sm"
- variant="outline"
- >
-
-
-
- );
-}
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 = {