diff --git a/packages/grant-explorer/src/features/round/DonateToGitcoin.tsx b/packages/grant-explorer/src/features/round/DonateToGitcoin.tsx new file mode 100644 index 0000000000..7dff3ad285 --- /dev/null +++ b/packages/grant-explorer/src/features/round/DonateToGitcoin.tsx @@ -0,0 +1,311 @@ +import { Fragment, useMemo, useRef, useEffect } from "react"; +import { Listbox, Transition } from "@headlessui/react"; +import { getChains, NATIVE } from "common"; +import { stringToBlobUrl } from "common"; +import { Checkbox } from "@chakra-ui/react"; +import { zeroAddress } from "viem"; +import { useDonateToGitcoin } from "./DonateToGitcoinContext"; +import React from "react"; + +type TokenFilter = { + chainId: number; + addresses: string[]; +}; + +export type DonationDetails = { + chainId: number; + tokenAddress: string; + amount: string; +}; + +type DonateToGitcoinProps = { + divider?: "none" | "top" | "bottom"; + tokenFilters?: TokenFilter[]; +}; + +const AmountInput = React.memo(function AmountInput({ + amount, + isAmountValid, + selectedToken, + selectedTokenBalance, + tokenDetails, + handleAmountChange, +}: { + amount: string; + isAmountValid: boolean; + selectedToken: string; + selectedTokenBalance: number; + tokenDetails?: { code: string }; + handleAmountChange: (e: React.ChangeEvent) => void; +}) { + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + return ( +
+ + {selectedToken && ( +
+ {tokenDetails?.code} +
+ )} +
+ ); +}); + +function DonateToGitcoinContent({ + divider = "none", + tokenFilters, +}: DonateToGitcoinProps) { + const { + isEnabled, + selectedChainId, + selectedToken, + amount, + tokenBalances, + selectedTokenBalance, + handleAmountChange, + handleTokenChange, + handleChainChange, + handleCheckboxChange, + } = useDonateToGitcoin(); + + // Filter chains based on tokenFilters + const chains = useMemo(() => { + const allChains = getChains().filter((c) => c.type === "mainnet"); + if (!tokenFilters) return allChains; + return allChains.filter((chain) => + tokenFilters.some((filter) => filter.chainId === chain.id) + ); + }, [tokenFilters]); + + const selectedChain = selectedChainId + ? chains.find((c) => c.id === selectedChainId) + : null; + const tokenDetails = selectedChain?.tokens.find( + (t) => t.address === selectedToken + ); + + // Filter tokens based on tokenFilters + const filteredTokens = useMemo(() => { + if (!selectedChain || !tokenFilters) return selectedChain?.tokens; + const chainFilter = tokenFilters.find( + (f) => f.chainId === selectedChain.id + ); + if (!chainFilter) return selectedChain.tokens; + return selectedChain.tokens.filter((token) => + chainFilter.addresses + .map((addr) => addr.toLowerCase()) + .includes(token.address.toLowerCase()) + ); + }, [selectedChain, tokenFilters]); + + const borderClass = useMemo(() => { + switch (divider) { + case "top": + return "border-t"; + case "bottom": + return "border-b"; + default: + return ""; + } + }, [divider]); + + const isAmountValid = useMemo(() => { + if (!amount || !selectedToken) return true; + const numAmount = Number(amount); + return ( + !isNaN(numAmount) && + (amount.endsWith(".") || numAmount > 0) && + numAmount <= selectedTokenBalance + ); + }, [amount, selectedToken, selectedTokenBalance]); + + return ( +
+
+

+ handleCheckboxChange(e.target.checked)} + /> + Gitcoin + Donate to Gitcoin +

+
+ + {isEnabled && ( +
+
+
+ {selectedChain && ( + {selectedChain.prettyName} + )} + +
+
+ + {selectedChain && ( +
+ +
+ + {selectedToken ? ( +
+ + { + selectedChain?.tokens.find( + (t) => + t.address.toLowerCase() === + selectedToken.toLowerCase() + )?.code + } + + + {selectedTokenBalance.toFixed(3)} + +
+ ) : ( + "Select token" + )} +
+ + +
+ {(filteredTokens || []) + .filter((token) => token.address !== zeroAddress) + .sort((a, b) => { + if ( + a.address.toLowerCase() === NATIVE.toLowerCase() + ) + return -1; + if ( + b.address.toLowerCase() === NATIVE.toLowerCase() + ) + return 1; + + const balanceA = + tokenBalances.find( + (b) => + b.token.toLowerCase() === + a.address.toLowerCase() + )?.balance || 0; + const balanceB = + tokenBalances.find( + (b) => + b.token.toLowerCase() === + b.token.toLowerCase() + )?.balance || 0; + + if (balanceA === 0 && balanceB === 0) return 0; + if (balanceA === 0) return 1; + if (balanceB === 0) return -1; + return balanceB - balanceA; + }) + .map((token) => { + const balance = + tokenBalances.find( + (b) => + b.token.toLowerCase() === + token.address.toLowerCase() + )?.balance || 0; + return ( + + `relative cursor-default select-none py-2 pl-3 pr-9 ${ + active ? "bg-gray-50" : "" + }` + } + > +
+ {token.code} + + {balance.toFixed(3)} + +
+
+ ); + })} +
+
+
+
+
+ + +
+ )} + + {!isAmountValid && amount && ( +

+ Amount must be greater than 0 and less than your balance +

+ )} +
+ )} +
+ ); +} + +export function DonateToGitcoin(props: DonateToGitcoinProps) { + return ; +} diff --git a/packages/grant-explorer/src/features/round/DonateToGitcoinContext.tsx b/packages/grant-explorer/src/features/round/DonateToGitcoinContext.tsx new file mode 100644 index 0000000000..ab303e7e07 --- /dev/null +++ b/packages/grant-explorer/src/features/round/DonateToGitcoinContext.tsx @@ -0,0 +1,175 @@ +import React, { createContext, useContext, useState, useCallback } from "react"; +import { getBalance } from "@wagmi/core"; +import { config } from "../../app/wagmi"; +import { NATIVE, getChains } from "common"; +import { useAccount } from "wagmi"; +import { zeroAddress } from "viem"; + +type TokenFilter = { + chainId: number; + addresses: string[]; +}; + +export type DonationDetails = { + chainId: number; + tokenAddress: string; + amount: string; +}; + +type DonateToGitcoinContextType = { + isEnabled: boolean; + selectedChainId: number | null; + selectedToken: string; + amount: string; + tokenBalances: { token: string; balance: number }[]; + selectedTokenBalance: number; + setIsEnabled: (enabled: boolean) => void; + setSelectedChainId: (chainId: number | null) => void; + setSelectedToken: (token: string) => void; + setAmount: (amount: string) => void; + handleAmountChange: (e: React.ChangeEvent) => void; + handleTokenChange: (newToken: string) => void; + handleChainChange: (e: React.ChangeEvent) => void; + handleCheckboxChange: (checked: boolean) => void; + onDonationChange?: (details: DonationDetails | null) => void; +}; + +const DonateToGitcoinContext = createContext( + null +); + +export function DonateToGitcoinProvider({ + children, + onDonationChange, +}: { + children: React.ReactNode; + tokenFilters?: TokenFilter[]; + onDonationChange?: (details: DonationDetails | null) => void; +}) { + const [isEnabled, setIsEnabled] = useState(false); + const [selectedChainId, setSelectedChainId] = useState(null); + const [selectedToken, setSelectedToken] = useState(""); + const [amount, setAmount] = useState(""); + const [tokenBalances, setTokenBalances] = useState< + { token: string; balance: number }[] + >([]); + const { address } = useAccount(); + + const selectedTokenBalance = + tokenBalances.find( + (b) => b.token.toLowerCase() === selectedToken.toLowerCase() + )?.balance || 0; + + // Fetch token balances when chain or address changes + React.useEffect(() => { + if (!address || !selectedChainId) return; + + const fetchBalances = async () => { + const chain = getChains().find((c) => c.id === selectedChainId); + if (!chain) return; + + const balances = await Promise.all( + chain.tokens + .filter((token) => token.address !== zeroAddress) + .map(async (token) => { + const { value } = await getBalance(config, { + address, + token: + token.address.toLowerCase() === NATIVE.toLowerCase() + ? undefined + : token.address, + chainId: selectedChainId, + }); + return { + token: token.address, + balance: Number(value) / 10 ** (token.decimals || 18), + }; + }) + ); + setTokenBalances(balances); + }; + + fetchBalances(); + }, [address, selectedChainId]); + + // Replace the two-part handler with a single handleAmountChange + const handleAmountChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + if (value === "" || /^\d*\.?\d*$/.test(value)) { + setAmount(value); + if (isEnabled && selectedChainId && selectedToken) { + onDonationChange?.({ + chainId: selectedChainId, + tokenAddress: selectedToken, + amount: value, + }); + } + } + }, + [isEnabled, selectedChainId, selectedToken, onDonationChange] + ); + + const handleTokenChange = useCallback((newToken: string) => { + setSelectedToken(newToken); + setAmount(""); + }, []); + + const handleChainChange = useCallback( + (e: React.ChangeEvent) => { + const newChainId = Number(e.target.value); + setSelectedChainId(newChainId || null); + setSelectedToken(""); + setAmount(""); + }, + [] + ); + + const handleCheckboxChange = useCallback( + (checked: boolean) => { + setIsEnabled(checked); + if (!checked) { + setSelectedChainId(null); + setSelectedToken(""); + setAmount(""); + setTokenBalances([]); + onDonationChange?.(null); + } + }, + [onDonationChange] + ); + + const value = { + isEnabled, + selectedChainId, + selectedToken, + amount, + tokenBalances, + selectedTokenBalance, + setIsEnabled, + setSelectedChainId, + setSelectedToken, + setAmount, + handleAmountChange, + handleTokenChange, + handleChainChange, + handleCheckboxChange, + onDonationChange, + }; + + return ( + + {children} + + ); +} + +export function useDonateToGitcoin() { + const context = useContext(DonateToGitcoinContext); + if (!context) { + throw new Error( + "useDonateToGitcoin must be used within a DonateToGitcoinProvider" + ); + } + return context; +} diff --git a/packages/grant-explorer/src/features/round/ViewCartPage/ChainConfirmationModalBody.tsx b/packages/grant-explorer/src/features/round/ViewCartPage/ChainConfirmationModalBody.tsx index 92f64058ab..a066e48604 100644 --- a/packages/grant-explorer/src/features/round/ViewCartPage/ChainConfirmationModalBody.tsx +++ b/packages/grant-explorer/src/features/round/ViewCartPage/ChainConfirmationModalBody.tsx @@ -1,9 +1,11 @@ import React from "react"; import { CartProject } from "../../api/types"; -import { TToken, getChainById, stringToBlobUrl } from "common"; +import { NATIVE, TToken, getChainById, stringToBlobUrl } from "common"; import { useCartStorage } from "../../../store"; import { parseChainId } from "common/src/chains"; import { Checkbox } from "@chakra-ui/react"; +import { DonateToGitcoin } from "../DonateToGitcoin"; +import { zeroAddress } from "viem"; type ChainConfirmationModalBodyProps = { projectsByChain: { [chain: number]: CartProject[] }; @@ -43,7 +45,7 @@ export function ChainConfirmationModalBody({ return ( <>

- {chainIdsBeingCheckedOut.length > 1 && ( + {chainIdsBeingCheckedOut.length > 1 && ( <> Checkout all your carts across different networks or select the cart you wish to checkout now. @@ -51,27 +53,44 @@ export function ChainConfirmationModalBody({ )}

- {Object.keys(projectsByChain) - .map(parseChainId) - .filter((chainId) => chainIdsBeingCheckedOut.includes(chainId)) - .map((chainId, index) => ( - - handleChainCheckboxChange(chainId, checked) - } - isLastItem={index === Object.keys(projectsByChain).length - 1} - notEnoughBalance={!enoughBalanceByChainId[chainId]} - handleSwap={() => handleSwap(chainId)} - /> - ))} + <> + {Object.keys(projectsByChain) + .map(parseChainId) + .filter((chainId) => chainIdsBeingCheckedOut.includes(chainId)) + .map((chainId, index) => ( + + handleChainCheckboxChange(chainId, checked) + } + isLastItem={index === Object.keys(projectsByChain).length - 1} + notEnoughBalance={!enoughBalanceByChainId[chainId]} + handleSwap={() => handleSwap(chainId)} + /> + ))} + + <> + ({ + chainId, + addresses: [ + getVotingTokenForChain(chainId).address === zeroAddress + ? NATIVE + : getVotingTokenForChain(chainId).address, + ], + }))} + /> +
); diff --git a/packages/grant-explorer/src/features/round/ViewCartPage/SummaryContainer.tsx b/packages/grant-explorer/src/features/round/ViewCartPage/SummaryContainer.tsx index 4e5e85cc4e..1bf5067711 100644 --- a/packages/grant-explorer/src/features/round/ViewCartPage/SummaryContainer.tsx +++ b/packages/grant-explorer/src/features/round/ViewCartPage/SummaryContainer.tsx @@ -31,12 +31,14 @@ import { useDataLayer } from "data-layer"; import { isPresent } from "ts-is-present"; import { getFormattedRoundId } from "../../common/utils/utils"; import { datadogLogs } from "@datadog/browser-logs"; +import { useDonateToGitcoin } from "../DonateToGitcoinContext"; export function SummaryContainer(props: { enoughBalanceByChainId: Record; totalAmountByChainId: Record; handleSwap: (chainId: number) => void; }) { + console.log("===> fuck 2"); const { data: walletClient } = useWalletClient(); const navigate = useNavigate(); const { address, isConnected, connector } = useAccount(); @@ -47,9 +49,17 @@ export function SummaryContainer(props: { } = useCartStorage(); const { checkout, voteStatus, chainsToCheckout } = useCheckoutStore(); const dataLayer = useDataLayer(); - const { openConnectModal } = useConnectModal(); + const { + isEnabled, + selectedChainId, + selectedToken, + amount, + tokenBalances, + selectedTokenBalance, + } = useDonateToGitcoin(); + const projectsByChain = useMemo( () => groupBy(projects, "chainId"), [projects] @@ -129,6 +139,17 @@ export function SummaryContainer(props: { } }, [chainsToCheckout, navigate, voteStatus]); + useEffect(() => { + console.log("==> ", { + isEnabled, + selectedChainId, + selectedToken, + amount, + tokenBalances, + selectedTokenBalance, + }); + }, []); + function checkEmptyDonations() { const emptyDonationsExist = projects.filter( @@ -187,7 +208,9 @@ export function SummaryContainer(props: { handleSwap={props.handleSwap} /> } - isOpen={openChainConfirmationModal} + // todo: put back + // isOpen={openChainConfirmationModal} + isOpen={true} setIsOpen={setOpenChainConfirmationModal} disabled={chainIdsBeingCheckedOut.length === 0} /> @@ -217,7 +240,7 @@ export function SummaryContainer(props: { onTryAgain={() => { window.location.href = "https://passport.gitcoin.co"; }} - heading={`Don’t miss out on getting your donations matched!`} + heading={`Don't miss out on getting your donations matched!`} subheading={ <>

diff --git a/packages/grant-explorer/src/index.tsx b/packages/grant-explorer/src/index.tsx index 28e12408e3..eef271be64 100644 --- a/packages/grant-explorer/src/index.tsx +++ b/packages/grant-explorer/src/index.tsx @@ -35,6 +35,7 @@ import { PostHogProvider } from "posthog-js/react"; import ViewProject from "./features/projects/ViewProject"; import { ExploreProjectsPage } from "./features/discovery/ExploreProjectsPage"; import { DirectAllocationProvider } from "./features/projects/hooks/useDirectAllocation"; +import { DonateToGitcoinProvider } from "./features/round/DonateToGitcoinContext"; initDatadog(); initTagmanager(); @@ -67,72 +68,74 @@ root.render( - - - - - - - {/* Protected Routes */} - } /> - - {/* Default Route */} - } /> - - } - /> - - {/* Round Routes */} - } - /> - } - /> - - {/* Project Routes */} - - } - /> - - } - /> - - } /> - - } /> - - } - /> - - {/* Access Denied */} - } - /> - } - /> - - {/* 404 */} - } /> - - - - - - + + + + + + + + {/* Protected Routes */} + } /> + + {/* Default Route */} + } /> + + } + /> + + {/* Round Routes */} + } + /> + } + /> + + {/* Project Routes */} + + } + /> + + } + /> + + } /> + + } /> + + } + /> + + {/* Access Denied */} + } + /> + } + /> + + {/* 404 */} + } /> + + + + + + +