diff --git a/packages/app/src/components/CommunityPool/CreatePool/GetStarted.tsx b/packages/app/src/components/CommunityPool/CreatePool/GetStarted.tsx index 1ea39c06..3287488c 100644 --- a/packages/app/src/components/CommunityPool/CreatePool/GetStarted.tsx +++ b/packages/app/src/components/CommunityPool/CreatePool/GetStarted.tsx @@ -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'; @@ -216,13 +216,13 @@ const GetStarted = ({}: {}) => { Provide image URL for your cover photo (1200x400px recommended). Upload to{' '} - window.open('https://ipfs.io/', '_blank')}> + IPFS - {' '} + {' '} (free tier) or{' '} - window.open('https://cloudinary.com/', '_blank')}> + Cloudinary - {' '} + {' '} for hosting. @@ -428,5 +428,6 @@ const styles = { color: 'goodPurple.400', textDecorationLine: 'underline', fontWeight: '600', + cursor: 'pointer', }, } as const; diff --git a/packages/app/src/components/CommunityPool/CreatePool/PoolConfiguration.tsx b/packages/app/src/components/CommunityPool/CreatePool/PoolConfiguration.tsx index d7f43bad..af10c80c 100644 --- a/packages/app/src/components/CommunityPool/CreatePool/PoolConfiguration.tsx +++ b/packages/app/src/components/CommunityPool/CreatePool/PoolConfiguration.tsx @@ -5,6 +5,7 @@ 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'; @@ -12,12 +13,17 @@ 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' @@ -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(); + const [isCheckingRecipients, setIsCheckingRecipients] = useState(false); const { data: ensName } = useEnsName({ address: managerAddress as `0x${string}`, chainId: 1 }); useEffect(() => { @@ -39,6 +47,10 @@ const PoolConfiguration = () => { } }, [maximumMembers, expectedMembers]); + useEffect(() => { + setRecipientEligibilityError(undefined); + }, [poolRecipients, maximumMembers]); + const handleValidate = () => { const formData: PoolConfigurationFormData = { poolRecipients, @@ -54,8 +66,49 @@ const PoolConfiguration = () => { return validate(formData); }; - const submitForm = () => { - if (handleValidate()) { + const validateRecipientEligibility = async (): Promise => { + 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, @@ -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, }} /> diff --git a/packages/app/src/components/CommunityPool/CreatePool/ReviewLaunch.tsx b/packages/app/src/components/CommunityPool/CreatePool/ReviewLaunch.tsx index 354155d4..896ae317 100644 --- a/packages/app/src/components/CommunityPool/CreatePool/ReviewLaunch.tsx +++ b/packages/app/src/components/CommunityPool/CreatePool/ReviewLaunch.tsx @@ -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 }) => ( @@ -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(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(undefined); + const [partialCreateReason, setPartialCreateReason] = useState(undefined); + const [partialCreateMembers, setPartialCreateMembers] = useState([]); + const [partialCopySuccess, setPartialCopySuccess] = useState(false); const [isCreating, setIsCreating] = useState(false); const socials = [ @@ -88,6 +100,10 @@ const ReviewLaunch = () => { setIsCreating(true); setApprovePoolModalVisible(true); setErrorMessage(undefined); + setPartialCreatePoolAddress(undefined); + setPartialCreateReason(undefined); + setPartialCreateMembers([]); + setPartialCopySuccess(false); try { const pool = await createPool(); @@ -102,8 +118,17 @@ 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); } @@ -111,6 +136,44 @@ const ReviewLaunch = () => { 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'; @@ -198,16 +261,27 @@ const ReviewLaunch = () => { {socials.map((social, index) => ( - - - + 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); + } + } + }}> + + + + ))} @@ -246,6 +320,12 @@ const ReviewLaunch = () => { + {form.poolRecipients && form.poolRecipients.trim() !== '' && ( + s.trim() !== '').length} + /> + )} @@ -255,6 +335,7 @@ const ReviewLaunch = () => { onBack={() => previousStep()} onNext={handleCreatePool} nextText={isCreating ? 'Creating...' : 'Launch Pool'} + nextDisabled={isCreating} marginTop={6} containerStyle={undefined} buttonWidth="140px" @@ -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. */} + 0 ? ( + + + + Members to add ({partialCreateMembers.length}) + + + + {partialCopySuccess ? 'Copied!' : 'Copy'} + + + + + {partialCreateMembers.map((address) => ( + + {address} + + ))} + + + ) : undefined, + 'You can finish adding members from the pool management page.', + ]} + image={PhoneImg} + /> + {/* Approval Modal */} 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} /> diff --git a/packages/app/src/components/CommunityPool/CreatePool/pool-configs/MembersSection.tsx b/packages/app/src/components/CommunityPool/CreatePool/pool-configs/MembersSection.tsx index 38474e3b..e65cd712 100644 --- a/packages/app/src/components/CommunityPool/CreatePool/pool-configs/MembersSection.tsx +++ b/packages/app/src/components/CommunityPool/CreatePool/pool-configs/MembersSection.tsx @@ -1,13 +1,18 @@ -import { Box, FormControl, Input, Radio, Text, VStack, WarningOutlineIcon } from 'native-base'; +import { Box, FormControl, Input, Radio, Text, TextArea, VStack, WarningOutlineIcon } from 'native-base'; interface MembersSectionProps { maximumMembers: number; setMaximumMembers: (value: number) => void; joinStatus: 'closed' | 'open'; setJoinStatus: (value: 'closed' | 'open') => void; + poolRecipients: string; + setPoolRecipients: (value: string) => void; onValidate: () => void; + onValidateRecipients: () => Promise; + isCheckingRecipients: boolean; errors: { maximumMembers?: string; + poolRecipients?: string; }; } @@ -16,7 +21,11 @@ const MembersSection = ({ setMaximumMembers, joinStatus, setJoinStatus, + poolRecipients, + setPoolRecipients, onValidate, + onValidateRecipients, + isCheckingRecipients, errors, }: MembersSectionProps) => { return ( @@ -85,6 +94,42 @@ const MembersSection = ({ + + {/* Initial Members */} + + + + Initial Members (optional) + + + + Add wallet addresses separated by commas or new lines. + + +