Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
70b7f7a
updated create-pool and implementd bulk addMember
0xcoredev Jun 12, 2026
acc61f1
Updated create-pool copy, terms link, and recipient validation
0xcoredev Jun 12, 2026
03b3643
clarify members-only intent for closed pools
0xcoredev Jun 12, 2026
8b0a7e5
fix: improve UX and fix broken links in create pool flow
0xcoredev Jun 12, 2026
4db7f73
fix: align welcome screen on desktop and mobile
0xcoredev Jun 12, 2026
dbe15c1
feat: complete bulk member integration across manage and create flows
0xcoredev Jun 12, 2026
27595e4
Validate pool member eligibility and load members from chain
0xcoredev Jun 12, 2026
c83b00e
Stabilize manager actions during role checks
0xcoredev Jun 12, 2026
2c07fb6
Polish member management and pool action states
0xcoredev Jun 12, 2026
590623d
Improve member validation across create and manage flows
0xcoredev Jun 12, 2026
80d946f
Fix create-pool member blockers (review round 2)
0xcoredev Jun 12, 2026
5c4aa9e
Use 'reach out here' as link text on pool-type selection
0xcoredev Jun 12, 2026
04175af
Make membersValidator chain-aware instead of hardcoded AddressZero
0xcoredev Jun 12, 2026
c13d792
default REACT_APP_NETWORK to production-celo
0xcoredev Jun 12, 2026
e83c17f
Revert REACT_APP_NETWORK default back to development-celo
0xcoredev Jun 12, 2026
c638405
Simplify uniqueness validator helper to a per-chain singleton lookup
0xcoredev Jun 12, 2026
c6ed79d
Drop env override from getMembersValidatorAddress
0xcoredev Jun 12, 2026
77abec4
Point REACT_APP_SUBGRAPH at version/latest
0xcoredev Jun 12, 2026
814c55f
Source manage-page member list from the subgraph
0xcoredev Jun 12, 2026
a966f88
Let the bulk-add TextArea grow past 120px
0xcoredev Jun 12, 2026
fe4d602
Collapse usePoolManager state to a single tri-state
0xcoredev Jun 12, 2026
157fcba
Show the attempted member list on the partial-create modal
0xcoredev Jun 12, 2026
428746c
Source pool membership from sdk.getUBIPoolMembers
0xcoredev Jun 12, 2026
90da5ce
Fix the 3 tsc errors blocking CI
0xcoredev Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Box, FormControl, Input, Text, TextArea, VStack, WarningOutlineIcon, Pressable } from 'native-base';
import { Box, FormControl, Input, Link, Pressable, Text, TextArea, VStack, WarningOutlineIcon } from 'native-base';
import { useEffect, useState } from 'react';

import { useCreatePool } from '../../../hooks/useCreatePool/useCreatePool';
Expand Down Expand Up @@ -216,13 +216,13 @@ const GetStarted = ({}: {}) => {
<FormControl.HelperText>
<Text style={styles.helperText} color="goodGrey.400">
Provide image URL for your cover photo (1200x400px recommended). Upload to{' '}
<Text style={styles.linkText} onPress={() => window.open('https://ipfs.io/', '_blank')}>
<Link href="https://ipfs.io/" isExternal _text={styles.linkText}>
IPFS
</Text>{' '}
</Link>{' '}
(free tier) or{' '}
<Text style={styles.linkText} onPress={() => window.open('https://cloudinary.com/', '_blank')}>
<Link href="https://cloudinary.com/" isExternal _text={styles.linkText}>
Cloudinary
</Text>{' '}
</Link>{' '}
for hosting.
</Text>
</FormControl.HelperText>
Expand Down Expand Up @@ -428,5 +428,6 @@ const styles = {
color: 'goodPurple.400',
textDecorationLine: 'underline',
fontWeight: '600',
cursor: 'pointer',
},
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,25 @@ import { useCreatePool } from '../../../hooks/useCreatePool/useCreatePool';
import {
usePoolConfigurationValidation,
PoolConfigurationFormData,
validatePoolRecipients,
} from '../../../hooks/useCreatePool/usePoolConfigurationValidation';
import { useScreenSize } from '../../../theme/hooks';
import MembersSection from './pool-configs/MembersSection';
import PayoutSettingsSection from './pool-configs/PayoutSettingsSection';
import PoolManagerFeeSection from './pool-configs/PoolManagerFeeSection';
import NavigationButtons from '../NavigationButtons';
import ClaimFrequencySection from './pool-configs/ClaimFrequencySection';
import { useEthersProvider } from '../../../hooks/useEthers';
import { assessPoolMemberEligibility, formatSkippedMembersMessage } from '../../../lib/poolMemberEligibility';
import { getMembersValidatorAddress, getUniquenessValidatorAddress } from '../../../models/constants';

const PoolConfiguration = () => {
const { form, nextStep, submitPartial, previousStep } = useCreatePool();
const { isDesktopView } = useScreenSize();
const { address } = useAccount();
const { address, chain } = useAccount();
const { validate, errors } = usePoolConfigurationValidation();
const chainId = chain?.id ?? 42220;
const provider = useEthersProvider({ chainId });

const [poolManagerFeeType, setPoolManagerFeeType] = useState<'default' | 'custom'>(
form.poolManagerFeeType ?? 'default'
Expand All @@ -31,6 +37,8 @@ const PoolConfiguration = () => {
const [claimAmountPerWeek, setClaimAmountPerWeek] = useState(form.claimAmountPerWeek ?? 10);
const [expectedMembers, setExpectedMembers] = useState(form.expectedMembers ?? 1);
const [customClaimFrequency, setCustomClaimFrequency] = useState(form.customClaimFrequency ?? 1);
const [recipientEligibilityError, setRecipientEligibilityError] = useState<string | undefined>();
const [isCheckingRecipients, setIsCheckingRecipients] = useState(false);
const { data: ensName } = useEnsName({ address: managerAddress as `0x${string}`, chainId: 1 });

useEffect(() => {
Expand All @@ -39,6 +47,10 @@ const PoolConfiguration = () => {
}
}, [maximumMembers, expectedMembers]);

useEffect(() => {
setRecipientEligibilityError(undefined);
}, [poolRecipients, maximumMembers]);

const handleValidate = () => {
const formData: PoolConfigurationFormData = {
poolRecipients,
Expand All @@ -54,8 +66,49 @@ const PoolConfiguration = () => {
return validate(formData);
};

const submitForm = () => {
if (handleValidate()) {
const validateRecipientEligibility = async (): Promise<boolean> => {
const recipientsValidation = validatePoolRecipients(poolRecipients, maximumMembers);
if (!recipientsValidation.isValid || recipientsValidation.memberAddresses.length === 0) {
setRecipientEligibilityError(undefined);
return recipientsValidation.isValid;
}

if (!provider || !managerAddress) {
return true;
}

try {
setIsCheckingRecipients(true);
const { skippedAddresses, validAddresses } = await assessPoolMemberEligibility({
provider,
addresses: recipientsValidation.memberAddresses,
uniquenessValidator: getUniquenessValidatorAddress(chainId),
membersValidator: getMembersValidatorAddress(chainId),
operatorAddress: managerAddress.toLowerCase(),
});

if (validAddresses.length !== recipientsValidation.memberAddresses.length) {
const skippedSummary = formatSkippedMembersMessage(skippedAddresses);
setRecipientEligibilityError(
skippedSummary
? `Some initial members are not eligible: ${skippedSummary}.`
: 'Some initial members cannot be added to this pool.'
);
return false;
}

setRecipientEligibilityError(undefined);
return true;
} finally {
setIsCheckingRecipients(false);
}
};

const submitForm = async () => {
const isValid = handleValidate();
const hasEligibleRecipients = await validateRecipientEligibility();

if (isValid && hasEligibleRecipients) {
submitPartial({
poolManagerFeeType,
claimFrequency: claimFrequency === 2 ? customClaimFrequency : claimFrequency,
Expand Down Expand Up @@ -113,9 +166,14 @@ const PoolConfiguration = () => {
setMaximumMembers={setMaximumMembers}
joinStatus={joinStatus}
setJoinStatus={setJoinStatus}
poolRecipients={poolRecipients}
setPoolRecipients={setPoolRecipients}
onValidate={handleValidate}
onValidateRecipients={validateRecipientEligibility}
isCheckingRecipients={isCheckingRecipients}
errors={{
maximumMembers: errors.maximumMembers,
poolRecipients: errors.poolRecipients ?? recipientEligibilityError,
}}
/>

Expand Down
165 changes: 150 additions & 15 deletions packages/app/src/components/CommunityPool/CreatePool/ReviewLaunch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import { ReactNode, useCallback, useMemo, useState } from 'react';
import { AtIcon, DiscordIcon, EditIcon, InstagramIcon, PhoneImg, TwitterIcon, WebsiteIcon } from '../../../assets';
import { useCreatePool } from '../../../hooks/useCreatePool/useCreatePool';
import { printAndParseSupportError } from '../../../hooks/useContractCalls/util';
import { isPoolMembersAddError } from '../../../hooks/useCreatePool/PoolMembersAddError';
import useCrossNavigate from '../../../routes/useCrossNavigate';
import BaseModal from '../../modals/BaseModal';
import NavigationButtons from '../NavigationButtons';
import { Linking } from 'react-native';
import { formatSocialUrls } from '../../../lib/formatSocialUrls';

const SectionHeader = ({ title, onEdit }: { title: string; onEdit: () => void }) => (
<HStack alignItems="center">
Expand Down Expand Up @@ -56,9 +60,17 @@ const ReviewLaunch = () => {
const { form, startOver, previousStep, goToBasics, goToProjectDetails, goToPoolConfiguration, createPool } =
useCreatePool();
const { isDesktopView } = useScreenSize();
const { navigate } = useCrossNavigate();

const [approvePoolModalVisible, setApprovePoolModalVisible] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
// Holds the deployed pool address when the pool was created on-chain but the
// follow-up addPoolMembers transaction failed. Used to render a recovery
// modal that links to the manage page so the user can retry.
const [partialCreatePoolAddress, setPartialCreatePoolAddress] = useState<string | undefined>(undefined);
const [partialCreateReason, setPartialCreateReason] = useState<string | undefined>(undefined);
const [partialCreateMembers, setPartialCreateMembers] = useState<string[]>([]);
const [partialCopySuccess, setPartialCopySuccess] = useState(false);
const [isCreating, setIsCreating] = useState(false);

const socials = [
Expand Down Expand Up @@ -88,6 +100,10 @@ const ReviewLaunch = () => {
setIsCreating(true);
setApprovePoolModalVisible(true);
setErrorMessage(undefined);
setPartialCreatePoolAddress(undefined);
setPartialCreateReason(undefined);
setPartialCreateMembers([]);
setPartialCopySuccess(false);

try {
const pool = await createPool();
Expand All @@ -102,15 +118,62 @@ const ReviewLaunch = () => {
console.error('Pool creation error:', error);
setApprovePoolModalVisible(false);

const message = printAndParseSupportError(error);
setErrorMessage(message);
// Pool deployed on-chain but addPoolMembers tx failed - show a recovery
// modal pointing the user at the manage page rather than a generic error
// that would leave them with a stranded deployed pool.
if (isPoolMembersAddError(error)) {
setPartialCreatePoolAddress(error.poolAddress);
setPartialCreateReason(printAndParseSupportError(error.cause));
setPartialCreateMembers(error.memberAddresses);
} else {
const message = printAndParseSupportError(error);
setErrorMessage(message);
}
} finally {
setIsCreating(false);
}
}, [createPool]);

const onCloseErrorModal = () => setErrorMessage(undefined);

const onClosePartialCreateModal = () => {
setPartialCreatePoolAddress(undefined);
setPartialCreateReason(undefined);
setPartialCreateMembers([]);
setPartialCopySuccess(false);
};

const onGoToManagePool = () => {
if (!partialCreatePoolAddress) return;
const address = partialCreatePoolAddress;
onClosePartialCreateModal();
navigate(`/collective/${address}/manage`);
};

const onCopyPartialMembers = useCallback(async () => {
if (partialCreateMembers.length === 0) return;
const text = partialCreateMembers.join('\n');
try {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
// Fallback for environments without clipboard API (older webviews).
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
setPartialCopySuccess(true);
setTimeout(() => setPartialCopySuccess(false), 2000);
} catch (err) {
console.error('Failed to copy member addresses:', err);
}
}, [partialCreateMembers]);

// ===== Helpers to reduce repetition =====
const formatPoolType = useCallback((poolType?: string) => {
if (!poolType) return 'Community Funds';
Expand Down Expand Up @@ -198,16 +261,27 @@ const ReviewLaunch = () => {
<Label>Socials</Label>
<HStack space={2}>
{socials.map((social, index) => (
<Box
<Pressable
key={index}
backgroundColor="gray.100"
width={10}
height={10}
justifyContent="center"
alignItems="center"
borderRadius={4}>
<img width={24} src={social.icon} />
</Box>
onPress={() => {
const url = form[social.name as keyof typeof form];
if (typeof url === 'string') {
const formattedUrl = formatSocialUrls[social.name as keyof typeof formatSocialUrls](url);
if (formattedUrl) {
Linking.openURL(formattedUrl);
}
}
}}>
<Box
backgroundColor="gray.100"
width={10}
height={10}
justifyContent="center"
alignItems="center"
borderRadius={4}>
<img width={24} src={social.icon} />
</Box>
</Pressable>
))}
</HStack>
</VStack>
Expand Down Expand Up @@ -246,6 +320,12 @@ const ReviewLaunch = () => {
<StatRow label="Min Claim Amount" value={`${form.claimAmountPerWeek}G$`} />
<StatRow label="Expected Members" value={form.expectedMembers} />
<StatRow label="Amount To Fund" value={`${amountToFund}G$`} />
{form.poolRecipients && form.poolRecipients.trim() !== '' && (
<StatRow
label="Initial Members"
value={form.poolRecipients.split(/[\n,]/).filter((s) => s.trim() !== '').length}
/>
)}
</VStack>
</VStack>
</VStack>
Expand All @@ -255,6 +335,7 @@ const ReviewLaunch = () => {
onBack={() => previousStep()}
onNext={handleCreatePool}
nextText={isCreating ? 'Creating...' : 'Launch Pool'}
nextDisabled={isCreating}
marginTop={6}
containerStyle={undefined}
buttonWidth="140px"
Expand All @@ -274,15 +355,69 @@ const ReviewLaunch = () => {
onConfirm={onCloseErrorModal}
/>

{/* Partial-create recovery modal: shown when the pool was deployed but
the second tx (adding initial members) failed. Surfaces the attempted
member list with a copy-to-clipboard so the user can paste it on the
manage page instead of typing it again. */}
<BaseModal
openModal={!!partialCreatePoolAddress}
onClose={onClosePartialCreateModal}
onConfirm={onGoToManagePool}
title="POOL CREATED, MEMBERS NOT ADDED"
confirmButtonText="Go to Manage Pool"
paragraphs={[
'Your pool was deployed on-chain, but the second transaction adding the initial members did not complete.',
partialCreateReason ? `Reason: ${partialCreateReason}` : undefined,
partialCreatePoolAddress ? `Pool address: ${partialCreatePoolAddress}` : undefined,
partialCreateMembers.length > 0 ? (
<VStack key="member-list" space={2} width="100%" maxWidth="360px">
<HStack alignItems="center" justifyContent="space-between">
<Text fontSize="sm" fontWeight="600">
Members to add ({partialCreateMembers.length})
</Text>
<Pressable onPress={onCopyPartialMembers}>
<Text fontSize="sm" color="blue.500" fontWeight="600">
{partialCopySuccess ? 'Copied!' : 'Copy'}
</Text>
</Pressable>
</HStack>
<Box
borderWidth={1}
borderColor="gray.200"
borderRadius={8}
padding={2}
maxHeight={120}
overflow="scroll"
backgroundColor="gray.50">
{partialCreateMembers.map((address) => (
<Text key={address} fontSize="xs" fontFamily="mono" textAlign="left">
{address}
</Text>
))}
</Box>
</VStack>
) : undefined,
'You can finish adding members from the pool management page.',
]}
image={PhoneImg}
/>

{/* Approval Modal */}
<BaseModal
openModal={approvePoolModalVisible}
onClose={() => setApprovePoolModalVisible(false)}
title="APPROVE POOL CREATION"
paragraphs={[
'To create your GoodCollective pool, sign with your wallet.',
'This will deploy your pool contract and make it available for members to join.',
]}
paragraphs={
form.poolRecipients && form.poolRecipients.trim() !== ''
? [
'To create your GoodCollective pool, sign with your wallet.',
'Note: You will be asked to sign TWO transactions. The first creates the pool, and the second adds your initial members.',
]
: [
'To create your GoodCollective pool, sign with your wallet.',
'This will deploy your pool contract and make it available for members to join.',
]
}
image={PhoneImg}
/>
</VStack>
Expand Down
Loading
Loading