Skip to content

Commit 839ac09

Browse files
author
Emeka Manuel
committed
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.
1 parent ac662a9 commit 839ac09

File tree

13 files changed

+689
-2
lines changed

13 files changed

+689
-2
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { View } from 'native-base';
3+
import { useAccount } from 'wagmi';
4+
import RoundedButton from './RoundedButton';
5+
import { Colors } from '../utils/colors';
6+
import { useClaimReward } from '../hooks/useClaimReward';
7+
import { usePoolRewards } from '../hooks/usePoolRewards';
8+
import BaseModal from './modals/BaseModal';
9+
import { ApproveTokenImg, PhoneImg, ThankYouImg } from '../assets';
10+
import { calculateGoodDollarAmounts } from '../lib/calculateGoodDollarAmounts';
11+
import { useGetTokenPrice } from '../hooks';
12+
import { ClaimTimer } from './ClaimTimer';
13+
14+
interface ClaimRewardButtonProps {
15+
poolAddress: `0x${string}`;
16+
poolType: string;
17+
poolName?: string;
18+
onSuccess?: () => void;
19+
}
20+
21+
export const ClaimRewardButton: React.FC<ClaimRewardButtonProps> = ({ poolAddress, poolType, poolName, onSuccess }) => {
22+
const { address } = useAccount();
23+
const { claimReward, isConfirming, isSuccess, isError, error } = useClaimReward(poolAddress, poolType);
24+
const { eligibleAmount, hasClaimed, nextClaimTime, claimPeriodDays } = usePoolRewards(poolAddress, poolType);
25+
const { price: tokenPrice } = useGetTokenPrice('G$');
26+
const [showClaimModal, setShowClaimModal] = useState(false);
27+
const [showProcessingModal, setShowProcessingModal] = useState(false);
28+
const [showSuccessModal, setShowSuccessModal] = useState(false);
29+
const [errorMessage, setErrorMessage] = useState<string | undefined>();
30+
31+
const { wei: formattedAmount } = calculateGoodDollarAmounts(eligibleAmount.toString(), tokenPrice, 2);
32+
33+
const handleClaimClick = () => {
34+
setShowClaimModal(true);
35+
};
36+
37+
const handleConfirmClaim = async () => {
38+
setShowClaimModal(false);
39+
setShowProcessingModal(true);
40+
try {
41+
await claimReward();
42+
} catch (err) {
43+
setShowProcessingModal(false);
44+
const message = err instanceof Error ? err.message : 'Failed to claim reward';
45+
setErrorMessage(message);
46+
}
47+
};
48+
49+
useEffect(() => {
50+
if (isSuccess && !isConfirming) {
51+
setShowProcessingModal(false);
52+
setShowSuccessModal(true);
53+
onSuccess?.();
54+
}
55+
}, [isSuccess, isConfirming, onSuccess]);
56+
57+
useEffect(() => {
58+
if (isError && error) {
59+
setShowProcessingModal(false);
60+
const message = error.message || 'Failed to claim reward';
61+
setErrorMessage(message);
62+
}
63+
}, [isError, error]);
64+
65+
if (!address) {
66+
return null;
67+
}
68+
69+
// If already claimed, show timer
70+
if (hasClaimed && nextClaimTime && claimPeriodDays) {
71+
return <ClaimTimer nextClaimTime={nextClaimTime} claimPeriodDays={claimPeriodDays} poolName={poolName} />;
72+
}
73+
74+
// Show button even if amount is 0 (pool might not have funds yet, but user is a member)
75+
76+
return (
77+
<View>
78+
<RoundedButton
79+
title={`Claim Reward${formattedAmount ? ` (G$ ${formattedAmount})` : ' (...)'}`}
80+
backgroundColor={Colors.orange[100]}
81+
color={Colors.orange[300]}
82+
onPress={handleClaimClick}
83+
/>
84+
<BaseModal
85+
openModal={showClaimModal}
86+
onClose={() => setShowClaimModal(false)}
87+
onConfirm={handleConfirmClaim}
88+
title="CLAIM REWARD"
89+
paragraphs={[
90+
`You are eligible to claim ${formattedAmount ? `G$ ${formattedAmount}` : '...'} from ${
91+
poolName || 'this pool'
92+
}.`,
93+
'To claim your reward, please sign with your wallet.',
94+
]}
95+
image={PhoneImg}
96+
confirmButtonText="CLAIM"
97+
/>
98+
<BaseModal
99+
openModal={showProcessingModal}
100+
onClose={() => {}}
101+
title="PROCESSING"
102+
paragraphs={['Please wait while we process your claim...']}
103+
image={ApproveTokenImg}
104+
withClose={false}
105+
/>
106+
<BaseModal
107+
openModal={showSuccessModal}
108+
onClose={() => setShowSuccessModal(false)}
109+
onConfirm={() => setShowSuccessModal(false)}
110+
title="SUCCESS!"
111+
paragraphs={[`You have successfully claimed your reward from ${poolName || 'the pool'}!`]}
112+
image={ThankYouImg}
113+
confirmButtonText="OK"
114+
/>
115+
<BaseModal
116+
type="error"
117+
openModal={!!errorMessage}
118+
onClose={() => setErrorMessage(undefined)}
119+
onConfirm={() => setErrorMessage(undefined)}
120+
errorMessage={errorMessage ?? ''}
121+
/>
122+
</View>
123+
);
124+
};
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { View, Text, VStack } from 'native-base';
3+
import { formatTime } from '../lib/formatTime';
4+
5+
interface ClaimTimerProps {
6+
nextClaimTime: number;
7+
claimPeriodDays: number;
8+
poolName?: string;
9+
}
10+
11+
export const ClaimTimer: React.FC<ClaimTimerProps> = ({ nextClaimTime }) => {
12+
const [timeRemaining, setTimeRemaining] = useState<number>(0);
13+
14+
useEffect(() => {
15+
const updateTimer = () => {
16+
const now = Math.floor(Date.now() / 1000);
17+
const remaining = Math.max(0, nextClaimTime - now);
18+
setTimeRemaining(remaining);
19+
};
20+
21+
updateTimer();
22+
const interval = setInterval(updateTimer, 1000);
23+
24+
return () => clearInterval(interval);
25+
}, [nextClaimTime]);
26+
27+
if (timeRemaining === 0) {
28+
return (
29+
<View>
30+
<Text fontSize="sm" color="gray.500">
31+
You can claim again now
32+
</Text>
33+
</View>
34+
);
35+
}
36+
37+
const days = Math.floor(timeRemaining / 86400);
38+
const hours = Math.floor((timeRemaining % 86400) / 3600);
39+
const minutes = Math.floor((timeRemaining % 3600) / 60);
40+
const seconds = timeRemaining % 60;
41+
42+
return (
43+
<VStack space={2} alignItems="center" padding={4} backgroundColor="gray.50" borderRadius={8}>
44+
<Text fontSize="md" fontWeight="bold" textAlign="center">
45+
Already Claimed
46+
</Text>
47+
<Text fontSize="sm" color="gray.600" textAlign="center">
48+
You can claim again in:
49+
</Text>
50+
<Text fontSize="lg" fontWeight="bold" color="orange.500" textAlign="center">
51+
{days > 0 && `${days}d `}
52+
{hours > 0 && `${hours}h `}
53+
{minutes > 0 && `${minutes}m `}
54+
{seconds}s
55+
</Text>
56+
<Text fontSize="xs" color="gray.500" textAlign="center">
57+
Next claim: {formatTime(nextClaimTime)}
58+
</Text>
59+
</VStack>
60+
);
61+
};
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import React, { useState } from 'react';
2+
import { View } from 'native-base';
3+
import { useAccount } from 'wagmi';
4+
import RoundedButton from './RoundedButton';
5+
import { Colors } from '../utils/colors';
6+
import { useJoinPool } from '../hooks/useJoinPool';
7+
import BaseModal from './modals/BaseModal';
8+
import { ApproveTokenImg, PhoneImg, ThankYouImg } from '../assets';
9+
10+
interface JoinPoolButtonProps {
11+
poolAddress: `0x${string}`;
12+
poolType: string;
13+
poolName?: string;
14+
onSuccess?: () => void;
15+
}
16+
17+
export const JoinPoolButton: React.FC<JoinPoolButtonProps> = ({ poolAddress, poolType, poolName, onSuccess }) => {
18+
const { address } = useAccount();
19+
const { joinPool, isConfirming, isSuccess, isError, error, hash } = useJoinPool(poolAddress, poolType);
20+
const [showJoinModal, setShowJoinModal] = useState(false);
21+
const [showProcessingModal, setShowProcessingModal] = useState(false);
22+
const [showSuccessModal, setShowSuccessModal] = useState(false);
23+
const [errorMessage, setErrorMessage] = useState<string | undefined>();
24+
25+
const handleJoinClick = () => {
26+
setShowJoinModal(true);
27+
};
28+
29+
const handleConfirmJoin = async () => {
30+
setShowJoinModal(false);
31+
setShowProcessingModal(true);
32+
try {
33+
await joinPool();
34+
} catch (err) {
35+
setShowProcessingModal(false);
36+
const message = err instanceof Error ? err.message : 'Failed to join pool';
37+
setErrorMessage(message);
38+
}
39+
};
40+
41+
React.useEffect(() => {
42+
if (isSuccess && !isConfirming && hash) {
43+
setShowProcessingModal(false);
44+
setShowSuccessModal(true);
45+
// Wait a bit for the transaction to be indexed, then call onSuccess
46+
setTimeout(() => {
47+
onSuccess?.();
48+
}, 1000);
49+
}
50+
}, [isSuccess, isConfirming, hash, onSuccess]);
51+
52+
React.useEffect(() => {
53+
if (isError && error) {
54+
setShowProcessingModal(false);
55+
const message = error.message || 'Failed to join pool';
56+
setErrorMessage(message);
57+
}
58+
}, [isError, error]);
59+
60+
if (!address) {
61+
return null;
62+
}
63+
64+
return (
65+
<View>
66+
<RoundedButton
67+
title="Join Pool"
68+
backgroundColor={Colors.green[100]}
69+
color={Colors.green[200]}
70+
onPress={handleJoinClick}
71+
/>
72+
<BaseModal
73+
openModal={showJoinModal}
74+
onClose={() => setShowJoinModal(false)}
75+
onConfirm={handleConfirmJoin}
76+
title="JOIN POOL"
77+
paragraphs={[`To join ${poolName || 'this pool'}, please sign with your wallet.`]}
78+
image={PhoneImg}
79+
confirmButtonText="JOIN"
80+
/>
81+
<BaseModal
82+
openModal={showProcessingModal}
83+
onClose={() => {}}
84+
title="PROCESSING"
85+
paragraphs={['Please wait while we process your request...']}
86+
image={ApproveTokenImg}
87+
withClose={false}
88+
/>
89+
<BaseModal
90+
openModal={showSuccessModal}
91+
onClose={() => setShowSuccessModal(false)}
92+
onConfirm={() => setShowSuccessModal(false)}
93+
title="SUCCESS!"
94+
paragraphs={[`You have successfully joined ${poolName || 'the pool'}!`]}
95+
image={ThankYouImg}
96+
confirmButtonText="OK"
97+
/>
98+
<BaseModal
99+
type="error"
100+
openModal={!!errorMessage}
101+
onClose={() => setErrorMessage(undefined)}
102+
onConfirm={() => setErrorMessage(undefined)}
103+
errorMessage={errorMessage ?? ''}
104+
/>
105+
</View>
106+
);
107+
};

packages/app/src/components/ViewCollective.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ import { styles as walletCardStyles } from '../components/WalletCards/styles';
4343
import { formatFlowRate } from '../lib/formatFlowRate';
4444
import { StopDonationActionButton } from './StopDonationActionButton';
4545
import BannerPool from './BannerPool';
46+
import { JoinPoolButton } from './JoinPoolButton';
47+
import { ClaimRewardButton } from './ClaimRewardButton';
48+
import { usePoolMembership } from '../hooks/usePoolMembership';
49+
import { usePoolOpenStatus } from '../hooks/usePoolOpenStatus';
4650

4751
const HasDonatedCard = ({
4852
donorCollective,
@@ -210,6 +214,10 @@ function ViewCollective({ collective }: ViewCollectiveProps) {
210214
const { price: tokenPrice } = useGetTokenPrice('G$');
211215
const { stats } = useRealtimeStats(poolAddress);
212216

217+
// Check pool membership and open status
218+
const { isMember, refetch: refetchMembership } = usePoolMembership(poolAddress as `0x${string}` | undefined);
219+
const isPoolOpen = usePoolOpenStatus(poolAddress, pooltype);
220+
213221
const { wei: formattedTotalRewards, usdValue: totalRewardsUsdValue } = calculateGoodDollarAmounts(
214222
totalRewards,
215223
tokenPrice,
@@ -265,6 +273,34 @@ function ViewCollective({ collective }: ViewCollectiveProps) {
265273
</View>
266274
{maybeDonorCollective && maybeDonorCollective.flowRate !== '0' ? null : (
267275
<View style={styles.collectiveDonateBox}>
276+
{/* Join Pool Button - show if pool is open and user is not a member (only for UBI pools) */}
277+
{isPoolOpen && !isMember && address && pooltype === 'UBI' && (
278+
<JoinPoolButton
279+
poolAddress={poolAddress as `0x${string}`}
280+
poolType={pooltype}
281+
poolName={ipfs?.name}
282+
onSuccess={() => {
283+
// Refetch membership status
284+
window.location.reload();
285+
}}
286+
/>
287+
)}
288+
{/* Claim Reward Button - show if user is a member (only for UBI pools) */}
289+
{isMember && address && pooltype === 'UBI' && (
290+
<ClaimRewardButton
291+
poolAddress={poolAddress as `0x${string}`}
292+
poolType={pooltype}
293+
poolName={ipfs?.name}
294+
onSuccess={async () => {
295+
// Refetch membership and reward status
296+
await refetchMembership();
297+
// Small delay to allow contract state to update
298+
setTimeout(() => {
299+
refetchMembership();
300+
}, 2000);
301+
}}
302+
/>
303+
)}
268304
<RoundedButton
269305
title="Donate"
270306
backgroundColor={Colors.green[100]}
@@ -389,6 +425,39 @@ function ViewCollective({ collective }: ViewCollectiveProps) {
389425
<Text style={styles.informationLabel}>{infoLabel}</Text>
390426
</View>
391427

428+
{/* Join Pool Button - show if pool is open and user is not a member (only for UBI pools) */}
429+
{isPoolOpen && !isMember && address && pooltype === 'UBI' && (
430+
<View style={{ marginBottom: 16 }}>
431+
<JoinPoolButton
432+
poolAddress={poolAddress as `0x${string}`}
433+
poolType={pooltype}
434+
poolName={ipfs?.name}
435+
onSuccess={() => {
436+
// Refetch membership status
437+
window.location.reload();
438+
}}
439+
/>
440+
</View>
441+
)}
442+
{/* Claim Reward Button - show if user is a member (only for UBI pools) */}
443+
{isMember && address && pooltype === 'UBI' && (
444+
<View style={{ marginBottom: 16 }}>
445+
<ClaimRewardButton
446+
poolAddress={poolAddress as `0x${string}`}
447+
poolType={pooltype}
448+
poolName={ipfs?.name}
449+
onSuccess={async () => {
450+
// Refetch membership and reward status
451+
await refetchMembership();
452+
// Small delay to allow contract state to update
453+
setTimeout(() => {
454+
refetchMembership();
455+
}, 2000);
456+
}}
457+
/>
458+
</View>
459+
)}
460+
392461
<View style={styles.rowContainer}>
393462
<RowItem imageUrl={CalendarIcon} rowInfo="Creation Date" rowData={formatTime(timestamp)} />
394463
<RowItem imageUrl={StewardGreen} rowInfo="Recipients Paid" rowData={stewardsPaid ?? 0} />

0 commit comments

Comments
 (0)