diff --git a/packages/app/src/components/ActionButton.tsx b/packages/app/src/components/ActionButton.tsx index cc30ab1d..5980f67e 100644 --- a/packages/app/src/components/ActionButton.tsx +++ b/packages/app/src/components/ActionButton.tsx @@ -10,6 +10,7 @@ type ActionButtonProps = { onPress?: any; width?: string; borderRadius?: number; + isDisabled?: boolean; }; export const buttonStyles = { @@ -40,7 +41,16 @@ export const buttonStyles = { }, }; -const ActionButton = ({ href, text, bg, textColor, onPress, width = '100%', borderRadius = 50 }: ActionButtonProps) => { +const ActionButton = ({ + href, + text, + bg, + textColor, + onPress, + width = '100%', + borderRadius = 50, + isDisabled = false, +}: ActionButtonProps) => { const responsiveStyles = useBreakpointValue({ base: { button: { @@ -71,7 +81,12 @@ const ActionButton = ({ href, text, bg, textColor, onPress, width = '100%', bord const { buttonContainer, button, buttonText } = responsiveStyles ?? {}; const content = ( - + {text} diff --git a/packages/app/src/components/ClaimRewardButton.tsx b/packages/app/src/components/ClaimRewardButton.tsx new file mode 100644 index 00000000..eb76f152 --- /dev/null +++ b/packages/app/src/components/ClaimRewardButton.tsx @@ -0,0 +1,137 @@ +import React, { useState, useEffect } from 'react'; +import { View } from 'native-base'; +import { useAccount } from 'wagmi'; +import RoundedButton from './RoundedButton'; +import { Colors } from '../utils/colors'; +import { useClaimReward } from '../hooks/useClaimReward'; +import BaseModal from './modals/BaseModal'; +import ProcessingModal from './modals/ProcessingModal'; +import { PhoneImg, ThankYouImg } from '../assets'; +import { calculateGoodDollarAmounts } from '../lib/calculateGoodDollarAmounts'; +import { useGetTokenPrice } from '../hooks'; +import { ClaimTimer } from './ClaimTimer'; + +type ClaimStatus = 'idle' | 'confirm' | 'processing' | 'success' | 'error'; + +interface ClaimRewardButtonProps { + poolAddress: `0x${string}`; + poolType: string; + poolName?: string; + onSuccess?: () => void; + eligibleAmount?: bigint; + hasClaimed?: boolean; + nextClaimTime?: number; + claimPeriodDays?: number; +} + +export const ClaimRewardButton: React.FC = ({ + poolAddress, + poolType, + poolName, + onSuccess, + eligibleAmount = 0n, + hasClaimed = false, + nextClaimTime, + claimPeriodDays, +}) => { + const { address } = useAccount(); + const { claimReward, isConfirming, isSuccess, isError, error } = useClaimReward(poolAddress, poolType); + const { price: tokenPrice } = useGetTokenPrice('G$'); + const [status, setStatus] = useState('idle'); + const [errorMessage, setErrorMessage] = useState(); + + const { wei: formattedAmount } = calculateGoodDollarAmounts(eligibleAmount.toString(), tokenPrice, 2); + + const handleClaimClick = () => { + setStatus('confirm'); + }; + + const handleConfirmClaim = async () => { + setStatus('processing'); + try { + await claimReward(); + // success is handled by useEffect listening to isSuccess + } catch (err) { + // Only handle truly unexpected errors here + const message = err instanceof Error ? err.message : 'Failed to claim reward'; + setErrorMessage(message); + setStatus('error'); + } + }; + + // Handle success state from hook + useEffect(() => { + if (isSuccess && !isConfirming) { + setStatus('success'); + onSuccess?.(); + } + }, [isSuccess, isConfirming, onSuccess]); + + // Handle error state from hook + useEffect(() => { + if (isError && error) { + const message = error.message || 'Failed to claim reward'; + setErrorMessage(message); + setStatus('error'); + } + }, [isError, error]); + + if (!address) { + return null; + } + + // If already claimed, show timer + if (hasClaimed && nextClaimTime && claimPeriodDays) { + return ; + } + + // Show button even if amount is 0 (pool might not have funds yet, but user is a member) + + return ( + + + setStatus('idle')} + onConfirm={handleConfirmClaim} + title="CLAIM REWARD" + paragraphs={[ + `You are eligible to claim ${formattedAmount ? `G$ ${formattedAmount}` : '...'} from ${ + poolName || 'this pool' + }.`, + 'To claim your reward, please sign with your wallet.', + ]} + image={PhoneImg} + confirmButtonText="CLAIM" + /> + + setStatus('idle')} + onConfirm={() => setStatus('idle')} + title="SUCCESS!" + paragraphs={[`You have successfully claimed your reward from ${poolName || 'the pool'}!`]} + image={ThankYouImg} + confirmButtonText="OK" + /> + { + setErrorMessage(undefined); + setStatus('idle'); + }} + onConfirm={() => { + setErrorMessage(undefined); + setStatus('idle'); + }} + errorMessage={errorMessage ?? ''} + /> + + ); +}; diff --git a/packages/app/src/components/ClaimTimer.tsx b/packages/app/src/components/ClaimTimer.tsx new file mode 100644 index 00000000..99fa4b86 --- /dev/null +++ b/packages/app/src/components/ClaimTimer.tsx @@ -0,0 +1,60 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, VStack } from 'native-base'; +import moment from 'moment'; +import { formatTime } from '../lib/formatTime'; + +interface ClaimTimerProps { + nextClaimTime: number; +} + +export const ClaimTimer: React.FC = ({ nextClaimTime }) => { + const [timeRemaining, setTimeRemaining] = useState(0); + + useEffect(() => { + const updateTimer = () => { + const now = moment().unix(); + const remaining = Math.max(0, nextClaimTime - now); + setTimeRemaining(remaining); + }; + + updateTimer(); + const interval = setInterval(updateTimer, 1000); + + return () => clearInterval(interval); + }, [nextClaimTime]); + + if (timeRemaining === 0) { + return ( + + + You can claim again now + + + ); + } + + const days = Math.floor(timeRemaining / 86400); + const hours = Math.floor((timeRemaining % 86400) / 3600); + const minutes = Math.floor((timeRemaining % 3600) / 60); + const seconds = timeRemaining % 60; + + return ( + + + Already Claimed + + + You can claim again in: + + + {days > 0 && `${days}d `} + {hours > 0 && `${hours}h `} + {minutes > 0 && `${minutes}m `} + {seconds}s + + + Next claim: {formatTime(nextClaimTime)} + + + ); +}; diff --git a/packages/app/src/components/JoinPoolButton.tsx b/packages/app/src/components/JoinPoolButton.tsx new file mode 100644 index 00000000..78819065 --- /dev/null +++ b/packages/app/src/components/JoinPoolButton.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useState } from 'react'; +import { View } from 'native-base'; +import { useAccount } from 'wagmi'; +import RoundedButton from './RoundedButton'; +import { Colors } from '../utils/colors'; +import { useJoinPool } from '../hooks/useJoinPool'; +import BaseModal from './modals/BaseModal'; +import ProcessingModal from './modals/ProcessingModal'; +import { PhoneImg, ThankYouImg } from '../assets'; + +interface JoinPoolButtonProps { + poolAddress: `0x${string}`; + poolType: string; + poolName?: string; + onSuccess?: () => void; +} + +export const JoinPoolButton: React.FC = ({ poolAddress, poolType, poolName, onSuccess }) => { + const { address } = useAccount(); + const { joinPool, isSimulating, isConfirming, isSuccess, isError, error, hash } = useJoinPool(poolAddress, poolType); + const [showJoinModal, setShowJoinModal] = useState(false); + const [showProcessingModal, setShowProcessingModal] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + + const handleJoinClick = () => { + setShowJoinModal(true); + }; + + const handleConfirmJoin = async () => { + setShowJoinModal(false); + setShowProcessingModal(true); + try { + await joinPool(); + } catch (err) { + setShowProcessingModal(false); + const message = err instanceof Error ? err.message : 'Failed to join pool'; + setErrorMessage(message); + } + }; + + useEffect(() => { + if (isSuccess && !isConfirming && hash) { + setShowProcessingModal(false); + setShowSuccessModal(true); + // Wait a bit for the transaction to be indexed, then call onSuccess + setTimeout(() => { + onSuccess?.(); + }, 1000); + } + }, [isSuccess, isConfirming, hash, onSuccess]); + + useEffect(() => { + if (isError && error) { + setShowProcessingModal(false); + const message = error.message || 'Failed to join pool'; + setErrorMessage(message); + } + }, [isError, error]); + + if (!address) { + return null; + } + + return ( + + + setShowJoinModal(false)} + onConfirm={handleConfirmJoin} + title="JOIN POOL" + paragraphs={[`To join ${poolName || 'this pool'}, please sign with your wallet.`]} + image={PhoneImg} + confirmButtonText="JOIN" + confirmDisabled={isSimulating} + /> + + setShowSuccessModal(false)} + onConfirm={() => setShowSuccessModal(false)} + title="SUCCESS!" + paragraphs={[`You have successfully joined ${poolName || 'the pool'}!`]} + image={ThankYouImg} + confirmButtonText="OK" + /> + setErrorMessage(undefined)} + onConfirm={() => setErrorMessage(undefined)} + errorMessage={errorMessage ?? ''} + /> + + ); +}; diff --git a/packages/app/src/components/ViewCollective.tsx b/packages/app/src/components/ViewCollective.tsx index d5d1b1fc..c8657088 100644 --- a/packages/app/src/components/ViewCollective.tsx +++ b/packages/app/src/components/ViewCollective.tsx @@ -1,20 +1,21 @@ -import { StyleSheet, Image } from 'react-native'; -import { Link, useBreakpointValue, Text, View, VStack } from 'native-base'; +import { Link, Spinner, Text, useBreakpointValue, View, VStack } from 'native-base'; +import { useCallback, useEffect, useState } from 'react'; +import { Image, StyleSheet } from 'react-native'; import { useAccount, useEnsName } from 'wagmi'; -import RowItem from './RowItem'; +import useCrossNavigate from '../routes/useCrossNavigate'; +import { InterSemiBold, InterSmall } from '../utils/webFonts'; import RoundedButton from './RoundedButton'; +import RowItem from './RowItem'; import StewardList from './StewardsList/StewardsList'; import TransactionList from './TransactionList/TransactionList'; -import { InterSemiBold, InterSmall } from '../utils/webFonts'; -import useCrossNavigate from '../routes/useCrossNavigate'; -import { Colors } from '../utils/colors'; import { useScreenSize } from '../theme/hooks'; +import { Colors } from '../utils/colors'; -import { formatTime } from '../lib/formatTime'; -import { Collective, DonorCollective } from '../models/models'; -import { useDonorCollectiveByAddresses, useGetTokenPrice } from '../hooks'; +import { GoodCollectiveSDK } from '@gooddollar/goodcollective-sdk'; +import { ethers } from 'ethers'; +import GoodCollectiveContracts from '../../../contracts/releases/deployment.json'; import { AtIcon, CalendarIcon, @@ -31,18 +32,30 @@ import { TwitterIcon, WebIcon, } from '../assets/'; -import { calculateGoodDollarAmounts } from '../lib/calculateGoodDollarAmounts'; -import FlowingDonationsRowItem from './FlowingDonationsRowItem'; -import { defaultInfoLabel, GDToken, SUBGRAPH_POLL_INTERVAL } from '../models/constants'; -import env from '../lib/env'; -import { useGetTokenBalance } from '../hooks/useGetTokenBalance'; +import { styles as walletCardStyles } from '../components/WalletCards/styles'; +import { useDonorCollectiveByAddresses, useGetTokenPrice } from '../hooks'; +import { useEthersProvider } from '../hooks/useEthers'; import { useFlowingBalance } from '../hooks/useFlowingBalance'; +import { useGetTokenBalance } from '../hooks/useGetTokenBalance'; import { useRealtimeStats } from '../hooks/useRealtimeStats'; -import { GoodDollarAmount } from './GoodDollarAmount'; -import { styles as walletCardStyles } from '../components/WalletCards/styles'; +import { calculateGoodDollarAmounts } from '../lib/calculateGoodDollarAmounts'; +import env from '../lib/env'; import { formatFlowRate } from '../lib/formatFlowRate'; -import { StopDonationActionButton } from './StopDonationActionButton'; +import { formatTime } from '../lib/formatTime'; +import { + defaultInfoLabel, + GDToken, + SUBGRAPH_POLL_INTERVAL, + SupportedNetwork, + SupportedNetworkNames, +} from '../models/constants'; +import { Collective, DonorCollective } from '../models/models'; import BannerPool from './BannerPool'; +import { ClaimRewardButton } from './ClaimRewardButton'; +import FlowingDonationsRowItem from './FlowingDonationsRowItem'; +import { GoodDollarAmount } from './GoodDollarAmount'; +import { JoinPoolButton } from './JoinPoolButton'; +import { StopDonationActionButton } from './StopDonationActionButton'; const HasDonatedCard = ({ donorCollective, @@ -204,9 +217,110 @@ function ViewCollective({ collective }: ViewCollectiveProps) { const stewardsPaid = stewardCollectives.length; const infoLabel = collective.ipfs.rewardDescription ?? defaultInfoLabel; - const { address } = useAccount(); + const { address, chain } = useAccount(); const maybeDonorCollective = useDonorCollectiveByAddresses(address ?? '', poolAddress, SUBGRAPH_POLL_INTERVAL); + const chainId = chain?.id ?? SupportedNetwork.CELO; + const provider = useEthersProvider({ chainId }); + + const [memberPoolData, setMemberPoolData] = useState<{ + eligibleAmount: bigint; + hasClaimed: boolean; + nextClaimTime?: number; + claimPeriodDays?: number; + onlyMembers?: boolean | Promise; + } | null>(null); + + const [poolOnlyMembers, setPoolOnlyMembers] = useState(undefined); + + const [refetchTrigger, setRefetchTrigger] = useState(0); + const [isMemberPoolLoading, setIsMemberPoolLoading] = useState(false); + + const fetchMemberPools = useCallback(async () => { + if (!poolAddress || pooltype !== 'UBI' || !provider) { + setMemberPoolData(null); + setPoolOnlyMembers(undefined); + setIsMemberPoolLoading(false); + return; + } + try { + setIsMemberPoolLoading(true); + const network = SupportedNetworkNames[chainId as SupportedNetwork]; + const sdk = new GoodCollectiveSDK(chainId.toString() as any, provider, { network }); + + // Always fetch pool settings to get onlyMembers, even if user is not a member + const poolDetails = await sdk.getUBIPoolsDetails([poolAddress], address); + const currentPool = poolDetails.find((pool: any) => pool.contract.toLowerCase() === poolAddress.toLowerCase()); + + if (!currentPool) { + setMemberPoolData(null); + setPoolOnlyMembers(undefined); + setIsMemberPoolLoading(false); + return; + } + + const claimAmountStr = currentPool.claimAmount?.toString?.() ?? '0'; + const nextClaimTimeStr = currentPool.nextClaimTime?.toString?.(); + const claimPeriodDaysRaw = currentPool.ubiSettings?.claimPeriodDays; + const onlyMembersRaw = currentPool.ubiSettings?.onlyMembers; + + // Always store onlyMembers setting for pool open check + setPoolOnlyMembers(onlyMembersRaw as boolean | undefined); + + if (!address || !currentPool.isRegistered) { + setMemberPoolData(null); + setIsMemberPoolLoading(false); + return; + } + + const eligibleAmount = BigInt(claimAmountStr || '0'); + + // Check if user has actually claimed by calling hasClaimed(address) on the contract + // This is the only reliable way to know if they've claimed (not just if nextClaimTime exists) + const networkName = env.REACT_APP_NETWORK || 'development-celo'; + const UBI_POOL_ABI = + (GoodCollectiveContracts as any)[chainId.toString()]?.find((envs: any) => envs.name === networkName)?.contracts + .UBIPool?.abi || []; + + let hasClaimedToday = false; + if (UBI_POOL_ABI.length > 0) { + try { + const poolContract = new ethers.Contract(poolAddress, UBI_POOL_ABI, provider); + hasClaimedToday = await poolContract.hasClaimed(address); + } catch (e) { + // If contract call fails, fall back to false + console.warn('Failed to check hasClaimed:', e); + } + } + + // hasClaimed should only be true if the user has actually claimed today + // The countdown should only show after a successful claim transaction + setMemberPoolData({ + eligibleAmount, + hasClaimed: hasClaimedToday, + nextClaimTime: nextClaimTimeStr ? Number(nextClaimTimeStr) : undefined, + claimPeriodDays: claimPeriodDaysRaw !== undefined ? Number(claimPeriodDaysRaw) : undefined, + // `onlyMembers` from the SDK is typed as PromiseOrValue, but in practice is a boolean. + // Cast to the expected boolean | undefined shape for local state. + onlyMembers: onlyMembersRaw as boolean | undefined, + }); + setIsMemberPoolLoading(false); + } catch (e) { + // If SDK call fails, gracefully fall back to null so UI can still render + setMemberPoolData(null); + setPoolOnlyMembers(undefined); + setIsMemberPoolLoading(false); + } + }, [address, poolAddress, pooltype, provider, chainId]); + + useEffect(() => { + fetchMemberPools(); + }, [fetchMemberPools, refetchTrigger]); + + const refetchMemberPoolData = () => { + setRefetchTrigger((prev) => prev + 1); + }; + const { price: tokenPrice } = useGetTokenPrice('G$'); const { stats } = useRealtimeStats(poolAddress); @@ -265,6 +379,40 @@ function ViewCollective({ collective }: ViewCollectiveProps) { {maybeDonorCollective && maybeDonorCollective.flowRate !== '0' ? null : ( + {pooltype === 'UBI' && isMemberPoolLoading ? ( + + + Loading pool details + + ) : ( + <> + {!poolOnlyMembers && !memberPoolData && address && pooltype === 'UBI' && ( + + )} + {memberPoolData && memberPoolData.eligibleAmount > 0n && address && pooltype === 'UBI' && ( + { + refetchMemberPoolData(); + setTimeout(() => { + refetchMemberPoolData(); + }, 2000); + }} + /> + )} + + )} {infoLabel} + {pooltype === 'UBI' && isMemberPoolLoading ? ( + + + + Loading pool details + + + ) : ( + <> + {!poolOnlyMembers && !memberPoolData && address && pooltype === 'UBI' && ( + + { + refetchMemberPoolData(); + }} + /> + + )} + {memberPoolData && memberPoolData.eligibleAmount > 0n && address && pooltype === 'UBI' && ( + + { + refetchMemberPoolData(); + setTimeout(() => { + refetchMemberPoolData(); + }, 2000); + }} + /> + + )} + + )} + diff --git a/packages/app/src/components/modals/BaseModal.tsx b/packages/app/src/components/modals/BaseModal.tsx index 7caff908..bfa19717 100644 --- a/packages/app/src/components/modals/BaseModal.tsx +++ b/packages/app/src/components/modals/BaseModal.tsx @@ -1,5 +1,5 @@ import { Modal, Platform } from 'react-native'; -import { Image, Pressable, Text, VStack } from 'native-base'; +import { Image, Link, Pressable, Text, VStack } from 'native-base'; import ActionButton from '../ActionButton'; import { CloseIcon, ThankYouImg } from '../../assets'; @@ -32,7 +32,31 @@ const modalView = { const defaultModalProps = { error: { dTitle: 'Something went wrong', - dParagraphs: (errorMessage: string) => ['Please try again later.', 'Reason: ' + (errorMessage ?? 'unknown')], + dParagraphs: (errorMessage: string) => { + const safeMessage = errorMessage ?? 'unknown'; + + // Special handling for messages that include an external link (e.g. https://gooddapp.org) + const goodDappUrl = 'https://gooddapp.org'; + const hasGoodDappLink = safeMessage.includes(goodDappUrl); + + if (!hasGoodDappLink) { + return ['Please try again later.', 'Reason: ' + safeMessage]; + } + + const [beforeLink, afterLink] = safeMessage.split(goodDappUrl); + + return [ + 'Please try again later.', + + {'Reason: '} + {beforeLink} + + {goodDappUrl} + + {afterLink} + , + ]; + }, dConfirmButtonText: 'OK', dImage: ThankYouImg, dMessage: 'Something went wrong', @@ -51,6 +75,7 @@ type BaseModalProps = { errorMessage?: string; withClose?: boolean; message?: string; + confirmDisabled?: boolean; }; export const BaseModal = ({ @@ -65,6 +90,7 @@ export const BaseModal = ({ errorMessage = '', withClose = true, message, + confirmDisabled = false, }: BaseModalProps) => { const _onClose = () => onClose(); const { dTitle, dParagraphs, dConfirmButtonText, dImage, dMessage } = @@ -105,7 +131,7 @@ export const BaseModal = ({ {withClose ? ( - + Close ) : null} @@ -115,17 +141,34 @@ export const BaseModal = ({ {paragraph - ? // eslint-disable-next-line @typescript-eslint/no-shadow - paragraph.map((paragraph: string, index: number) => - paragraph ? ( - - {paragraph} - - ) : null - ) + ? paragraph.map((item: any, index: number) => { + if (!item) return null; + if (typeof item === 'string') { + return ( + + {item} + + ); + } + // Allow JSX elements (e.g. Text with embedded Link) to be passed directly + return ( + + {item} + + ); + }) : null} - woman + {dImage && ( + {dTitle + )} {dMessage && ( {message} @@ -137,6 +180,7 @@ export const BaseModal = ({ onPress={onConfirm} bg="goodOrange.200" textColor="goodOrange.500" + isDisabled={confirmDisabled} /> ) : null} diff --git a/packages/app/src/hooks/index.ts b/packages/app/src/hooks/index.ts index 7093f8d9..7df5586d 100644 --- a/packages/app/src/hooks/index.ts +++ b/packages/app/src/hooks/index.ts @@ -8,3 +8,5 @@ export * from './useIsStewardVerified'; export * from './useEthers'; export * from './useTotalStats'; export * from './useCollectiveFees'; +export * from './useJoinPool'; +export * from './useClaimReward'; diff --git a/packages/app/src/hooks/useClaimReward.ts b/packages/app/src/hooks/useClaimReward.ts new file mode 100644 index 00000000..4d929706 --- /dev/null +++ b/packages/app/src/hooks/useClaimReward.ts @@ -0,0 +1,47 @@ +import { useAccount, useWriteContract, useSimulateContract, useWaitForTransactionReceipt } from 'wagmi'; +import { useCallback } from 'react'; +import GoodCollectiveContracts from '../../../contracts/releases/deployment.json'; +import env from '../lib/env'; + +const networkName = env.REACT_APP_NETWORK || 'development-celo'; +const UBI_POOL_CLAIM_ABI = + (GoodCollectiveContracts as any)['42220']?.find((envs: any) => envs.name === networkName)?.contracts.UBIPool?.abi || + []; + +export function useClaimReward(poolAddress: `0x${string}` | undefined, poolType: string | undefined) { + const { address, chain } = useAccount(); + + const { data: simulateData, error: simulateError } = useSimulateContract({ + chainId: chain?.id, + address: poolAddress, + abi: UBI_POOL_CLAIM_ABI, + functionName: 'claim', + query: { + enabled: !!poolAddress && !!address && poolType === 'UBI', + }, + }); + + const { writeContractAsync, isPending, isError, error, data: hash } = useWriteContract(); + + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ + hash, + chainId: chain?.id, + }); + + const claimReward = useCallback(async () => { + if (!simulateData) { + throw new Error('Transaction simulation failed'); + } + return writeContractAsync(simulateData.request); + }, [simulateData, writeContractAsync]); + + return { + claimReward, + isPending, + isConfirming, + isSuccess, + isError, + error: error || simulateError, + hash, + }; +} diff --git a/packages/app/src/hooks/useJoinPool.ts b/packages/app/src/hooks/useJoinPool.ts new file mode 100644 index 00000000..3c758652 --- /dev/null +++ b/packages/app/src/hooks/useJoinPool.ts @@ -0,0 +1,76 @@ +import { useAccount, useWriteContract, useSimulateContract, useWaitForTransactionReceipt } from 'wagmi'; +import { useCallback } from 'react'; +import GoodCollectiveContracts from '../../../contracts/releases/deployment.json'; +import env from '../lib/env'; + +const networkName = env.REACT_APP_NETWORK || 'development-celo'; +const UBI_POOL_ABI = + (GoodCollectiveContracts as any)['42220']?.find((envs: any) => envs.name === networkName)?.contracts.UBIPool?.abi || + []; + +// User-facing error copy for join pool reverts +export const joinPoolErrors: Record = { + NOT_WHITELISTED: + 'You need to be a whitelisted G$ user to join this pool. Visit https://gooddapp.org and verify yourself by claiming your first G$s.', + MAX_MEMBERS_REACHED: 'This pool reached the maximum amount of members.', +}; + +export function useJoinPool(poolAddress: `0x${string}` | undefined, poolType?: string) { + const { address, chain } = useAccount(); + + // Only enable for UBI pools (DirectPayments pools don't have addMember) + const isUBIPool = poolType === 'UBI'; + + const { + data: simulateData, + error: simulateError, + isLoading: isSimulating, + } = useSimulateContract({ + chainId: chain?.id, + address: poolAddress, + abi: UBI_POOL_ABI, + functionName: 'addMember', + args: [address as `0x${string}`, '0x' as `0x${string}`], + query: { + enabled: !!poolAddress && !!address && isUBIPool, + }, + }); + + + let simulateUiError: Error | undefined; + const simulateErrorName = (simulateError as any)?.cause?.data?.errorName as string | undefined; + + if (simulateErrorName && joinPoolErrors[simulateErrorName]) { + simulateUiError = new Error(joinPoolErrors[simulateErrorName]); + } else if (simulateError) { + simulateUiError = new Error('Unable to join this pool. Please try again or contact support.'); + } + + const { writeContractAsync, isPending, isError, error, data: hash } = useWriteContract(); + + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ + hash, + chainId: chain?.id, + }); + + const joinPool = useCallback(async () => { + if (!simulateData) { + if (simulateUiError) { + throw simulateUiError; + } + throw new Error('Unable to join this pool. Please try again or contact support.'); + } + return writeContractAsync(simulateData.request); + }, [simulateData, writeContractAsync, simulateUiError]); + + return { + joinPool, + isPending, + isSimulating, + isConfirming, + isSuccess, + isError, + error: error || simulateUiError || simulateError, + hash, + }; +} diff --git a/packages/app/src/subgraph/subgraphModels.ts b/packages/app/src/subgraph/subgraphModels.ts index 3ab973df..e9951bd2 100644 --- a/packages/app/src/subgraph/subgraphModels.ts +++ b/packages/app/src/subgraph/subgraphModels.ts @@ -37,6 +37,7 @@ export type SubgraphCollective = { ipfs?: SubgraphIpfsCollective; settings?: SubgraphPoolSettings; limits?: SubgraphSafetyLimits; + ubiLimits?: SubgraphUBILimits; donors?: SubgraphDonorCollective[]; stewards?: SubgraphStewardCollective[]; projectId?: string; @@ -76,6 +77,17 @@ export type SubgraphPoolSettings = { rewardToken: string; }; +export type SubgraphUBILimits = { + id: string; + cycleLengthDays: string; + claimPeriodDays: string; + minActiveUsers: string; + claimForEnabled: boolean; + maxClaimAmount: string; + maxClaimers: string; + onlyMembers: boolean; +}; + export type SubgraphSafetyLimits = { id: string; maxTotalPerMonth: string; diff --git a/packages/app/src/subgraph/useSubgraphCollective.ts b/packages/app/src/subgraph/useSubgraphCollective.ts index eb8b4303..c35d810c 100644 --- a/packages/app/src/subgraph/useSubgraphCollective.ts +++ b/packages/app/src/subgraph/useSubgraphCollective.ts @@ -50,6 +50,16 @@ export const collectivesById = gql` totalRewards settings { rewardToken + membersValidator + } + ubiLimits { + onlyMembers + claimPeriodDays + cycleLengthDays + minActiveUsers + claimForEnabled + maxClaimAmount + maxClaimers } } }