From 839ac0958e2e877ee5ecc3b93f60d45e410b935a Mon Sep 17 00:00:00 2001 From: Emeka Manuel Date: Tue, 25 Nov 2025 22:27:18 +0100 Subject: [PATCH 01/11] feat: add pool membership and reward claiming functionality - Add join pool button for open UBI pools - Add claim reward button with eligible amount display - Add claim timer showing next claim availability - Create hooks for pool membership, rewards, joining, and claiming - Update ViewCollective to show join/claim buttons based on pool state - Add transaction confirmation flows using BaseModal - Update subgraph queries to include ubiLimits and membersValidator - Fix BaseModal Image components to include alt props This enables users to easily join open pools and claim their rewards without manual intervention, improving the user experience for pool membership management. --- .../app/src/components/ClaimRewardButton.tsx | 124 ++++++++++++++++++ packages/app/src/components/ClaimTimer.tsx | 61 +++++++++ .../app/src/components/JoinPoolButton.tsx | 107 +++++++++++++++ .../app/src/components/ViewCollective.tsx | 69 ++++++++++ .../app/src/components/modals/BaseModal.tsx | 13 +- packages/app/src/hooks/index.ts | 5 + packages/app/src/hooks/useClaimReward.ts | 51 +++++++ packages/app/src/hooks/useJoinPool.ts | 58 ++++++++ packages/app/src/hooks/usePoolMembership.ts | 45 +++++++ packages/app/src/hooks/usePoolOpenStatus.ts | 28 ++++ packages/app/src/hooks/usePoolRewards.ts | 108 +++++++++++++++ packages/app/src/subgraph/subgraphModels.ts | 12 ++ .../app/src/subgraph/useSubgraphCollective.ts | 10 ++ 13 files changed, 689 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/components/ClaimRewardButton.tsx create mode 100644 packages/app/src/components/ClaimTimer.tsx create mode 100644 packages/app/src/components/JoinPoolButton.tsx create mode 100644 packages/app/src/hooks/useClaimReward.ts create mode 100644 packages/app/src/hooks/useJoinPool.ts create mode 100644 packages/app/src/hooks/usePoolMembership.ts create mode 100644 packages/app/src/hooks/usePoolOpenStatus.ts create mode 100644 packages/app/src/hooks/usePoolRewards.ts diff --git a/packages/app/src/components/ClaimRewardButton.tsx b/packages/app/src/components/ClaimRewardButton.tsx new file mode 100644 index 00000000..4336256e --- /dev/null +++ b/packages/app/src/components/ClaimRewardButton.tsx @@ -0,0 +1,124 @@ +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 { usePoolRewards } from '../hooks/usePoolRewards'; +import BaseModal from './modals/BaseModal'; +import { ApproveTokenImg, PhoneImg, ThankYouImg } from '../assets'; +import { calculateGoodDollarAmounts } from '../lib/calculateGoodDollarAmounts'; +import { useGetTokenPrice } from '../hooks'; +import { ClaimTimer } from './ClaimTimer'; + +interface ClaimRewardButtonProps { + poolAddress: `0x${string}`; + poolType: string; + poolName?: string; + onSuccess?: () => void; +} + +export const ClaimRewardButton: React.FC = ({ poolAddress, poolType, poolName, onSuccess }) => { + const { address } = useAccount(); + const { claimReward, isConfirming, isSuccess, isError, error } = useClaimReward(poolAddress, poolType); + const { eligibleAmount, hasClaimed, nextClaimTime, claimPeriodDays } = usePoolRewards(poolAddress, poolType); + const { price: tokenPrice } = useGetTokenPrice('G$'); + const [showClaimModal, setShowClaimModal] = useState(false); + const [showProcessingModal, setShowProcessingModal] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + + const { wei: formattedAmount } = calculateGoodDollarAmounts(eligibleAmount.toString(), tokenPrice, 2); + + const handleClaimClick = () => { + setShowClaimModal(true); + }; + + const handleConfirmClaim = async () => { + setShowClaimModal(false); + setShowProcessingModal(true); + try { + await claimReward(); + } catch (err) { + setShowProcessingModal(false); + const message = err instanceof Error ? err.message : 'Failed to claim reward'; + setErrorMessage(message); + } + }; + + useEffect(() => { + if (isSuccess && !isConfirming) { + setShowProcessingModal(false); + setShowSuccessModal(true); + onSuccess?.(); + } + }, [isSuccess, isConfirming, onSuccess]); + + useEffect(() => { + if (isError && error) { + setShowProcessingModal(false); + const message = error.message || 'Failed to claim reward'; + setErrorMessage(message); + } + }, [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 ( + + + setShowClaimModal(false)} + 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" + /> + {}} + title="PROCESSING" + paragraphs={['Please wait while we process your claim...']} + image={ApproveTokenImg} + withClose={false} + /> + setShowSuccessModal(false)} + onConfirm={() => setShowSuccessModal(false)} + title="SUCCESS!" + paragraphs={[`You have successfully claimed your reward from ${poolName || 'the pool'}!`]} + image={ThankYouImg} + confirmButtonText="OK" + /> + setErrorMessage(undefined)} + onConfirm={() => setErrorMessage(undefined)} + errorMessage={errorMessage ?? ''} + /> + + ); +}; diff --git a/packages/app/src/components/ClaimTimer.tsx b/packages/app/src/components/ClaimTimer.tsx new file mode 100644 index 00000000..25ecd34a --- /dev/null +++ b/packages/app/src/components/ClaimTimer.tsx @@ -0,0 +1,61 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, VStack } from 'native-base'; +import { formatTime } from '../lib/formatTime'; + +interface ClaimTimerProps { + nextClaimTime: number; + claimPeriodDays: number; + poolName?: string; +} + +export const ClaimTimer: React.FC = ({ nextClaimTime }) => { + const [timeRemaining, setTimeRemaining] = useState(0); + + useEffect(() => { + const updateTimer = () => { + const now = Math.floor(Date.now() / 1000); + 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..3140df44 --- /dev/null +++ b/packages/app/src/components/JoinPoolButton.tsx @@ -0,0 +1,107 @@ +import React, { 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 { ApproveTokenImg, 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, 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); + } + }; + + React.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]); + + React.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" + /> + {}} + title="PROCESSING" + paragraphs={['Please wait while we process your request...']} + image={ApproveTokenImg} + withClose={false} + /> + 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..32235e2e 100644 --- a/packages/app/src/components/ViewCollective.tsx +++ b/packages/app/src/components/ViewCollective.tsx @@ -43,6 +43,10 @@ import { styles as walletCardStyles } from '../components/WalletCards/styles'; import { formatFlowRate } from '../lib/formatFlowRate'; import { StopDonationActionButton } from './StopDonationActionButton'; import BannerPool from './BannerPool'; +import { JoinPoolButton } from './JoinPoolButton'; +import { ClaimRewardButton } from './ClaimRewardButton'; +import { usePoolMembership } from '../hooks/usePoolMembership'; +import { usePoolOpenStatus } from '../hooks/usePoolOpenStatus'; const HasDonatedCard = ({ donorCollective, @@ -210,6 +214,10 @@ function ViewCollective({ collective }: ViewCollectiveProps) { const { price: tokenPrice } = useGetTokenPrice('G$'); const { stats } = useRealtimeStats(poolAddress); + // Check pool membership and open status + const { isMember, refetch: refetchMembership } = usePoolMembership(poolAddress as `0x${string}` | undefined); + const isPoolOpen = usePoolOpenStatus(poolAddress, pooltype); + const { wei: formattedTotalRewards, usdValue: totalRewardsUsdValue } = calculateGoodDollarAmounts( totalRewards, tokenPrice, @@ -265,6 +273,34 @@ function ViewCollective({ collective }: ViewCollectiveProps) { {maybeDonorCollective && maybeDonorCollective.flowRate !== '0' ? null : ( + {/* Join Pool Button - show if pool is open and user is not a member (only for UBI pools) */} + {isPoolOpen && !isMember && address && pooltype === 'UBI' && ( + { + // Refetch membership status + window.location.reload(); + }} + /> + )} + {/* Claim Reward Button - show if user is a member (only for UBI pools) */} + {isMember && address && pooltype === 'UBI' && ( + { + // Refetch membership and reward status + await refetchMembership(); + // Small delay to allow contract state to update + setTimeout(() => { + refetchMembership(); + }, 2000); + }} + /> + )} {infoLabel} + {/* Join Pool Button - show if pool is open and user is not a member (only for UBI pools) */} + {isPoolOpen && !isMember && address && pooltype === 'UBI' && ( + + { + // Refetch membership status + window.location.reload(); + }} + /> + + )} + {/* Claim Reward Button - show if user is a member (only for UBI pools) */} + {isMember && address && pooltype === 'UBI' && ( + + { + // Refetch membership and reward status + await refetchMembership(); + // Small delay to allow contract state to update + setTimeout(() => { + refetchMembership(); + }, 2000); + }} + /> + + )} + diff --git a/packages/app/src/components/modals/BaseModal.tsx b/packages/app/src/components/modals/BaseModal.tsx index 7caff908..1911dd85 100644 --- a/packages/app/src/components/modals/BaseModal.tsx +++ b/packages/app/src/components/modals/BaseModal.tsx @@ -105,7 +105,7 @@ export const BaseModal = ({ {withClose ? ( - + Close ) : null} @@ -125,7 +125,16 @@ export const BaseModal = ({ ) : null} - woman + {dImage && ( + {dTitle + )} {dMessage && ( {message} diff --git a/packages/app/src/hooks/index.ts b/packages/app/src/hooks/index.ts index 7093f8d9..07267fd0 100644 --- a/packages/app/src/hooks/index.ts +++ b/packages/app/src/hooks/index.ts @@ -8,3 +8,8 @@ export * from './useIsStewardVerified'; export * from './useEthers'; export * from './useTotalStats'; export * from './useCollectiveFees'; +export * from './usePoolMembership'; +export * from './usePoolRewards'; +export * from './useJoinPool'; +export * from './useClaimReward'; +export * from './usePoolOpenStatus'; diff --git a/packages/app/src/hooks/useClaimReward.ts b/packages/app/src/hooks/useClaimReward.ts new file mode 100644 index 00000000..82b2aa8d --- /dev/null +++ b/packages/app/src/hooks/useClaimReward.ts @@ -0,0 +1,51 @@ +import { useAccount, useWriteContract, useSimulateContract, useWaitForTransactionReceipt } from 'wagmi'; +import { useCallback } from 'react'; + +// ABI for claiming UBI rewards +const UBI_POOL_CLAIM_ABI = [ + { + inputs: [], + name: 'claim', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; + +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..279850a3 --- /dev/null +++ b/packages/app/src/hooks/useJoinPool.ts @@ -0,0 +1,58 @@ +import { useAccount, useWriteContract, useSimulateContract, useWaitForTransactionReceipt } from 'wagmi'; +import { useCallback } from 'react'; + +// ABI for joining a UBI pool +const UBI_POOL_ABI = [ + { + inputs: [ + { name: 'member', type: 'address', internalType: 'address' }, + { name: 'extraData', type: 'bytes', internalType: 'bytes' }, + ], + name: 'addMember', + outputs: [{ name: 'isMember', type: 'bool', internalType: 'bool' }], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; + +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 } = 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, + }, + }); + + const { writeContractAsync, isPending, isError, error, data: hash } = useWriteContract(); + + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ + hash, + chainId: chain?.id, + }); + + const joinPool = useCallback(async () => { + if (!simulateData) { + throw new Error('Transaction simulation failed'); + } + return writeContractAsync(simulateData.request); + }, [simulateData, writeContractAsync]); + + return { + joinPool, + isPending, + isConfirming, + isSuccess, + isError, + error: error || simulateError, + hash, + }; +} diff --git a/packages/app/src/hooks/usePoolMembership.ts b/packages/app/src/hooks/usePoolMembership.ts new file mode 100644 index 00000000..cd5cbd57 --- /dev/null +++ b/packages/app/src/hooks/usePoolMembership.ts @@ -0,0 +1,45 @@ +import { useAccount, useReadContract } from 'wagmi'; +import { keccak256 } from 'viem'; +import { stringToBytes } from 'viem/utils'; + +// MEMBER_ROLE = keccak256("MEMBER_ROLE") +const MEMBER_ROLE = keccak256(stringToBytes('MEMBER_ROLE')); + +// ABI for checking membership (AccessControl) +const ACCESS_CONTROL_ABI = [ + { + inputs: [ + { name: 'role', type: 'bytes32', internalType: 'bytes32' }, + { name: 'account', type: 'address', internalType: 'address' }, + ], + name: 'hasRole', + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + type: 'function', + }, +] as const; + +export function usePoolMembership(poolAddress: `0x${string}` | undefined) { + const { address, chain } = useAccount(); + + const { + data: isMember, + isLoading, + refetch, + } = useReadContract({ + chainId: chain?.id, + address: poolAddress, + abi: ACCESS_CONTROL_ABI, + functionName: 'hasRole', + args: [MEMBER_ROLE as `0x${string}`, address as `0x${string}`], + query: { + enabled: !!poolAddress && !!address, + }, + }); + + return { + isMember: isMember ?? false, + isLoading, + refetch, + }; +} diff --git a/packages/app/src/hooks/usePoolOpenStatus.ts b/packages/app/src/hooks/usePoolOpenStatus.ts new file mode 100644 index 00000000..efc30845 --- /dev/null +++ b/packages/app/src/hooks/usePoolOpenStatus.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; +import { useSubgraphCollectivesById } from '../subgraph/useSubgraphCollective'; +import { zeroAddress } from 'viem'; + +export function usePoolOpenStatus(poolAddress: string | undefined, poolType: string | undefined): boolean | undefined { + const subgraphCollectives = useSubgraphCollectivesById(poolAddress ? [poolAddress] : []); + const collective = subgraphCollectives?.[0]; + + return useMemo(() => { + if (!collective || !poolType) { + return undefined; + } + + if (poolType === 'UBI') { + // For UBI pools, check if onlyMembers is false + return collective.ubiLimits ? !collective.ubiLimits.onlyMembers : undefined; + } + + if (poolType === 'DirectPayments') { + // For DirectPayments pools, check if membersValidator is zero address + // If membersValidator is zero, anyone can join + const membersValidator = collective.settings?.membersValidator; + return membersValidator ? membersValidator.toLowerCase() === zeroAddress.toLowerCase() : undefined; + } + + return undefined; + }, [collective, poolType]); +} diff --git a/packages/app/src/hooks/usePoolRewards.ts b/packages/app/src/hooks/usePoolRewards.ts new file mode 100644 index 00000000..ddbffd40 --- /dev/null +++ b/packages/app/src/hooks/usePoolRewards.ts @@ -0,0 +1,108 @@ +import { useAccount, useReadContract } from 'wagmi'; +import { useMemo } from 'react'; + +const UBI_POOL_ABI = [ + { + inputs: [{ name: '_member', type: 'address', internalType: 'address' }], + name: 'checkEntitlement', + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [{ name: '_member', type: 'address', internalType: 'address' }], + name: 'hasClaimed', + outputs: [{ name: '', type: 'bool', internalType: 'bool' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'nextClaimTime', + outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'ubiSettings', + outputs: [ + { name: 'cycleLengthDays', type: 'uint32', internalType: 'uint32' }, + { name: 'claimPeriodDays', type: 'uint32', internalType: 'uint32' }, + { name: 'minActiveUsers', type: 'uint32', internalType: 'uint32' }, + { name: 'claimForEnabled', type: 'bool', internalType: 'bool' }, + { name: 'maxClaimAmount', type: 'uint256', internalType: 'uint256' }, + { name: 'maxMembers', type: 'uint32', internalType: 'uint32' }, + { name: 'onlyMembers', type: 'bool', internalType: 'bool' }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const; + +export function usePoolRewards(poolAddress: `0x${string}` | undefined, poolType: string | undefined) { + const { address, chain } = useAccount(); + + // Check if pool is open (only for UBI pools) + const { data: ubiSettings } = useReadContract({ + chainId: chain?.id, + address: poolAddress, + abi: UBI_POOL_ABI, + functionName: 'ubiSettings', + query: { + enabled: !!poolAddress && poolType === 'UBI', + }, + }); + + // Get eligible reward amount (only for UBI pools) + const { data: eligibleAmount } = useReadContract({ + chainId: chain?.id, + address: poolAddress, + abi: UBI_POOL_ABI, + functionName: 'checkEntitlement', + args: [address as `0x${string}`], + query: { + enabled: !!poolAddress && !!address && poolType === 'UBI', + }, + }); + + // Check if already claimed (only for UBI pools) + const { data: hasClaimed } = useReadContract({ + chainId: chain?.id, + address: poolAddress, + abi: UBI_POOL_ABI, + functionName: 'hasClaimed', + args: [address as `0x${string}`], + query: { + enabled: !!poolAddress && !!address && poolType === 'UBI', + }, + }); + + // Get next claim time (only for UBI pools) + const { data: nextClaimTime } = useReadContract({ + chainId: chain?.id, + address: poolAddress, + abi: UBI_POOL_ABI, + functionName: 'nextClaimTime', + query: { + enabled: !!poolAddress && poolType === 'UBI', + }, + }); + + const isPoolOpen = useMemo(() => { + if (poolType === 'UBI') { + return ubiSettings ? !ubiSettings[6] : undefined; // onlyMembers is at index 6 + } + // For DirectPayments pools, we'll need to check membersValidator from subgraph + // For now, return undefined and we'll handle it in the component + return undefined; + }, [poolType, ubiSettings]); + + return { + isPoolOpen, + eligibleAmount: eligibleAmount ? BigInt(eligibleAmount.toString()) : 0n, + hasClaimed: hasClaimed ?? false, + nextClaimTime: nextClaimTime ? Number(BigInt(nextClaimTime.toString())) : undefined, + claimPeriodDays: ubiSettings ? Number(ubiSettings[1]) : undefined, // claimPeriodDays is at index 1 + }; +} 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 } } } From ae80757cf30fcaf35364f320ecbdc3caf090589d Mon Sep 17 00:00:00 2001 From: Emeka Manuel Date: Wed, 26 Nov 2025 00:56:44 +0100 Subject: [PATCH 02/11] refactor: update onSuccess handlers in ViewCollective to use refetchMembership instead of window.location.reload for improved user experience --- packages/app/src/components/ViewCollective.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/ViewCollective.tsx b/packages/app/src/components/ViewCollective.tsx index 32235e2e..81cc861b 100644 --- a/packages/app/src/components/ViewCollective.tsx +++ b/packages/app/src/components/ViewCollective.tsx @@ -279,9 +279,9 @@ function ViewCollective({ collective }: ViewCollectiveProps) { poolAddress={poolAddress as `0x${string}`} poolType={pooltype} poolName={ipfs?.name} - onSuccess={() => { - // Refetch membership status - window.location.reload(); + onSuccess={async () => { + // Refetch membership status without reloading the page + await refetchMembership(); }} /> )} @@ -432,9 +432,9 @@ function ViewCollective({ collective }: ViewCollectiveProps) { poolAddress={poolAddress as `0x${string}`} poolType={pooltype} poolName={ipfs?.name} - onSuccess={() => { - // Refetch membership status - window.location.reload(); + onSuccess={async () => { + // Refetch membership status without reloading the page + await refetchMembership(); }} /> From 3d4333719df5843a121a81844f746514e8acb2b0 Mon Sep 17 00:00:00 2001 From: Emeka Manuel Date: Wed, 26 Nov 2025 01:03:04 +0100 Subject: [PATCH 03/11] refactor: streamline ClaimRewardButton state management by replacing individual modal states with a unified status state for improved clarity and maintainability --- .../app/src/components/ClaimRewardButton.tsx | 46 +++++++++++-------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/packages/app/src/components/ClaimRewardButton.tsx b/packages/app/src/components/ClaimRewardButton.tsx index 4336256e..d334358c 100644 --- a/packages/app/src/components/ClaimRewardButton.tsx +++ b/packages/app/src/components/ClaimRewardButton.tsx @@ -11,6 +11,8 @@ 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; @@ -23,42 +25,42 @@ export const ClaimRewardButton: React.FC = ({ poolAddres const { claimReward, isConfirming, isSuccess, isError, error } = useClaimReward(poolAddress, poolType); const { eligibleAmount, hasClaimed, nextClaimTime, claimPeriodDays } = usePoolRewards(poolAddress, poolType); const { price: tokenPrice } = useGetTokenPrice('G$'); - const [showClaimModal, setShowClaimModal] = useState(false); - const [showProcessingModal, setShowProcessingModal] = useState(false); - const [showSuccessModal, setShowSuccessModal] = useState(false); + const [status, setStatus] = useState('idle'); const [errorMessage, setErrorMessage] = useState(); const { wei: formattedAmount } = calculateGoodDollarAmounts(eligibleAmount.toString(), tokenPrice, 2); const handleClaimClick = () => { - setShowClaimModal(true); + setStatus('confirm'); }; const handleConfirmClaim = async () => { - setShowClaimModal(false); - setShowProcessingModal(true); + setStatus('processing'); try { await claimReward(); + // success is handled by useEffect listening to isSuccess } catch (err) { - setShowProcessingModal(false); + // 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) { - setShowProcessingModal(false); - setShowSuccessModal(true); + setStatus('success'); onSuccess?.(); } }, [isSuccess, isConfirming, onSuccess]); + // Handle error state from hook useEffect(() => { if (isError && error) { - setShowProcessingModal(false); const message = error.message || 'Failed to claim reward'; setErrorMessage(message); + setStatus('error'); } }, [isError, error]); @@ -82,8 +84,8 @@ export const ClaimRewardButton: React.FC = ({ poolAddres onPress={handleClaimClick} /> setShowClaimModal(false)} + openModal={status === 'confirm'} + onClose={() => setStatus('idle')} onConfirm={handleConfirmClaim} title="CLAIM REWARD" paragraphs={[ @@ -96,7 +98,7 @@ export const ClaimRewardButton: React.FC = ({ poolAddres confirmButtonText="CLAIM" /> {}} title="PROCESSING" paragraphs={['Please wait while we process your claim...']} @@ -104,9 +106,9 @@ export const ClaimRewardButton: React.FC = ({ poolAddres withClose={false} /> setShowSuccessModal(false)} - onConfirm={() => setShowSuccessModal(false)} + openModal={status === 'success'} + onClose={() => setStatus('idle')} + onConfirm={() => setStatus('idle')} title="SUCCESS!" paragraphs={[`You have successfully claimed your reward from ${poolName || 'the pool'}!`]} image={ThankYouImg} @@ -114,9 +116,15 @@ export const ClaimRewardButton: React.FC = ({ poolAddres /> setErrorMessage(undefined)} - onConfirm={() => setErrorMessage(undefined)} + openModal={status === 'error'} + onClose={() => { + setErrorMessage(undefined); + setStatus('idle'); + }} + onConfirm={() => { + setErrorMessage(undefined); + setStatus('idle'); + }} errorMessage={errorMessage ?? ''} /> From 5efc1aad6cfb57ca855c747f9d5308a057fd30be Mon Sep 17 00:00:00 2001 From: Emeka Manuel Date: Wed, 26 Nov 2025 01:07:55 +0100 Subject: [PATCH 04/11] refactor: enhance usePoolRewards hook by consolidating UBI pool condition checks for improved clarity and maintainability --- packages/app/src/hooks/usePoolRewards.ts | 40 +++++++++++++++--------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/app/src/hooks/usePoolRewards.ts b/packages/app/src/hooks/usePoolRewards.ts index ddbffd40..96afbd8d 100644 --- a/packages/app/src/hooks/usePoolRewards.ts +++ b/packages/app/src/hooks/usePoolRewards.ts @@ -43,18 +43,26 @@ const UBI_POOL_ABI = [ export function usePoolRewards(poolAddress: `0x${string}` | undefined, poolType: string | undefined) { const { address, chain } = useAccount(); - // Check if pool is open (only for UBI pools) + // Factor shared enabled conditions + const isUBIPool = poolType === 'UBI'; + const hasPoolAddress = !!poolAddress; + const hasAddress = !!address; + + const enabledUBI = hasPoolAddress && isUBIPool; + const enabledUBIWithAddress = enabledUBI && hasAddress; + + // Get UBI settings (only for UBI pools) const { data: ubiSettings } = useReadContract({ chainId: chain?.id, address: poolAddress, abi: UBI_POOL_ABI, functionName: 'ubiSettings', query: { - enabled: !!poolAddress && poolType === 'UBI', + enabled: enabledUBI, }, }); - // Get eligible reward amount (only for UBI pools) + // Get eligible reward amount (only for UBI pools with address) const { data: eligibleAmount } = useReadContract({ chainId: chain?.id, address: poolAddress, @@ -62,11 +70,11 @@ export function usePoolRewards(poolAddress: `0x${string}` | undefined, poolType: functionName: 'checkEntitlement', args: [address as `0x${string}`], query: { - enabled: !!poolAddress && !!address && poolType === 'UBI', + enabled: enabledUBIWithAddress, }, }); - // Check if already claimed (only for UBI pools) + // Check if already claimed (only for UBI pools with address) const { data: hasClaimed } = useReadContract({ chainId: chain?.id, address: poolAddress, @@ -74,7 +82,7 @@ export function usePoolRewards(poolAddress: `0x${string}` | undefined, poolType: functionName: 'hasClaimed', args: [address as `0x${string}`], query: { - enabled: !!poolAddress && !!address && poolType === 'UBI', + enabled: enabledUBIWithAddress, }, }); @@ -85,18 +93,19 @@ export function usePoolRewards(poolAddress: `0x${string}` | undefined, poolType: abi: UBI_POOL_ABI, functionName: 'nextClaimTime', query: { - enabled: !!poolAddress && poolType === 'UBI', + enabled: enabledUBI, }, }); - const isPoolOpen = useMemo(() => { - if (poolType === 'UBI') { - return ubiSettings ? !ubiSettings[6] : undefined; // onlyMembers is at index 6 - } - // For DirectPayments pools, we'll need to check membersValidator from subgraph - // For now, return undefined and we'll handle it in the component - return undefined; - }, [poolType, ubiSettings]); + // Extract onlyMembers from ubiSettings (index 6) + const ubiOnlyMembers = ubiSettings ? ubiSettings[6] : undefined; + + // Thin derivation of isPoolOpen for backward compatibility + // Prefer usePoolOpenStatus as the single source of truth + const isPoolOpen = useMemo( + () => (poolType === 'UBI' ? (ubiOnlyMembers === undefined ? undefined : !ubiOnlyMembers) : undefined), + [poolType, ubiOnlyMembers] + ); return { isPoolOpen, @@ -104,5 +113,6 @@ export function usePoolRewards(poolAddress: `0x${string}` | undefined, poolType: hasClaimed: hasClaimed ?? false, nextClaimTime: nextClaimTime ? Number(BigInt(nextClaimTime.toString())) : undefined, claimPeriodDays: ubiSettings ? Number(ubiSettings[1]) : undefined, // claimPeriodDays is at index 1 + onlyMembers: ubiOnlyMembers, }; } From 7a939ac5d3dcd39da748828f4825eb84ae48d86d Mon Sep 17 00:00:00 2001 From: Emeka Manuel Date: Wed, 26 Nov 2025 01:11:21 +0100 Subject: [PATCH 05/11] refactor: simplify ClaimTimer component by removing unused props and updating ClaimRewardButton to reflect changes for improved clarity --- packages/app/src/components/ClaimRewardButton.tsx | 2 +- packages/app/src/components/ClaimTimer.tsx | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/app/src/components/ClaimRewardButton.tsx b/packages/app/src/components/ClaimRewardButton.tsx index d334358c..f9da32b7 100644 --- a/packages/app/src/components/ClaimRewardButton.tsx +++ b/packages/app/src/components/ClaimRewardButton.tsx @@ -70,7 +70,7 @@ export const ClaimRewardButton: React.FC = ({ poolAddres // If already claimed, show timer if (hasClaimed && nextClaimTime && claimPeriodDays) { - return ; + return ; } // Show button even if amount is 0 (pool might not have funds yet, but user is a member) diff --git a/packages/app/src/components/ClaimTimer.tsx b/packages/app/src/components/ClaimTimer.tsx index 25ecd34a..c6666430 100644 --- a/packages/app/src/components/ClaimTimer.tsx +++ b/packages/app/src/components/ClaimTimer.tsx @@ -4,8 +4,6 @@ import { formatTime } from '../lib/formatTime'; interface ClaimTimerProps { nextClaimTime: number; - claimPeriodDays: number; - poolName?: string; } export const ClaimTimer: React.FC = ({ nextClaimTime }) => { From 10e627f5a6ead8a4cd83684be3d3cd9327b003a8 Mon Sep 17 00:00:00 2001 From: Emeka Manuel Date: Wed, 26 Nov 2025 09:12:31 +0100 Subject: [PATCH 06/11] refactor: remove usePoolRewards hook and integrate the goodcollective sdk --- .../app/src/components/ClaimRewardButton.tsx | 29 +++-- packages/app/src/components/ClaimTimer.tsx | 3 +- .../app/src/components/JoinPoolButton.tsx | 12 +- .../app/src/components/ViewCollective.tsx | 112 +++++++++++++---- packages/app/src/hooks/index.ts | 1 - packages/app/src/hooks/useClaimReward.ts | 16 +-- packages/app/src/hooks/useJoinPool.ts | 19 +-- packages/app/src/hooks/usePoolRewards.ts | 118 ------------------ 8 files changed, 125 insertions(+), 185 deletions(-) delete mode 100644 packages/app/src/hooks/usePoolRewards.ts diff --git a/packages/app/src/components/ClaimRewardButton.tsx b/packages/app/src/components/ClaimRewardButton.tsx index f9da32b7..eb76f152 100644 --- a/packages/app/src/components/ClaimRewardButton.tsx +++ b/packages/app/src/components/ClaimRewardButton.tsx @@ -4,9 +4,9 @@ import { useAccount } from 'wagmi'; import RoundedButton from './RoundedButton'; import { Colors } from '../utils/colors'; import { useClaimReward } from '../hooks/useClaimReward'; -import { usePoolRewards } from '../hooks/usePoolRewards'; import BaseModal from './modals/BaseModal'; -import { ApproveTokenImg, PhoneImg, ThankYouImg } from '../assets'; +import ProcessingModal from './modals/ProcessingModal'; +import { PhoneImg, ThankYouImg } from '../assets'; import { calculateGoodDollarAmounts } from '../lib/calculateGoodDollarAmounts'; import { useGetTokenPrice } from '../hooks'; import { ClaimTimer } from './ClaimTimer'; @@ -18,12 +18,24 @@ interface ClaimRewardButtonProps { poolType: string; poolName?: string; onSuccess?: () => void; + eligibleAmount?: bigint; + hasClaimed?: boolean; + nextClaimTime?: number; + claimPeriodDays?: number; } -export const ClaimRewardButton: React.FC = ({ poolAddress, poolType, poolName, onSuccess }) => { +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 { eligibleAmount, hasClaimed, nextClaimTime, claimPeriodDays } = usePoolRewards(poolAddress, poolType); const { price: tokenPrice } = useGetTokenPrice('G$'); const [status, setStatus] = useState('idle'); const [errorMessage, setErrorMessage] = useState(); @@ -97,14 +109,7 @@ export const ClaimRewardButton: React.FC = ({ poolAddres image={PhoneImg} confirmButtonText="CLAIM" /> - {}} - title="PROCESSING" - paragraphs={['Please wait while we process your claim...']} - image={ApproveTokenImg} - withClose={false} - /> + setStatus('idle')} diff --git a/packages/app/src/components/ClaimTimer.tsx b/packages/app/src/components/ClaimTimer.tsx index c6666430..99fa4b86 100644 --- a/packages/app/src/components/ClaimTimer.tsx +++ b/packages/app/src/components/ClaimTimer.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { View, Text, VStack } from 'native-base'; +import moment from 'moment'; import { formatTime } from '../lib/formatTime'; interface ClaimTimerProps { @@ -11,7 +12,7 @@ export const ClaimTimer: React.FC = ({ nextClaimTime }) => { useEffect(() => { const updateTimer = () => { - const now = Math.floor(Date.now() / 1000); + const now = moment().unix(); const remaining = Math.max(0, nextClaimTime - now); setTimeRemaining(remaining); }; diff --git a/packages/app/src/components/JoinPoolButton.tsx b/packages/app/src/components/JoinPoolButton.tsx index 3140df44..c80409b2 100644 --- a/packages/app/src/components/JoinPoolButton.tsx +++ b/packages/app/src/components/JoinPoolButton.tsx @@ -5,7 +5,8 @@ import RoundedButton from './RoundedButton'; import { Colors } from '../utils/colors'; import { useJoinPool } from '../hooks/useJoinPool'; import BaseModal from './modals/BaseModal'; -import { ApproveTokenImg, PhoneImg, ThankYouImg } from '../assets'; +import ProcessingModal from './modals/ProcessingModal'; +import { PhoneImg, ThankYouImg } from '../assets'; interface JoinPoolButtonProps { poolAddress: `0x${string}`; @@ -78,14 +79,7 @@ export const JoinPoolButton: React.FC = ({ poolAddress, poo image={PhoneImg} confirmButtonText="JOIN" /> - {}} - title="PROCESSING" - paragraphs={['Please wait while we process your request...']} - image={ApproveTokenImg} - withClose={false} - /> + setShowSuccessModal(false)} diff --git a/packages/app/src/components/ViewCollective.tsx b/packages/app/src/components/ViewCollective.tsx index 81cc861b..8902423b 100644 --- a/packages/app/src/components/ViewCollective.tsx +++ b/packages/app/src/components/ViewCollective.tsx @@ -1,20 +1,19 @@ -import { StyleSheet, Image } from 'react-native'; -import { Link, useBreakpointValue, Text, View, VStack } from 'native-base'; +import { Link, Text, useBreakpointValue, View, VStack } from 'native-base'; +import { 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 { AtIcon, CalendarIcon, @@ -31,22 +30,32 @@ 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 { usePoolMembership } from '../hooks/usePoolMembership'; +import { usePoolOpenStatus } from '../hooks/usePoolOpenStatus'; 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 { JoinPoolButton } from './JoinPoolButton'; import { ClaimRewardButton } from './ClaimRewardButton'; -import { usePoolMembership } from '../hooks/usePoolMembership'; -import { usePoolOpenStatus } from '../hooks/usePoolOpenStatus'; +import FlowingDonationsRowItem from './FlowingDonationsRowItem'; +import { GoodDollarAmount } from './GoodDollarAmount'; +import { JoinPoolButton } from './JoinPoolButton'; +import { StopDonationActionButton } from './StopDonationActionButton'; const HasDonatedCard = ({ donorCollective, @@ -208,9 +217,62 @@ 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); + + useEffect(() => { + const fetchMemberPools = async () => { + if (!address || !poolAddress || pooltype !== 'UBI' || !provider) { + setMemberPoolData(null); + return; + } + try { + const network = SupportedNetworkNames[chainId as SupportedNetwork]; + const sdk = new GoodCollectiveSDK(chainId.toString() as any, provider, { network }); + const pools = await sdk.getMemberUBIPools(address); + const currentPool = pools.find((pool: any) => pool.contract.toLowerCase() === poolAddress.toLowerCase()); + if (!currentPool) { + setMemberPoolData(null); + return; + } + + const claimAmountStr = currentPool.claimAmount?.toString?.() ?? '0'; + const nextClaimTimeStr = currentPool.nextClaimTime?.toString?.(); + const claimPeriodDaysRaw = currentPool.ubiSettings?.claimPeriodDays; + const onlyMembersRaw = currentPool.ubiSettings?.onlyMembers; + + const eligibleAmount = BigInt(claimAmountStr || '0'); + const hasClaimed = eligibleAmount === 0n; + + setMemberPoolData({ + eligibleAmount, + hasClaimed, + 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, + }); + } catch (e) { + // If SDK call fails, gracefully fall back to null so UI can still render + setMemberPoolData(null); + } + }; + + fetchMemberPools(); + }, [address, poolAddress, pooltype, provider, chainId]); + const { price: tokenPrice } = useGetTokenPrice('G$'); const { stats } = useRealtimeStats(poolAddress); @@ -291,6 +353,10 @@ function ViewCollective({ collective }: ViewCollectiveProps) { poolAddress={poolAddress as `0x${string}`} poolType={pooltype} poolName={ipfs?.name} + eligibleAmount={memberPoolData?.eligibleAmount} + hasClaimed={memberPoolData?.hasClaimed} + nextClaimTime={memberPoolData?.nextClaimTime} + claimPeriodDays={memberPoolData?.claimPeriodDays} onSuccess={async () => { // Refetch membership and reward status await refetchMembership(); @@ -446,6 +512,10 @@ function ViewCollective({ collective }: ViewCollectiveProps) { poolAddress={poolAddress as `0x${string}`} poolType={pooltype} poolName={ipfs?.name} + eligibleAmount={memberPoolData?.eligibleAmount} + hasClaimed={memberPoolData?.hasClaimed} + nextClaimTime={memberPoolData?.nextClaimTime} + claimPeriodDays={memberPoolData?.claimPeriodDays} onSuccess={async () => { // Refetch membership and reward status await refetchMembership(); diff --git a/packages/app/src/hooks/index.ts b/packages/app/src/hooks/index.ts index 07267fd0..733330ab 100644 --- a/packages/app/src/hooks/index.ts +++ b/packages/app/src/hooks/index.ts @@ -9,7 +9,6 @@ export * from './useEthers'; export * from './useTotalStats'; export * from './useCollectiveFees'; export * from './usePoolMembership'; -export * from './usePoolRewards'; export * from './useJoinPool'; export * from './useClaimReward'; export * from './usePoolOpenStatus'; diff --git a/packages/app/src/hooks/useClaimReward.ts b/packages/app/src/hooks/useClaimReward.ts index 82b2aa8d..4d929706 100644 --- a/packages/app/src/hooks/useClaimReward.ts +++ b/packages/app/src/hooks/useClaimReward.ts @@ -1,16 +1,12 @@ import { useAccount, useWriteContract, useSimulateContract, useWaitForTransactionReceipt } from 'wagmi'; import { useCallback } from 'react'; +import GoodCollectiveContracts from '../../../contracts/releases/deployment.json'; +import env from '../lib/env'; -// ABI for claiming UBI rewards -const UBI_POOL_CLAIM_ABI = [ - { - inputs: [], - name: 'claim', - outputs: [], - stateMutability: 'nonpayable', - type: 'function', - }, -] as const; +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(); diff --git a/packages/app/src/hooks/useJoinPool.ts b/packages/app/src/hooks/useJoinPool.ts index 279850a3..df8b5f02 100644 --- a/packages/app/src/hooks/useJoinPool.ts +++ b/packages/app/src/hooks/useJoinPool.ts @@ -1,19 +1,12 @@ import { useAccount, useWriteContract, useSimulateContract, useWaitForTransactionReceipt } from 'wagmi'; import { useCallback } from 'react'; +import GoodCollectiveContracts from '../../../contracts/releases/deployment.json'; +import env from '../lib/env'; -// ABI for joining a UBI pool -const UBI_POOL_ABI = [ - { - inputs: [ - { name: 'member', type: 'address', internalType: 'address' }, - { name: 'extraData', type: 'bytes', internalType: 'bytes' }, - ], - name: 'addMember', - outputs: [{ name: 'isMember', type: 'bool', internalType: 'bool' }], - stateMutability: 'nonpayable', - type: 'function', - }, -] as const; +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 || + []; export function useJoinPool(poolAddress: `0x${string}` | undefined, poolType?: string) { const { address, chain } = useAccount(); diff --git a/packages/app/src/hooks/usePoolRewards.ts b/packages/app/src/hooks/usePoolRewards.ts deleted file mode 100644 index 96afbd8d..00000000 --- a/packages/app/src/hooks/usePoolRewards.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { useAccount, useReadContract } from 'wagmi'; -import { useMemo } from 'react'; - -const UBI_POOL_ABI = [ - { - inputs: [{ name: '_member', type: 'address', internalType: 'address' }], - name: 'checkEntitlement', - outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [{ name: '_member', type: 'address', internalType: 'address' }], - name: 'hasClaimed', - outputs: [{ name: '', type: 'bool', internalType: 'bool' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [], - name: 'nextClaimTime', - outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }], - stateMutability: 'view', - type: 'function', - }, - { - inputs: [], - name: 'ubiSettings', - outputs: [ - { name: 'cycleLengthDays', type: 'uint32', internalType: 'uint32' }, - { name: 'claimPeriodDays', type: 'uint32', internalType: 'uint32' }, - { name: 'minActiveUsers', type: 'uint32', internalType: 'uint32' }, - { name: 'claimForEnabled', type: 'bool', internalType: 'bool' }, - { name: 'maxClaimAmount', type: 'uint256', internalType: 'uint256' }, - { name: 'maxMembers', type: 'uint32', internalType: 'uint32' }, - { name: 'onlyMembers', type: 'bool', internalType: 'bool' }, - ], - stateMutability: 'view', - type: 'function', - }, -] as const; - -export function usePoolRewards(poolAddress: `0x${string}` | undefined, poolType: string | undefined) { - const { address, chain } = useAccount(); - - // Factor shared enabled conditions - const isUBIPool = poolType === 'UBI'; - const hasPoolAddress = !!poolAddress; - const hasAddress = !!address; - - const enabledUBI = hasPoolAddress && isUBIPool; - const enabledUBIWithAddress = enabledUBI && hasAddress; - - // Get UBI settings (only for UBI pools) - const { data: ubiSettings } = useReadContract({ - chainId: chain?.id, - address: poolAddress, - abi: UBI_POOL_ABI, - functionName: 'ubiSettings', - query: { - enabled: enabledUBI, - }, - }); - - // Get eligible reward amount (only for UBI pools with address) - const { data: eligibleAmount } = useReadContract({ - chainId: chain?.id, - address: poolAddress, - abi: UBI_POOL_ABI, - functionName: 'checkEntitlement', - args: [address as `0x${string}`], - query: { - enabled: enabledUBIWithAddress, - }, - }); - - // Check if already claimed (only for UBI pools with address) - const { data: hasClaimed } = useReadContract({ - chainId: chain?.id, - address: poolAddress, - abi: UBI_POOL_ABI, - functionName: 'hasClaimed', - args: [address as `0x${string}`], - query: { - enabled: enabledUBIWithAddress, - }, - }); - - // Get next claim time (only for UBI pools) - const { data: nextClaimTime } = useReadContract({ - chainId: chain?.id, - address: poolAddress, - abi: UBI_POOL_ABI, - functionName: 'nextClaimTime', - query: { - enabled: enabledUBI, - }, - }); - - // Extract onlyMembers from ubiSettings (index 6) - const ubiOnlyMembers = ubiSettings ? ubiSettings[6] : undefined; - - // Thin derivation of isPoolOpen for backward compatibility - // Prefer usePoolOpenStatus as the single source of truth - const isPoolOpen = useMemo( - () => (poolType === 'UBI' ? (ubiOnlyMembers === undefined ? undefined : !ubiOnlyMembers) : undefined), - [poolType, ubiOnlyMembers] - ); - - return { - isPoolOpen, - eligibleAmount: eligibleAmount ? BigInt(eligibleAmount.toString()) : 0n, - hasClaimed: hasClaimed ?? false, - nextClaimTime: nextClaimTime ? Number(BigInt(nextClaimTime.toString())) : undefined, - claimPeriodDays: ubiSettings ? Number(ubiSettings[1]) : undefined, // claimPeriodDays is at index 1 - onlyMembers: ubiOnlyMembers, - }; -} From 49e483edbf5a070a7f9a93f9bb1a85de64eea935 Mon Sep 17 00:00:00 2001 From: Emeka Manuel Date: Wed, 26 Nov 2025 21:19:30 +0100 Subject: [PATCH 07/11] refactor(): enhance ViewCollective component by integrating GoodCollectiveSDK for improved pool data fetching and state management - Replace useEffect with useCallback for fetching member pool data - Introduce state management for pool membership and eligibility - Update join and claim buttons to reflect new pool state logic - Improve error handling for SDK calls to ensure UI stability --- .../app/src/components/ViewCollective.tsx | 140 +++++++++++------- 1 file changed, 90 insertions(+), 50 deletions(-) diff --git a/packages/app/src/components/ViewCollective.tsx b/packages/app/src/components/ViewCollective.tsx index 8902423b..e0fb5507 100644 --- a/packages/app/src/components/ViewCollective.tsx +++ b/packages/app/src/components/ViewCollective.tsx @@ -1,5 +1,5 @@ import { Link, Text, useBreakpointValue, View, VStack } from 'native-base'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Image, StyleSheet } from 'react-native'; import { useAccount, useEnsName } from 'wagmi'; @@ -14,6 +14,8 @@ import { useScreenSize } from '../theme/hooks'; import { Colors } from '../utils/colors'; import { GoodCollectiveSDK } from '@gooddollar/goodcollective-sdk'; +import { ethers } from 'ethers'; +import GoodCollectiveContracts from '../../../contracts/releases/deployment.json'; import { AtIcon, CalendarIcon, @@ -35,8 +37,6 @@ import { useDonorCollectiveByAddresses, useGetTokenPrice } from '../hooks'; import { useEthersProvider } from '../hooks/useEthers'; import { useFlowingBalance } from '../hooks/useFlowingBalance'; import { useGetTokenBalance } from '../hooks/useGetTokenBalance'; -import { usePoolMembership } from '../hooks/usePoolMembership'; -import { usePoolOpenStatus } from '../hooks/usePoolOpenStatus'; import { useRealtimeStats } from '../hooks/useRealtimeStats'; import { calculateGoodDollarAmounts } from '../lib/calculateGoodDollarAmounts'; import env from '../lib/env'; @@ -231,55 +231,95 @@ function ViewCollective({ collective }: ViewCollectiveProps) { onlyMembers?: boolean | Promise; } | null>(null); - useEffect(() => { - const fetchMemberPools = async () => { - if (!address || !poolAddress || pooltype !== 'UBI' || !provider) { + const [poolOnlyMembers, setPoolOnlyMembers] = useState(undefined); + + const [refetchTrigger, setRefetchTrigger] = useState(0); + + const fetchMemberPools = useCallback(async () => { + if (!poolAddress || pooltype !== 'UBI' || !provider) { + setMemberPoolData(null); + setPoolOnlyMembers(undefined); + return; + } + try { + 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); return; } - try { - const network = SupportedNetworkNames[chainId as SupportedNetwork]; - const sdk = new GoodCollectiveSDK(chainId.toString() as any, provider, { network }); - const pools = await sdk.getMemberUBIPools(address); - const currentPool = pools.find((pool: any) => pool.contract.toLowerCase() === poolAddress.toLowerCase()); - if (!currentPool) { - setMemberPoolData(null); - return; - } - const claimAmountStr = currentPool.claimAmount?.toString?.() ?? '0'; - const nextClaimTimeStr = currentPool.nextClaimTime?.toString?.(); - const claimPeriodDaysRaw = currentPool.ubiSettings?.claimPeriodDays; - const onlyMembersRaw = currentPool.ubiSettings?.onlyMembers; - - const eligibleAmount = BigInt(claimAmountStr || '0'); - const hasClaimed = eligibleAmount === 0n; - - setMemberPoolData({ - eligibleAmount, - hasClaimed, - 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, - }); - } catch (e) { - // If SDK call fails, gracefully fall back to null so UI can still render + 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 user is not a member, set memberPoolData to null + if (!address || !currentPool.isRegistered) { setMemberPoolData(null); + return; } - }; - fetchMemberPools(); + 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 + const hasClaimed = hasClaimedToday; + + setMemberPoolData({ + eligibleAmount, + hasClaimed, + 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, + }); + } catch (e) { + // If SDK call fails, gracefully fall back to null so UI can still render + setMemberPoolData(null); + setPoolOnlyMembers(undefined); + } }, [address, poolAddress, pooltype, provider, chainId]); + useEffect(() => { + fetchMemberPools(); + }, [fetchMemberPools, refetchTrigger]); + + const refetchMemberPoolData = () => { + setRefetchTrigger((prev) => prev + 1); + }; + const { price: tokenPrice } = useGetTokenPrice('G$'); const { stats } = useRealtimeStats(poolAddress); - // Check pool membership and open status - const { isMember, refetch: refetchMembership } = usePoolMembership(poolAddress as `0x${string}` | undefined); - const isPoolOpen = usePoolOpenStatus(poolAddress, pooltype); - const { wei: formattedTotalRewards, usdValue: totalRewardsUsdValue } = calculateGoodDollarAmounts( totalRewards, tokenPrice, @@ -336,19 +376,19 @@ function ViewCollective({ collective }: ViewCollectiveProps) { {maybeDonorCollective && maybeDonorCollective.flowRate !== '0' ? null : ( {/* Join Pool Button - show if pool is open and user is not a member (only for UBI pools) */} - {isPoolOpen && !isMember && address && pooltype === 'UBI' && ( + {!poolOnlyMembers && !memberPoolData && address && pooltype === 'UBI' && ( { // Refetch membership status without reloading the page - await refetchMembership(); + refetchMemberPoolData(); }} /> )} {/* Claim Reward Button - show if user is a member (only for UBI pools) */} - {isMember && address && pooltype === 'UBI' && ( + {memberPoolData && address && pooltype === 'UBI' && ( { // Refetch membership and reward status - await refetchMembership(); + refetchMemberPoolData(); // Small delay to allow contract state to update setTimeout(() => { - refetchMembership(); + refetchMemberPoolData(); }, 2000); }} /> @@ -492,7 +532,7 @@ function ViewCollective({ collective }: ViewCollectiveProps) { {/* Join Pool Button - show if pool is open and user is not a member (only for UBI pools) */} - {isPoolOpen && !isMember && address && pooltype === 'UBI' && ( + {!poolOnlyMembers && !memberPoolData && address && pooltype === 'UBI' && ( { // Refetch membership status without reloading the page - await refetchMembership(); + refetchMemberPoolData(); }} /> )} {/* Claim Reward Button - show if user is a member (only for UBI pools) */} - {isMember && address && pooltype === 'UBI' && ( + {memberPoolData && address && pooltype === 'UBI' && ( { // Refetch membership and reward status - await refetchMembership(); + refetchMemberPoolData(); // Small delay to allow contract state to update setTimeout(() => { - refetchMembership(); + refetchMemberPoolData(); }, 2000); }} /> From b4ea1650c7cf8b1daba847e92000f68be5a8b44e Mon Sep 17 00:00:00 2001 From: Emeka Manuel Date: Wed, 26 Nov 2025 22:31:48 +0100 Subject: [PATCH 08/11] refactor: remove usePoolMembership and usePoolOpenStatus hooks to streamline codebase and improve maintainability --- packages/app/src/hooks/index.ts | 2 - packages/app/src/hooks/usePoolMembership.ts | 45 --------------------- packages/app/src/hooks/usePoolOpenStatus.ts | 28 ------------- 3 files changed, 75 deletions(-) delete mode 100644 packages/app/src/hooks/usePoolMembership.ts delete mode 100644 packages/app/src/hooks/usePoolOpenStatus.ts diff --git a/packages/app/src/hooks/index.ts b/packages/app/src/hooks/index.ts index 733330ab..7df5586d 100644 --- a/packages/app/src/hooks/index.ts +++ b/packages/app/src/hooks/index.ts @@ -8,7 +8,5 @@ export * from './useIsStewardVerified'; export * from './useEthers'; export * from './useTotalStats'; export * from './useCollectiveFees'; -export * from './usePoolMembership'; export * from './useJoinPool'; export * from './useClaimReward'; -export * from './usePoolOpenStatus'; diff --git a/packages/app/src/hooks/usePoolMembership.ts b/packages/app/src/hooks/usePoolMembership.ts deleted file mode 100644 index cd5cbd57..00000000 --- a/packages/app/src/hooks/usePoolMembership.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useAccount, useReadContract } from 'wagmi'; -import { keccak256 } from 'viem'; -import { stringToBytes } from 'viem/utils'; - -// MEMBER_ROLE = keccak256("MEMBER_ROLE") -const MEMBER_ROLE = keccak256(stringToBytes('MEMBER_ROLE')); - -// ABI for checking membership (AccessControl) -const ACCESS_CONTROL_ABI = [ - { - inputs: [ - { name: 'role', type: 'bytes32', internalType: 'bytes32' }, - { name: 'account', type: 'address', internalType: 'address' }, - ], - name: 'hasRole', - outputs: [{ name: '', type: 'bool', internalType: 'bool' }], - stateMutability: 'view', - type: 'function', - }, -] as const; - -export function usePoolMembership(poolAddress: `0x${string}` | undefined) { - const { address, chain } = useAccount(); - - const { - data: isMember, - isLoading, - refetch, - } = useReadContract({ - chainId: chain?.id, - address: poolAddress, - abi: ACCESS_CONTROL_ABI, - functionName: 'hasRole', - args: [MEMBER_ROLE as `0x${string}`, address as `0x${string}`], - query: { - enabled: !!poolAddress && !!address, - }, - }); - - return { - isMember: isMember ?? false, - isLoading, - refetch, - }; -} diff --git a/packages/app/src/hooks/usePoolOpenStatus.ts b/packages/app/src/hooks/usePoolOpenStatus.ts deleted file mode 100644 index efc30845..00000000 --- a/packages/app/src/hooks/usePoolOpenStatus.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useMemo } from 'react'; -import { useSubgraphCollectivesById } from '../subgraph/useSubgraphCollective'; -import { zeroAddress } from 'viem'; - -export function usePoolOpenStatus(poolAddress: string | undefined, poolType: string | undefined): boolean | undefined { - const subgraphCollectives = useSubgraphCollectivesById(poolAddress ? [poolAddress] : []); - const collective = subgraphCollectives?.[0]; - - return useMemo(() => { - if (!collective || !poolType) { - return undefined; - } - - if (poolType === 'UBI') { - // For UBI pools, check if onlyMembers is false - return collective.ubiLimits ? !collective.ubiLimits.onlyMembers : undefined; - } - - if (poolType === 'DirectPayments') { - // For DirectPayments pools, check if membersValidator is zero address - // If membersValidator is zero, anyone can join - const membersValidator = collective.settings?.membersValidator; - return membersValidator ? membersValidator.toLowerCase() === zeroAddress.toLowerCase() : undefined; - } - - return undefined; - }, [collective, poolType]); -} From 87c297431d6cc8da474eb7d036b82e75c4538bbf Mon Sep 17 00:00:00 2001 From: Lewis B Date: Thu, 27 Nov 2025 15:56:57 +0700 Subject: [PATCH 09/11] Apply suggestions from code review --- packages/app/src/components/JoinPoolButton.tsx | 6 +++--- packages/app/src/components/ViewCollective.tsx | 10 ++-------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/JoinPoolButton.tsx b/packages/app/src/components/JoinPoolButton.tsx index c80409b2..02a0e0ed 100644 --- a/packages/app/src/components/JoinPoolButton.tsx +++ b/packages/app/src/components/JoinPoolButton.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { View } from 'native-base'; import { useAccount } from 'wagmi'; import RoundedButton from './RoundedButton'; @@ -39,7 +39,7 @@ export const JoinPoolButton: React.FC = ({ poolAddress, poo } }; - React.useEffect(() => { + useEffect(() => { if (isSuccess && !isConfirming && hash) { setShowProcessingModal(false); setShowSuccessModal(true); @@ -50,7 +50,7 @@ export const JoinPoolButton: React.FC = ({ poolAddress, poo } }, [isSuccess, isConfirming, hash, onSuccess]); - React.useEffect(() => { + useEffect(() => { if (isError && error) { setShowProcessingModal(false); const message = error.message || 'Failed to join pool'; diff --git a/packages/app/src/components/ViewCollective.tsx b/packages/app/src/components/ViewCollective.tsx index e0fb5507..7e437818 100644 --- a/packages/app/src/components/ViewCollective.tsx +++ b/packages/app/src/components/ViewCollective.tsx @@ -263,7 +263,6 @@ function ViewCollective({ collective }: ViewCollectiveProps) { // Always store onlyMembers setting for pool open check setPoolOnlyMembers(onlyMembersRaw as boolean | undefined); - // If user is not a member, set memberPoolData to null if (!address || !currentPool.isRegistered) { setMemberPoolData(null); return; @@ -291,11 +290,9 @@ function ViewCollective({ collective }: ViewCollectiveProps) { // hasClaimed should only be true if the user has actually claimed today // The countdown should only show after a successful claim transaction - const hasClaimed = hasClaimedToday; - setMemberPoolData({ eligibleAmount, - hasClaimed, + 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. @@ -381,10 +378,7 @@ function ViewCollective({ collective }: ViewCollectiveProps) { poolAddress={poolAddress as `0x${string}`} poolType={pooltype} poolName={ipfs?.name} - onSuccess={async () => { - // Refetch membership status without reloading the page - refetchMemberPoolData(); - }} + onSuccess={refetchMemberPoolData} /> )} {/* Claim Reward Button - show if user is a member (only for UBI pools) */} From 28189534cbf180fac5594d023cf465137ffb03fd Mon Sep 17 00:00:00 2001 From: Emeka Manuel Date: Thu, 27 Nov 2025 13:06:44 +0100 Subject: [PATCH 10/11] feat: enhance ActionButton and JoinPoolButton components for improved user interaction - Add isDisabled prop to ActionButton to manage button state and styling - Update JoinPoolButton to include isSimulating state for better user feedback during pool joining - Integrate loading indicators in ViewCollective for improved UX while fetching pool details - Enhance BaseModal to handle error messages with external links for better clarity - Refactor useJoinPool hook to provide user-friendly error messages for join pool reverts --- packages/app/src/components/ActionButton.tsx | 19 ++- .../app/src/components/JoinPoolButton.tsx | 3 +- .../app/src/components/ViewCollective.tsx | 142 ++++++++++-------- .../app/src/components/modals/BaseModal.tsx | 55 +++++-- packages/app/src/hooks/useJoinPool.ts | 42 +++++- 5 files changed, 182 insertions(+), 79 deletions(-) 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/JoinPoolButton.tsx b/packages/app/src/components/JoinPoolButton.tsx index 02a0e0ed..78819065 100644 --- a/packages/app/src/components/JoinPoolButton.tsx +++ b/packages/app/src/components/JoinPoolButton.tsx @@ -17,7 +17,7 @@ interface JoinPoolButtonProps { export const JoinPoolButton: React.FC = ({ poolAddress, poolType, poolName, onSuccess }) => { const { address } = useAccount(); - const { joinPool, isConfirming, isSuccess, isError, error, hash } = useJoinPool(poolAddress, poolType); + 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); @@ -78,6 +78,7 @@ export const JoinPoolButton: React.FC = ({ poolAddress, poo paragraphs={[`To join ${poolName || 'this pool'}, please sign with your wallet.`]} image={PhoneImg} confirmButtonText="JOIN" + confirmDisabled={isSimulating} /> (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 }); @@ -252,6 +255,7 @@ function ViewCollective({ collective }: ViewCollectiveProps) { if (!currentPool) { setMemberPoolData(null); setPoolOnlyMembers(undefined); + setIsMemberPoolLoading(false); return; } @@ -265,6 +269,7 @@ function ViewCollective({ collective }: ViewCollectiveProps) { if (!address || !currentPool.isRegistered) { setMemberPoolData(null); + setIsMemberPoolLoading(false); return; } @@ -299,10 +304,12 @@ function ViewCollective({ collective }: ViewCollectiveProps) { // 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]); @@ -372,34 +379,39 @@ function ViewCollective({ collective }: ViewCollectiveProps) { {maybeDonorCollective && maybeDonorCollective.flowRate !== '0' ? null : ( - {/* Join Pool Button - show if pool is open and user is not a member (only for UBI pools) */} - {!poolOnlyMembers && !memberPoolData && address && pooltype === 'UBI' && ( - - )} - {/* Claim Reward Button - show if user is a member (only for UBI pools) */} - {memberPoolData && address && pooltype === 'UBI' && ( - { - // Refetch membership and reward status - refetchMemberPoolData(); - // Small delay to allow contract state to update - setTimeout(() => { - refetchMemberPoolData(); - }, 2000); - }} - /> + {pooltype === 'UBI' && isMemberPoolLoading ? ( + + + Loading pool details + + ) : ( + <> + {!poolOnlyMembers && !memberPoolData && address && pooltype === 'UBI' && ( + + )} + {memberPoolData && memberPoolData.eligibleAmount > 0n && address && pooltype === 'UBI' && ( + { + refetchMemberPoolData(); + setTimeout(() => { + refetchMemberPoolData(); + }, 2000); + }} + /> + )} + )} {infoLabel} - {/* Join Pool Button - show if pool is open and user is not a member (only for UBI pools) */} - {!poolOnlyMembers && !memberPoolData && address && pooltype === 'UBI' && ( - - { - // Refetch membership status without reloading the page - refetchMemberPoolData(); - }} - /> - - )} - {/* Claim Reward Button - show if user is a member (only for UBI pools) */} - {memberPoolData && address && pooltype === 'UBI' && ( + {pooltype === 'UBI' && isMemberPoolLoading ? ( - { - // Refetch membership and reward status - refetchMemberPoolData(); - // Small delay to allow contract state to update - setTimeout(() => { - refetchMemberPoolData(); - }, 2000); - }} - /> + + + 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 1911dd85..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 } = @@ -115,14 +141,22 @@ 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} {dImage && ( @@ -146,6 +180,7 @@ export const BaseModal = ({ onPress={onConfirm} bg="goodOrange.200" textColor="goodOrange.500" + isDisabled={confirmDisabled} /> ) : null} diff --git a/packages/app/src/hooks/useJoinPool.ts b/packages/app/src/hooks/useJoinPool.ts index df8b5f02..30cfbe70 100644 --- a/packages/app/src/hooks/useJoinPool.ts +++ b/packages/app/src/hooks/useJoinPool.ts @@ -8,13 +8,24 @@ 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 } = useSimulateContract({ + const { + data: simulateData, + error: simulateError, + isLoading: isSimulating, + } = useSimulateContract({ chainId: chain?.id, address: poolAddress, abi: UBI_POOL_ABI, @@ -25,6 +36,25 @@ export function useJoinPool(poolAddress: `0x${string}` | undefined, poolType?: s }, }); + if (simulateError) { + console.log('useJoinPool simulateData -->', { + simulateData, + simulateError, + chain, + UBI_POOL_ABI, + address, + }); + } + + 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 prepare your transaction. Please try again or contact support.'); + } + const { writeContractAsync, isPending, isError, error, data: hash } = useWriteContract(); const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ @@ -34,18 +64,22 @@ export function useJoinPool(poolAddress: `0x${string}` | undefined, poolType?: s const joinPool = useCallback(async () => { if (!simulateData) { - throw new Error('Transaction simulation failed'); + if (simulateUiError) { + throw simulateUiError; + } + throw new Error('Unable to prepare your transaction. Please try again.'); } return writeContractAsync(simulateData.request); - }, [simulateData, writeContractAsync]); + }, [simulateData, writeContractAsync, simulateUiError]); return { joinPool, isPending, + isSimulating, isConfirming, isSuccess, isError, - error: error || simulateError, + error: error || simulateUiError || simulateError, hash, }; } From 4d022f7d039913716723a36988a06d61c56b807b Mon Sep 17 00:00:00 2001 From: Lewis B Date: Thu, 27 Nov 2025 19:25:02 +0700 Subject: [PATCH 11/11] Apply suggestions from code review --- packages/app/src/hooks/useJoinPool.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/app/src/hooks/useJoinPool.ts b/packages/app/src/hooks/useJoinPool.ts index 30cfbe70..3c758652 100644 --- a/packages/app/src/hooks/useJoinPool.ts +++ b/packages/app/src/hooks/useJoinPool.ts @@ -36,15 +36,6 @@ export function useJoinPool(poolAddress: `0x${string}` | undefined, poolType?: s }, }); - if (simulateError) { - console.log('useJoinPool simulateData -->', { - simulateData, - simulateError, - chain, - UBI_POOL_ABI, - address, - }); - } let simulateUiError: Error | undefined; const simulateErrorName = (simulateError as any)?.cause?.data?.errorName as string | undefined; @@ -52,7 +43,7 @@ export function useJoinPool(poolAddress: `0x${string}` | undefined, poolType?: s if (simulateErrorName && joinPoolErrors[simulateErrorName]) { simulateUiError = new Error(joinPoolErrors[simulateErrorName]); } else if (simulateError) { - simulateUiError = new Error('Unable to prepare your transaction. Please try again or contact support.'); + simulateUiError = new Error('Unable to join this pool. Please try again or contact support.'); } const { writeContractAsync, isPending, isError, error, data: hash } = useWriteContract(); @@ -67,7 +58,7 @@ export function useJoinPool(poolAddress: `0x${string}` | undefined, poolType?: s if (simulateUiError) { throw simulateUiError; } - throw new Error('Unable to prepare your transaction. Please try again.'); + throw new Error('Unable to join this pool. Please try again or contact support.'); } return writeContractAsync(simulateData.request); }, [simulateData, writeContractAsync, simulateUiError]);