Skip to content

Commit db37d3a

Browse files
EmekaManuelEmeka ManuelL03TJ3
authored
feat: add pool membership and reward claiming functionality (#309)
* 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. * refactor: update onSuccess handlers in ViewCollective to use refetchMembership instead of window.location.reload for improved user experience * refactor: streamline ClaimRewardButton state management by replacing individual modal states with a unified status state for improved clarity and maintainability * refactor: enhance usePoolRewards hook by consolidating UBI pool condition checks for improved clarity and maintainability * refactor: simplify ClaimTimer component by removing unused props and updating ClaimRewardButton to reflect changes for improved clarity * refactor: remove usePoolRewards hook and integrate the goodcollective sdk * 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 * refactor: remove usePoolMembership and usePoolOpenStatus hooks to streamline codebase and improve maintainability * Apply suggestions from code review * 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 * Apply suggestions from code review --------- Co-authored-by: Emeka Manuel <[email protected]> Co-authored-by: Lewis B <[email protected]>
1 parent ac662a9 commit db37d3a

File tree

11 files changed

+728
-32
lines changed

11 files changed

+728
-32
lines changed

packages/app/src/components/ActionButton.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type ActionButtonProps = {
1010
onPress?: any;
1111
width?: string;
1212
borderRadius?: number;
13+
isDisabled?: boolean;
1314
};
1415

1516
export const buttonStyles = {
@@ -40,7 +41,16 @@ export const buttonStyles = {
4041
},
4142
};
4243

43-
const ActionButton = ({ href, text, bg, textColor, onPress, width = '100%', borderRadius = 50 }: ActionButtonProps) => {
44+
const ActionButton = ({
45+
href,
46+
text,
47+
bg,
48+
textColor,
49+
onPress,
50+
width = '100%',
51+
borderRadius = 50,
52+
isDisabled = false,
53+
}: ActionButtonProps) => {
4454
const responsiveStyles = useBreakpointValue({
4555
base: {
4656
button: {
@@ -71,7 +81,12 @@ const ActionButton = ({ href, text, bg, textColor, onPress, width = '100%', bord
7181
const { buttonContainer, button, buttonText } = responsiveStyles ?? {};
7282

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

0 commit comments

Comments
 (0)