diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/airdrop-tab.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/airdrop-tab.tsx index 36b1d755374..7db74fa7b87 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/airdrop-tab.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/nfts/[tokenId]/components/airdrop-tab.tsx @@ -1,17 +1,28 @@ "use client"; -import { Flex, useDisclosure } from "@chakra-ui/react"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { Flex } from "@chakra-ui/react"; import { TransactionButton } from "components/buttons/TransactionButton"; import { useTrack } from "hooks/analytics/useTrack"; import { useTxNotifications } from "hooks/useTxNotifications"; import { UploadIcon } from "lucide-react"; +import { useState } from "react"; import { useForm } from "react-hook-form"; import type { ThirdwebContract } from "thirdweb"; import { multicall } from "thirdweb/extensions/common"; import { balanceOf, encodeSafeTransferFrom } from "thirdweb/extensions/erc1155"; import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react"; import { Button, Text } from "tw-components"; -import { type AirdropAddressInput, AirdropUpload } from "./airdrop-upload"; +import { + type AirdropAddressInput, + AirdropUpload, +} from "../../../tokens/components/airdrop-upload"; interface AirdropTabProps { contract: ThirdwebContract; @@ -31,11 +42,7 @@ const AirdropTab: React.FC = ({ contract, tokenId }) => { defaultValues: { addresses: [] }, }); const trackEvent = useTrack(); - - const { isOpen, onOpen, onClose } = useDisclosure(); - const { mutate, isPending } = useSendAndConfirmTransaction(); - const { onSuccess, onError } = useTxNotifications( "Airdrop successful", "Error transferring", @@ -43,6 +50,7 @@ const AirdropTab: React.FC = ({ contract, tokenId }) => { ); const addresses = watch("addresses"); + const [open, setOpen] = useState(false); return (
@@ -109,22 +117,29 @@ const AirdropTab: React.FC = ({ contract, tokenId }) => { >
- - setValue("addresses", value, { shouldDirty: true }) - } - /> - + + + + + + + Aidrop NFTs + + setOpen(false)} + setAirdrop={(value) => + setValue("addresses", value, { shouldDirty: true }) + } + /> + + void; - isOpen: boolean; - onClose: () => void; -} - -export const AirdropUpload: React.FC = ({ - setAirdrop, - isOpen, - onClose, -}) => { - const client = useThirdwebClient(); - const [validAirdrop, setValidAirdrop] = useState([]); - const [airdropData, setAirdropData] = useState([]); - const [noCsv, setNoCsv] = useState(false); - const [invalidFound, setInvalidFound] = useState(false); - - const reset = useCallback(() => { - setValidAirdrop([]); - setNoCsv(false); - }, []); - - const _onClose = useCallback(() => { - onClose(); - }, [onClose]); - - const onDrop = useCallback["onDrop"]>( - (acceptedFiles) => { - setNoCsv(false); - - const csv = acceptedFiles.find( - (f) => csvMimeTypes.includes(f.type) || f.name?.endsWith(".csv"), - ); - if (!csv) { - console.error( - "No valid CSV file found, make sure you have an address column.", - ); - setNoCsv(true); - return; - } - - Papa.parse(csv, { - header: true, - complete: (results) => { - const data: AirdropAddressInput[] = ( - results.data as AirdropAddressInput[] - ) - .map(({ address, quantity }) => ({ - address: (address || "").trim(), - quantity: (quantity || "1").trim(), - })) - .filter(({ address }) => address !== ""); - - if (!data[0]?.address) { - setNoCsv(true); - return; - } - - setValidAirdrop(data); - }, - }); - }, - [], - ); - - // FIXME: this can be a mutation or query instead! - // eslint-disable-next-line no-restricted-syntax - useEffect(() => { - if (validAirdrop.length === 0) { - return setAirdropData([]); - } - - const normalizeAddresses = async (snapshot: AirdropAddressInput[]) => { - const normalized = await Promise.all( - snapshot.map(async ({ address, ...rest }) => { - let isValid = true; - let resolvedAddress = address; - - try { - resolvedAddress = isAddress(address) - ? address - : await resolveAddress({ name: address, client }); - isValid = !!resolvedAddress; - } catch { - isValid = false; - } - - return { - address, - resolvedAddress, - isValid, - ...rest, - }; - }), - ); - - const seen = new Set(); - const filteredData = normalized.filter((el) => { - const duplicate = seen.has(el.resolvedAddress); - seen.add(el.resolvedAddress); - return !duplicate; - }); - - const valid = filteredData.filter(({ isValid }) => isValid); - const invalid = filteredData.filter(({ isValid }) => !isValid); - - if (invalid?.length > 0) { - setInvalidFound(true); - } - const ordered = [...invalid, ...valid]; - setAirdropData(ordered); - }; - normalizeAddresses(validAirdrop); - }, [validAirdrop, client]); - - const removeInvalid = useCallback(() => { - const filteredData = airdropData.filter(({ isValid }) => isValid); - setValidAirdrop(filteredData); - setInvalidFound(false); - }, [airdropData]); - - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - onDrop, - }); - - const paginationPortalRef = useRef(null); - - const onSave = () => { - setAirdrop(airdropData); - onClose(); - }; - - return ( - - - - - -
- - - {validAirdrop.length ? "Edit" : "Upload"} Airdrop - -
-
-
-
- - {validAirdrop.length > 0 ? ( - - ) : ( - - - -
-
- -
- - {isDragActive ? ( - - Drop the files here - - ) : ( - - {noCsv - ? `No valid CSV file found, make sure your CSV includes the "address" column.` - : "Drag & Drop a CSV file here"} - - )} -
-
-
-
- Requirements - -
  • - Files must contain one .csv file with an address - and quantity column, if the quantity column is not - provided, it will default to 1 NFT per wallet. -{" "} - - Download an example CSV - -
  • -
  • - Repeated addresses will be removed and only the first - found will be kept. -
  • -
    -
    -
    -
    -
    - )} - - - - - - - - {invalidFound ? ( - - ) : ( - - )} - - - - -
    -
    - ); -}; - -interface AirdropTableProps { - data: AirdropAddressInput[]; - portalRef: React.RefObject; -} - -const AirdropTable: React.FC = ({ data, portalRef }) => { - const columns = useMemo(() => { - return [ - { - Header: "Address", - accessor: ({ address, isValid }) => { - if (isValid) { - return address; - } - return ( -
    - -
    - - - {address} - -
    -
    -
    - ); - }, - }, - { - Header: "Quantity", - accessor: ({ quantity }: { quantity: string }) => { - return quantity || "1"; - }, - }, - ] as Column[]; - }, []); - - 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: { - pageSize: 50, - pageIndex: 0, - }, - }, - // old package: this will be removed - // eslint-disable-next-line react-compiler/react-compiler - usePagination, - ); - - // Render the UI for your table - return ( - - - - - {headerGroups.map((headerGroup, headerGroupIndex) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: FIXME - - {headerGroup.headers.map((column, columnIndex) => ( - - ))} - - ))} - - - {page.map((row, rowIndex) => { - prepareRow(row); - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: FIXME - - {row.cells.map((cell, cellIndex) => ( - - ))} - - ); - })} - -
    - - {column.render("Header")} - -
    - {cell.render("Cell")} -
    -
    - -
    -
    - } - onClick={() => gotoPage(0)} - /> - } - onClick={() => previousPage()} - /> -

    - Page {pageIndex + 1} of{" "} - {pageOptions.length} -

    - } - onClick={() => nextPage()} - /> - } - onClick={() => gotoPage(pageCount - 1)} - /> - - -
    -
    -
    -
    - ); -}; diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx index 963d8595cf4..9a4f1253558 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-form.tsx @@ -10,10 +10,7 @@ import type { ThirdwebContract } from "thirdweb"; import { transferBatch } from "thirdweb/extensions/erc20"; import { useSendAndConfirmTransaction } from "thirdweb/react"; import { Button, Text } from "tw-components"; -import { - AirdropUploadERC20, - type ERC20AirdropAddressInput, -} from "./airdrop-upload-erc20"; +import { type AirdropAddressInput, AirdropUpload } from "./airdrop-upload"; interface TokenAirdropFormProps { contract: ThirdwebContract; toggle?: Dispatch>; @@ -25,7 +22,7 @@ export const TokenAirdropForm: React.FC = ({ toggle, }) => { const { handleSubmit, setValue, watch } = useForm<{ - addresses: ERC20AirdropAddressInput[]; + addresses: AirdropAddressInput[]; }>({ defaultValues: { addresses: [] }, }); @@ -89,7 +86,7 @@ export const TokenAirdropForm: React.FC = ({ >
    {airdropFormOpen ? ( - setAirdropFormOpen(false)} setAirdrop={(value) => setValue("addresses", value, { shouldDirty: true }) diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-table.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-table.tsx new file mode 100644 index 00000000000..75b49235b80 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-table.tsx @@ -0,0 +1,201 @@ +import { ToolTipLabel } from "@/components/ui/tooltip"; +import { + IconButton, + Portal, + Select, + Table, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@chakra-ui/react"; +import { + ChevronFirstIcon, + ChevronLastIcon, + ChevronLeftIcon, + ChevronRightIcon, + CircleAlertIcon, +} from "lucide-react"; +import { useMemo } from "react"; +import { type Column, usePagination, useTable } from "react-table"; +import { ZERO_ADDRESS } from "thirdweb"; +import { Text } from "tw-components"; +import type { AirdropAddressInput } from "./airdrop-upload"; + +interface AirdropTableProps { + data: AirdropAddressInput[]; + portalRef: React.RefObject; +} + +/** + * A view-only component, used to show the airdrop records from the uploaded csv + * This component is shared between the Aidrop features of ERC1155 and ERC20 + */ +export const AirdropTable: React.FC = ({ + data, + portalRef, +}) => { + const columns = useMemo(() => { + return [ + { + Header: "Address", + accessor: ({ address, isValid }) => { + if (isValid) { + return address; + } + return ( + +
    + +
    + {address} +
    +
    +
    + ); + }, + }, + { + Header: "Quantity", + accessor: ({ quantity }: { quantity: string }) => { + return quantity || "1"; + }, + }, + ] as Column[]; + }, []); + 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: { + pageSize: 50, + pageIndex: 0, + }, + }, + // old package: this will be removed + // eslint-disable-next-line react-compiler/react-compiler + usePagination, + ); + // Render the UI for your table + return ( + <> + + + {headerGroups.map((headerGroup, headerGroupIndex) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: FIXME + + {headerGroup.headers.map((column, columnIndex) => ( + + ))} + + ))} + + + {page.map((row, rowIndex) => { + prepareRow(row); + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: FIXME + + {row.cells.map((cell, cellIndex) => ( + + ))} + + ); + })} + +
    + + {column.render("Header")} + +
    + {cell.render("Cell")} +
    + {/* Only need to show the Pagination components if we have more than 25 records */} + {data.length > 0 && ( + +
    +
    + } + onClick={() => gotoPage(0)} + /> + } + onClick={() => previousPage()} + /> +

    + Page {pageIndex + 1} of{" "} + {pageOptions.length} +

    + } + onClick={() => nextPage()} + /> + } + onClick={() => gotoPage(pageCount - 1)} + /> + +
    +
    +
    + )} + + ); +}; diff --git a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload-erc20.tsx b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx similarity index 95% rename from apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload-erc20.tsx rename to apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx index 6c60fc84365..89d57a02b04 100644 --- a/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload-erc20.tsx +++ b/apps/dashboard/src/app/(dashboard)/(chain)/[chain_id]/[contractAddress]/tokens/components/airdrop-upload.tsx @@ -33,17 +33,17 @@ import { isAddress } from "thirdweb/utils"; import { Button, Heading, Text } from "tw-components"; import { csvMimeTypes } from "utils/batch"; -export interface ERC20AirdropAddressInput { +export interface AirdropAddressInput { address: string; quantity: string; isValid?: boolean; } interface AirdropUploadProps { - setAirdrop: (airdrop: ERC20AirdropAddressInput[]) => void; + setAirdrop: (airdrop: AirdropAddressInput[]) => void; onClose: () => void; } async function checkIsAddress( - item: ERC20AirdropAddressInput, + item: AirdropAddressInput, thirdwebClient: ThirdwebClient, ) { const { address, ...rest } = item; @@ -75,10 +75,7 @@ async function checkIsAddress( }; } function processAirdropData( - data: ( - | (ERC20AirdropAddressInput & { resolvedAddress: string }) - | undefined - )[], + data: ((AirdropAddressInput & { resolvedAddress: string }) | undefined)[], ) { const seen = new Set(); const filteredData = data @@ -95,14 +92,12 @@ function processAirdropData( return ordered; } -export const AirdropUploadERC20: React.FC = ({ +export const AirdropUpload: React.FC = ({ setAirdrop, onClose, }) => { const thirdwebClient = useThirdwebClient(); - const [validAirdrop, setValidAirdrop] = useState( - [], - ); + const [validAirdrop, setValidAirdrop] = useState([]); const [noCsv, setNoCsv] = useState(false); const reset = useCallback(() => { setValidAirdrop([]); @@ -124,8 +119,8 @@ export const AirdropUploadERC20: React.FC = ({ Papa.parse(csv, { header: true, complete: (results) => { - const data: ERC20AirdropAddressInput[] = ( - results.data as ERC20AirdropAddressInput[] + const data: AirdropAddressInput[] = ( + results.data as AirdropAddressInput[] ) .map(({ address, quantity }) => ({ address: (address || "").trim(), @@ -278,7 +273,7 @@ export const AirdropUploadERC20: React.FC = ({ }; interface AirdropTableProps { - data: ERC20AirdropAddressInput[]; + data: AirdropAddressInput[]; portalRef: React.RefObject; } const AirdropTable: React.FC = ({ data, portalRef }) => { @@ -316,7 +311,7 @@ const AirdropTable: React.FC = ({ data, portalRef }) => { return quantity || "1"; }, }, - ] as Column[]; + ] as Column[]; }, []); const { getTableProps,