diff --git a/components/containers/AddressContainer.tsx b/components/containers/AddressContainer.tsx index 3c3f366..7d3888a 100644 --- a/components/containers/AddressContainer.tsx +++ b/components/containers/AddressContainer.tsx @@ -8,10 +8,12 @@ interface Props { tokenSymbol: string; displayModal: () => void; setRecipients: Dispatch>; + holderAddresses?: string[]; + isFriendTech?: boolean; } const AddressContainer = (props: Props) => { - const { show, isERC721, tokenSymbol, displayModal, setRecipients } = props; + const { show, isERC721, tokenSymbol, displayModal, setRecipients, holderAddresses, isFriendTech } = props; const [showCSVUpload, setShowCSVUpload] = useState(false); @@ -42,12 +44,13 @@ const AddressContainer = (props: Props) => { isERC721={isERC721} displayModal={displayModal} setRecipients={setRecipients} + holderAddresses={holderAddresses} /> ); diff --git a/components/containers/FriendtechContainer.tsx b/components/containers/FriendtechContainer.tsx new file mode 100644 index 0000000..99a2087 --- /dev/null +++ b/components/containers/FriendtechContainer.tsx @@ -0,0 +1,205 @@ +import React, { + Dispatch, + ChangeEvent, + SetStateAction, + useEffect, + useRef, + useState, + useMemo, +} from "react"; +import { toast } from "sonner"; +import { useChainId, useNetwork } from "wagmi"; +import { useBalance, useAccount } from "wagmi"; +import { AirdropRecipient } from "../types/airdrop"; +import { ModalSelector } from "../types/modals"; +import { recipientsParser } from "../types/parsers"; +import ConfirmModal from "../ui/modals/ConfirmModal"; +import CongratsModal from "../ui/modals/CongratsModal"; +import AddressContainer from "./AddressContainer"; +import useAirdrop from "../hooks/eth/useAirdrop"; +import { formatUnits } from "viem"; +import useNetworkNativeToken from "../hooks/networkNativeToken"; + +interface Props { + holders: string[]; + allowance?: BigInt; + errorMessage: string | false; + isERC721: boolean; + loadingMessage: string | false; + openModal: false | ModalSelector; + displayModal: () => void; + handleTokenAddressChange: (e: ChangeEvent) => void; + resetForm: () => void; + setErrorMessage: Dispatch>; + setOpenModal: Dispatch>; + setRecipients: Dispatch>; +} + +const FriendtechContainer = (props: Props) => { + const { + holders, + isERC721, + displayModal, + handleTokenAddressChange, + resetForm, + openModal, + setOpenModal + } = props; + + const inputRef = useRef(null); + const chainId = useChainId(); + const { address, isConnected } = useAccount(); + + const [recipients, setRecipients] = useState<[string, string][]>([]); + const [loadingMessage, setLoadingMessage] = useState(false); + const [errorMessage, setErrorMessage] = useState(false); + + const displayMessage = (message: string, type?: "success" | "error") => { + if (type === "error") { + setLoadingMessage(false); + setErrorMessage(message); + toast[type](message); + } else if (type === "success") { + setLoadingMessage(message); + setErrorMessage(false); + toast[type](message); + } else { + setLoadingMessage(message); + setErrorMessage(false); + toast(message); + } + }; + + const { nativeToken } = useNetworkNativeToken(); + + const { data: balance } = useBalance({ + address: address, + onError: (error) => displayMessage(error.message, "error"), + chainId, + }); + + const { chain } = useNetwork(); + + const parsedRecipients = useMemo(() => { + try { + return ( + recipients.length + ? recipientsParser(balance?.decimals).parse(recipients) + : [] + ) as AirdropRecipient[]; + } catch (e) { + displayMessage((e as Error).message, "error"); + return [] as AirdropRecipient[]; + } + }, [balance?.decimals, recipients]); + + const { write: airdropWrite } = useAirdrop( + parsedRecipients, + () => displayMessage("Airdrop transaction pending..."), + function onSuccess() { + displayMessage("Airdrop transaction successful!", "success"); + setOpenModal("congrats"); + }, + function onError(error: string) { + displayMessage(error, "error"); + } + ); + + const pillStyle = + "flex items-center border rounded-md md:rounded-full md:inline-block py-2 px-2 md:px-3 text-sm mt-2 min-h-fit"; + + // Focus on the input when rendered + useEffect(() => inputRef.current?.focus(), []); + + return ( +
+ {openModal === "confirm" && ( + setOpenModal(val ? "confirm" : '')} + symbol="ETH" + recipients={parsedRecipients} + balanceData={balance} + loadingMessage={loadingMessage ? loadingMessage : undefined} + errorMessage={errorMessage ? errorMessage : undefined} + setErrorMessage={setErrorMessage} + onSubmit={() => { + try { + airdropWrite?.(); + } catch (error) { + console.log("ETH: ", error); + } + }} + /> + )} + + {openModal === "congrats" && ( +
+ { + resetForm(); + setOpenModal(val ? "congrats" : ''); + }} + resetForm={resetForm} + /> +
+ )} + +
+

+ Enter your Friendtech wallet address: +

+ + + +
+
+ Friendtech +
+ +
+ {holders ? ( + <>{`You have ${ + holders.length > 0 ? holders.length : "0" + } key holders to airdrop to`} + ) : ( + `Enter your friendtech wallet address to airdrop.` + )} +
+
+ + {(() => { + if (!holders || holders.length === 0) { + return ( +

+ Please enter your Friendtech wallet address to perform an airdrop with {nativeToken}{" "} + on {chain?.name}. +

+ ); + } else { + return ( + + ); + } + })()} +
+
+ ); +}; + +export default FriendtechContainer; diff --git a/components/hooks/friendtech/useFriendtechData.ts b/components/hooks/friendtech/useFriendtechData.ts new file mode 100644 index 0000000..eaf9ce8 --- /dev/null +++ b/components/hooks/friendtech/useFriendtechData.ts @@ -0,0 +1,34 @@ +import { useState, useEffect } from "react"; + +export default function useFriendtechData(accountAddress: String) { + + const [holders, setHolders] = useState([]); + const [error, setError] = useState(null); + + const apiURL = `https://prod-api.kosetto.com/users/${accountAddress.toString()}/token/holders`; + + useEffect(() => { + const fetchData = async () => { + try { + const response = await fetch(apiURL); + + if (!response.ok) { + throw new Error("Failed to fetch"); + } + + const data = await response.json(); + console.log(data); + + // Map over the data to extract the desired properties + const extractedData = data.users.map((user: any) => user.address); + + setHolders(extractedData); + } catch (err) { + setError(err); + } + }; + + fetchData(); + }, [apiURL]); + return { holders, error }; +} diff --git a/components/providers/FriendtechProvider.tsx b/components/providers/FriendtechProvider.tsx new file mode 100644 index 0000000..8263a8a --- /dev/null +++ b/components/providers/FriendtechProvider.tsx @@ -0,0 +1,138 @@ +import React, { useState, useMemo, ChangeEvent } from "react"; +import { toast } from "sonner"; +import { Address } from "wagmi"; +import FriendtechContainer from "../containers/FriendtechContainer"; +import useApproveAllowance from "../hooks/erc20/useApproveAllowance"; +import useTokenData from "../hooks/erc20/useTokenData"; +import useFriendtechData from "../hooks/friendtech/useFriendtechData"; +import useApproveAirdrop from "../hooks/erc20/useApproveAirdrop"; +import { AirdropRecipient, IAirdropEthProps } from "../types/airdrop"; +import { ModalSelector } from "../types/modals"; +import { recipientsParser } from "../types/parsers"; +import { formatUnits } from "viem"; + +export default function Friendtech(props: IAirdropEthProps) { + const { contractAddress = null, setSelected, setContractAddress } = props; + + // tokenAddress is the contract token address (e.g. the WETH contract) + const [tokenAddress, setTokenAddress] = useState
( + (contractAddress as Address) || "" + ); + const [recipients, setRecipients] = useState<[string, string][]>([]); + const [openModal, setOpenModal] = useState(""); + const [loadingMessage, setLoadingMessage] = useState(false); + const [errorMessage, setErrorMessage] = useState(false); + + const pattern = useMemo(() => /^0x[a-fA-F0-9]{40}$/, []); + + const displayMessage = (message: string, type?: "success" | "error") => { + const errorMsg = type === "error" ? message : false; + setErrorMessage(errorMsg); + + const loadingMsg = type === "error" ? false : message; + setLoadingMessage(loadingMsg); + + type === "success" || type === "error" + ? toast[type](message) + : toast(message); + }; + + const displayModal = () => { + setOpenModal("confirm"); + setLoadingMessage(false); + setErrorMessage(false); + }; + + const { holders } = useFriendtechData(tokenAddress); + +// const parsedRecipients = useMemo(() => { +// try { +// return ( +// recipients.length +// ? recipientsParser(tokenDecimals).parse(recipients) +// : [] +// ) as AirdropRecipient[]; +// } catch (e) { +// displayMessage((e as Error).message, "error"); +// return [] as AirdropRecipient[]; +// } +// }, [tokenDecimals, recipients]); + + // low-priority todo: improve the typing without casting +// const totalAllowance = useMemo(() => { +// return parsedRecipients.reduce((acc: BigInt, { amount }) => { +// const a = BigInt(acc.toString()); +// const b = BigInt(amount.toString()); + +// return a + b; +// }, BigInt(0)); +// }, [parsedRecipients]); + +// const { write: airdropWrite } = useApproveAirdrop( +// tokenAddress, +// parsedRecipients, +// () => displayMessage("Airdrop transaction pending..."), +// function onSuccess() { +// displayMessage("Airdrop transaction successful!", "success"); +// setOpenModal("congrats"); +// }, +// function onError(error) { +// displayMessage(error, "error"); +// } +// ); + +// const { allowance, write: approveWrite } = useApproveAllowance( +// tokenAddress, +// totalAllowance, +// () => displayMessage("Approval transaction pending..."), // on Pending +// function onSuccess() { +// displayMessage("Approval transaction submitted!", "success"); +// airdropWrite?.(); +// }, +// function onError(error) { +// displayMessage(error, "error"); +// } +// ); + +// // Switch over to ERC-721 if the contract entered is a 721 +// if (isERC721) { +// setContractAddress?.(tokenAddress); +// setSelected("ERC721"); +// return null; +// } + + const handleTokenAddressChange = (e: ChangeEvent) => { + const rawInput = e.target.value; + if (rawInput.length > 42) return; + + const address = rawInput as Address; + setTokenAddress(address); + setContractAddress?.(address); + + if (!pattern.test(rawInput)) { + setSelected("UNSET"); + } + }; + + const resetForm = () => { + setSelected("UNSET"); + setRecipients([]); + setContractAddress?.(""); + }; + + return ( + + ); +} diff --git a/components/providers/NewProvider.tsx b/components/providers/NewProvider.tsx index 6a6f166..01d2b39 100644 --- a/components/providers/NewProvider.tsx +++ b/components/providers/NewProvider.tsx @@ -112,7 +112,7 @@ const NewProvider = (props: IAirdropEthProps) => { onChange={handleTokenAddressChange} onKeyDown={handleKeyDown} onPaste={handlePaste} - placeholder={'0x'} + placeholder={"0x"} /> + + {contractAddress && contractAddress.length === 42 && notAContractAddress && (

- Oops! That doesn't look like a valid contract address on{' '} + Oops! That doesn't look like a valid contract address on{" "} {chain?.name}. Double check the address and please try again.

)} diff --git a/components/types/airdrop.ts b/components/types/airdrop.ts index f538e2d..983cf4d 100644 --- a/components/types/airdrop.ts +++ b/components/types/airdrop.ts @@ -4,6 +4,7 @@ export const AirdropType = { ERC20: 'ERC20', ERC721: 'ERC721', ETH: 'ETH', + FRIENDTECH: 'FRIENDTECH', unset: 'UNSET' } as const; diff --git a/components/types/modals.ts b/components/types/modals.ts index 48a6ea3..a89a2d8 100644 --- a/components/types/modals.ts +++ b/components/types/modals.ts @@ -1 +1 @@ -export type ModalSelector = 'confirm' | 'congrats'; +export type ModalSelector = 'confirm' | 'congrats' | ''; diff --git a/components/ui/MagicTextArea.tsx b/components/ui/MagicTextArea.tsx index 340ee2f..5b75361 100644 --- a/components/ui/MagicTextArea.tsx +++ b/components/ui/MagicTextArea.tsx @@ -1,6 +1,7 @@ import React, { FC, useState, + useEffect, Dispatch, SetStateAction, ChangeEvent, @@ -13,16 +14,39 @@ interface Props { isERC721: boolean; displayModal: () => void; setRecipients: Dispatch>; + holderAddresses?: string[]; } const MagicTextArea: FC = (props: Props) => { - const { isERC721, displayModal, setRecipients } = props; + const { isERC721, displayModal, setRecipients, holderAddresses } = props; + + const [friendtechValue, setFriendtechValue] = useState(""); + + const formatFriendtechAirdrop = (addresses: string[] | undefined, friendtechValue: string) => { + return addresses?.map((address) => `${address},${friendtechValue}`).join("\n") || ""; + }; + + const [textareaValue, setTextareaValue] = useState( + formatFriendtechAirdrop(holderAddresses, friendtechValue) + ); - const [textareaValue, setTextareaValue] = useState(''); const [localRecipients, setLocalRecipients] = useState<[string, string][]>( [] ); + useEffect(() => { + const formattedTextValue = formatFriendtechAirdrop(holderAddresses, friendtechValue); + + if (isERC721) { + handleERC721(formattedTextValue); + } else { + handleERC20(formattedTextValue); + } + + setTextareaValue(formattedTextValue); + + }, [friendtechValue, holderAddresses]); + const placeholder = () => { if (isERC721) { return `0x3a6372B2013f9876a84761187d933DEe0653E377, 4 @@ -75,24 +99,31 @@ const MagicTextArea: FC = (props: Props) => { }; const handleERC20 = (value: string) => { - const regex = /^0x[a-fA-F0-9]{40}(?= ?[^ ])([=,]?) *(\d+(\.\d+)?)$/; + const regex = /^0x[a-fA-F0-9]{40}(?= ?[^ ])([=,]?) *(\d*(\.\d*)?)$/; const lines = value.split('\n'); + const validLines = lines.filter((line) => regex.test(line)); const addressValueCombos: [string, string][] = []; // Loop through validLines for (let i = 0; i < validLines.length; i++) { - const regex = /(?=[=,\s]\s*\d)/; + // Split the input on a space, a comma, or an = sign - const parts: string[] = validLines[i].split(regex); + const parts: string[] = validLines[i].split(','); // Address is the first element const address = parts[0]; + // The unsanitized value to send is the last element let val: string = parts[parts.length - 1]; + val = val.replace(/^[^0-9.]+/, ''); + if(val.startsWith('.')){ + val = `0${val}`; // add 0 for straight decimal value + } + addressValueCombos.push([address, val]); } @@ -102,6 +133,7 @@ const MagicTextArea: FC = (props: Props) => { }; const handleTextareaChange = (e: ChangeEvent) => { + // Restored to make the original ETH usecase work, doesn't affect FT const value = e.target.value; if (isERC721) { @@ -114,6 +146,7 @@ const MagicTextArea: FC = (props: Props) => { }; const handleClick = (e: MouseEvent) => { + if (localRecipients.length === 0) return; setRecipients(localRecipients); @@ -122,18 +155,36 @@ const MagicTextArea: FC = (props: Props) => { return (
-

{instructions}

+ {holderAddresses && holderAddresses.length > 0 ? ( + <> +

+ Enter an amount to send to each address: +

+ + { + setFriendtechValue(e.target.value); + }} + placeholder="Ex: 0.1" + className="border-2 border-neutral-700 bg-transparent text-base-100 p-4 text-xl rounded-md mb-2" // added mb-2 here + /> + + ) : ( +

{instructions}

+ )}