diff --git a/assets/icons/cross.svg b/assets/icons/cross.svg new file mode 100644 index 0000000000..90221c8183 --- /dev/null +++ b/assets/icons/cross.svg @@ -0,0 +1,3 @@ + + + diff --git a/package.json b/package.json index a59540cd71..fc106605a4 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "graphql-request": "^5", "html-to-draftjs": "^1.5.0", "immutable": "^4.0.0", + "keccak256": "^1.0.6", "kubernetes-models": "^4.3.1", "leaflet": "^1.9.4", "leaflet.markercluster": "^1.5.3", diff --git a/packages/components/FilePreview/SelectedFilesPreview/ItemView.tsx b/packages/components/FilePreview/SelectedFilesPreview/ItemView.tsx new file mode 100644 index 0000000000..28f7d08ff7 --- /dev/null +++ b/packages/components/FilePreview/SelectedFilesPreview/ItemView.tsx @@ -0,0 +1,78 @@ +import { FC } from "react"; +import { TouchableOpacity, Image, StyleProp, ViewStyle } from "react-native"; + +import { BrandText } from "../../BrandText"; +import { PrimaryBox } from "../../boxes/PrimaryBox"; + +import { neutral77, secondaryColor } from "@/utils/style/colors"; +import { fontSemibold11, fontSemibold13 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; + +export const itemSize = 120; +export const ItemView: FC<{ + label: string; + subLabel?: string; + uri: string; + onPress: () => void; + style?: StyleProp; +}> = ({ label, subLabel, uri, onPress, style }) => { + return ( + + + + + + + + {label} + + {subLabel && ( + + {subLabel} + + )} + + + ); +}; diff --git a/packages/components/FilePreview/SelectedFilesPreview/SelectedFilesPreview.tsx b/packages/components/FilePreview/SelectedFilesPreview/SelectedFilesPreview.tsx new file mode 100644 index 0000000000..d1e5cf7956 --- /dev/null +++ b/packages/components/FilePreview/SelectedFilesPreview/SelectedFilesPreview.tsx @@ -0,0 +1,74 @@ +import { FC } from "react"; +import { View } from "react-native"; + +import { itemSize, ItemView } from "./ItemView"; +import { BrandText } from "../../BrandText"; +import { PrimaryBox } from "../../boxes/PrimaryBox"; + +import { DeleteButton } from "@/components/FilePreview/DeleteButton"; +import { GridList } from "@/components/layout/GridList"; +import { neutral33, neutral55 } from "@/utils/style/colors"; +import { fontSemibold20 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; +import { LocalFileData } from "@/utils/types/files"; + +export const SelectedFilesPreview: FC<{ + files: LocalFileData[]; + onPressItem: (file: LocalFileData, itemIndex: number) => void; + onPressDeleteItem: (itemIndex: number) => void; +}> = ({ files, onPressItem, onPressDeleteItem }) => { + return ( + + {files.length ? ( + + data={files} + keyExtractor={(item) => item.url} + renderItem={({ item, index }, elemWidth) => ( + + onPressDeleteItem(index)} + style={{ top: 0, right: 0 }} + /> + { + onPressItem(item, index); + }} + style={{ width: elemWidth }} + /> + + )} + minElemWidth={itemSize} + gap={layout.spacing_x2_5} + noFixedHeight + /> + ) : ( + + + Selected files preview + + + )} + + ); +}; diff --git a/packages/components/NetworkSelector/NetworkSelectorWithLabel.tsx b/packages/components/NetworkSelector/NetworkSelectorWithLabel.tsx new file mode 100644 index 0000000000..804cbfb1ab --- /dev/null +++ b/packages/components/NetworkSelector/NetworkSelectorWithLabel.tsx @@ -0,0 +1,127 @@ +import { FC, useState } from "react"; +import { StyleProp, View, ViewStyle } from "react-native"; + +import { NetworkSelectorMenu } from "./NetworkSelectorMenu"; +import { BrandText } from "../BrandText"; +import { SVG } from "../SVG"; +import { TertiaryBox } from "../boxes/TertiaryBox"; +import { Label } from "../inputs/TextInputCustom"; + +import chevronDownSVG from "@/assets/icons/chevron-down.svg"; +import chevronUpSVG from "@/assets/icons/chevron-up.svg"; +import { NetworkIcon } from "@/components/NetworkIcon"; +import { CustomPressable } from "@/components/buttons/CustomPressable"; +import { SpacerRow } from "@/components/spacer"; +import { useDropdowns } from "@/hooks/useDropdowns"; +import { useSelectedNetworkInfo } from "@/hooks/useSelectedNetwork"; +import { NetworkFeature, NetworkKind } from "@/networks"; +import { neutral17, neutral77, secondaryColor } from "@/utils/style/colors"; +import { fontSemibold14 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; + +export const NetworkSelectorWithLabel: FC<{ + label: string; + style?: StyleProp; + forceNetworkId?: string; + forceNetworkKind?: NetworkKind; + forceNetworkFeatures?: NetworkFeature[]; +}> = ({ + style, + forceNetworkId, + forceNetworkKind, + forceNetworkFeatures, + label, +}) => { + const [isDropdownOpen, setDropdownState, ref] = useDropdowns(); + const [hovered, setHovered] = useState(false); + const selectedNetworkInfo = useSelectedNetworkInfo(); + + return ( + + setHovered(true)} + onHoverOut={() => setHovered(false)} + onPress={() => setDropdownState(!isDropdownOpen)} + > + + + + + + + + + + {selectedNetworkInfo?.displayName} + + + + + + + + {isDropdownOpen && ( + {}} + optionsMenuwidth={416} + style={{ width: "100%", marginTop: layout.spacing_x0_75 }} + forceNetworkId={forceNetworkId} + forceNetworkKind={forceNetworkKind} + forceNetworkFeatures={forceNetworkFeatures} + /> + )} + + + ); +}; diff --git a/packages/components/navigation/getNormalModeScreens.tsx b/packages/components/navigation/getNormalModeScreens.tsx index c7145bb147..fe998e9796 100644 --- a/packages/components/navigation/getNormalModeScreens.tsx +++ b/packages/components/navigation/getNormalModeScreens.tsx @@ -17,8 +17,9 @@ import { GuardiansScreen } from "@/screens/Guardians/GuardiansScreen"; import { HashtagFeedScreen } from "@/screens/HashtagFeed/HashtagFeedScreen"; import { HomeScreen } from "@/screens/Home/HomeScreen"; import { LaunchpadApplyScreen } from "@/screens/Launchpad/LaunchpadApply/LaunchpadApplyScreen"; +import { LaunchpadCreateScreen } from "@/screens/Launchpad/LaunchpadApply/LaunchpadCreate/LaunchpadCreateScreen"; import { LaunchpadScreen } from "@/screens/Launchpad/LaunchpadHome/LaunchpadScreen"; -import { MintCollectionScreen } from "@/screens/Launchpad/MintCollectionScreen"; +import { MintCollectionScreen } from "@/screens/Launchpad/LaunchpadHome/MintCollectionScreen"; import { LaunchpadERC20CreateSaleScreen } from "@/screens/LaunchpadERC20/LaunchpadERC20Sales/LaunchpadERC20CreateSaleScreen"; import { LaunchpadERC20SalesScreen } from "@/screens/LaunchpadERC20/LaunchpadERC20Sales/LaunchpadERC20SalesScreen"; import { LaunchpadERC20Screen } from "@/screens/LaunchpadERC20/LaunchpadERC20Screen"; @@ -229,6 +230,14 @@ export const getNormalModeScreens = ({ title: screenTitle("Launchpad (Apply)"), }} /> + null, + title: screenTitle("Create Collection"), + }} + /> { + // Since the Collection network is the selected network, we use useSelectedNetworkId (See LaunchpadBasic.tsx) + const selectedNetworkId = useSelectedNetworkId(); + const selectedWallet = useSelectedWallet(); + const { setToast } = useFeedbacks(); + const userIPFSKey = useSelector(selectNFTStorageAPI); + const { pinataPinFileToIPFS, uploadFilesToPinata } = useIpfs(); + + // TODO: Uncomment when the NFT Launchpad MyCollections frontend and useCompleteCollection are merged + // const { completeCollection } = useCompleteCollection(); + + const createCollection = useCallback( + async (collectionFormValues: CollectionFormValues) => { + if (!selectedWallet) return false; + const userId = selectedWallet.userId; + const walletAddress = selectedWallet.address; + + const signingComswasmClient = + await getKeplrSigningCosmWasmClient(selectedNetworkId); + const cosmwasmNftLaunchpadFeature = getNetworkFeature( + selectedNetworkId, + NetworkFeature.CosmWasmNFTLaunchpad, + ); + if (!cosmwasmNftLaunchpadFeature) return false; + + const nftLaunchpadContractClient = new NftLaunchpadClient( + signingComswasmClient, + walletAddress, + cosmwasmNftLaunchpadFeature.launchpadContractAddress, + ); + const pinataJWTKey = + collectionFormValues.assetsMetadatas?.nftApiKey || + userIPFSKey || + (await generateIpfsKey(selectedNetworkId, userId)); + if (!pinataJWTKey) { + console.error("Project creation error: No Pinata JWT"); + setToast({ + mode: "normal", + type: "error", + title: "Project creation error: No Pinata JWT", + }); + return false; + } + + try { + // ========== Cover image + const fileIpfsHash = await pinataPinFileToIPFS({ + pinataJWTKey, + file: collectionFormValues.coverImage, + } as PinataFileProps); + if (!fileIpfsHash) { + console.error("Project creation error: Pin to Pinata failed"); + setToast({ + mode: "normal", + type: "error", + title: "Project creation error: Pin to Pinata failed", + }); + return false; + } + + // ========== Whitelists + const whitelistAddressesFilesToUpload: LocalFileData[] = []; + collectionFormValues.mintPeriods.forEach((mintPeriod) => { + if (mintPeriod.whitelistAddressesFile) + whitelistAddressesFilesToUpload.push( + mintPeriod.whitelistAddressesFile, + ); + }); + const remoteWhitelistAddressesFiles = await uploadFilesToPinata({ + pinataJWTKey, + files: whitelistAddressesFilesToUpload, + }); + const mint_periods: MintPeriod[] = collectionFormValues.mintPeriods.map( + (mintPeriod: CollectionMintPeriodFormValues, index) => { + let whitelist_info: WhitelistInfo | null = null; + if ( + mintPeriod.whitelistAddresses?.length && + remoteWhitelistAddressesFiles[index].url + ) { + const addresses: string[] = mintPeriod.whitelistAddresses; + const leaves = addresses.map(keccak256); + const tree = new MerkleTree(leaves, keccak256); + const merkleRoot = tree.getRoot().toString("hex"); + whitelist_info = { + addresses_count: addresses.length, + addresses_ipfs: remoteWhitelistAddressesFiles[index].url, + addresses_merkle_root: merkleRoot, + }; + } + const price: Coin | null = mintPeriod.price + ? { + amount: mintPeriod.price.amount || "0", + denom: mintPeriod.price.denom, + } + : null; + return { + price, + end_time: mintPeriod.endTime, + max_tokens: mintPeriod.maxTokens + ? parseInt(mintPeriod.maxTokens, 10) + : 0, + limit_per_address: mintPeriod.perAddressLimit + ? parseInt(mintPeriod.perAddressLimit, 10) + : 0, + start_time: mintPeriod.startTime, + whitelist_info, + }; + }, + ); + + const assetsMetadataFormsValues: + | CollectionAssetsMetadatasFormValues + | undefined + | null = collectionFormValues.assetsMetadatas; + + // ========== Final collection + const collection: CollectionToSubmit = { + name: collectionFormValues.name, + desc: collectionFormValues.description, + symbol: collectionFormValues.symbol, + website_link: collectionFormValues.websiteLink, + contact_email: collectionFormValues.email, + project_type: collectionFormValues.projectTypes.join(), + tokens_count: assetsMetadataFormsValues?.assetsMetadatas?.length || 0, + reveal_time: collectionFormValues.revealTime, + team_desc: collectionFormValues.teamDescription, + partners: collectionFormValues.partnersDescription, + investment_desc: collectionFormValues.investDescription, + investment_link: collectionFormValues.investLink, + artwork_desc: collectionFormValues.artworkDescription, + cover_img_uri: "ipfs://" + fileIpfsHash, + is_applied_previously: collectionFormValues.isPreviouslyApplied, + is_project_derivative: collectionFormValues.isDerivativeProject, + is_ready_for_mint: collectionFormValues.isReadyForMint, + is_dox: collectionFormValues.isDox, + escrow_mint_proceeds_period: parseInt( + collectionFormValues.escrowMintProceedsPeriod, + 10, + ), + dao_whitelist_count: parseInt( + collectionFormValues.daoWhitelistCount, + 10, + ), + mint_periods, + royalty_address: collectionFormValues.royaltyAddress, + royalty_percentage: collectionFormValues.royaltyPercentage + ? parseInt(collectionFormValues.royaltyPercentage, 10) + : null, + target_network: selectedNetworkId, + }; + // TODO: Uncomment when the NFT Launchpad MyCollections frontend and useCompleteCollection are merged + // const collectionId = collectionFormValues.symbol; + + // ========== Submit the collection through the contract + await nftLaunchpadContractClient.submitCollection({ + collection, + }); + + // ========== Handle assets metadata + if (!assetsMetadataFormsValues?.assetsMetadatas?.length) { + setToast({ + mode: "normal", + type: "success", + title: "Project submitted (Incomplete)", + message: "You will need to Complete the Project", + }); + } else { + const isCompleteSuccess = false; + // TODO: Uncomment when the NFT Launchpad MyCollections frontend and useCompleteCollection are merged + // await completeCollection( + // collectionId, + // assetsMetadataFormsValues, + // ); + + if (!isCompleteSuccess) { + setToast({ + mode: "normal", + type: "warning", + title: "Project submitted (Incomplete)", + message: + "Error during uploading the Assets.\nYou will need to Complete the Project", + }); + } else { + setToast({ + mode: "normal", + type: "success", + title: "Project submitted", + }); + } + } + + return true; + } catch (e: any) { + console.error("Error creating a NFT Collection in the Launchpad: ", e); + setToast({ + mode: "normal", + type: "error", + title: "Error creating a NFT Collection in the Launchpad", + message: e.message, + }); + } + }, + [ + pinataPinFileToIPFS, + selectedWallet, + userIPFSKey, + uploadFilesToPinata, + selectedNetworkId, + setToast, + // TODO: Uncomment when the NFT Launchpad MyCollections frontend and useCompleteCollection are merged + // completeCollection, + ], + ); + + return { + createCollection, + }; +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadApplyScreen.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadApplyScreen.tsx index 6fd2715f5e..8850c64571 100644 --- a/packages/screens/Launchpad/LaunchpadApply/LaunchpadApplyScreen.tsx +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadApplyScreen.tsx @@ -1,18 +1,23 @@ -import React from "react"; -import { StyleSheet, View } from "react-native"; +import { Linking, TextStyle, View } from "react-native"; import LaunchpadBannerImage from "@/assets/banners/launchpad.jpg"; import { BrandText } from "@/components/BrandText"; import { ImageBackgroundLogoText } from "@/components/ImageBackgroundLogoText"; +import { OmniLink } from "@/components/OmniLink"; import { ScreenContainer } from "@/components/ScreenContainer"; +import { CustomPressable } from "@/components/buttons/CustomPressable"; import { LargeBoxButton, LargeBoxButtonProps, } from "@/components/buttons/LargeBoxButton"; -import { SpacerColumn, SpacerRow } from "@/components/spacer"; +import { SpacerColumn } from "@/components/spacer"; +import { useMaxResolution } from "@/hooks/useMaxResolution"; import { ScreenFC } from "@/utils/navigation"; import { neutral77 } from "@/utils/style/colors"; import { fontSemibold14, fontSemibold28 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; + +const MD_BREAKPOINT = 720; const BUTTONS: LargeBoxButtonProps[] = [ { @@ -24,7 +29,7 @@ const BUTTONS: LargeBoxButtonProps[] = [ title: "Create", description: "Upload your assets, enter collection metadata and deploy your collection.", - buttonTitle: "Coming soon", + buttonTitle: "Open", }, { title: "My Collections", @@ -34,6 +39,7 @@ const BUTTONS: LargeBoxButtonProps[] = [ ]; export const LaunchpadApplyScreen: ScreenFC<"LaunchpadApply"> = () => { + const { width } = useMaxResolution(); return ( = () => { Welcome - + Looking for a fast and efficient way to build an NFT collection? - + Teritori is the solution. Teritori is built to provide useful smart contract interfaces that helps you build and deploy your own NFT collections in no time. - - - - - + + + + Linking.openURL("https://airtable.com/shr1kU7kXW0267gNV") + } + style={{ flex: 1 }} + > + + + + = MD_BREAKPOINT ? layout.spacing_x1_5 : 0, + marginVertical: width >= MD_BREAKPOINT ? 0 : layout.spacing_x1_5, + }} + > + + + ); }; -// FIXME: remove StyleSheet.create -// eslint-disable-next-line no-restricted-syntax -const styles = StyleSheet.create({ - descriptionText: StyleSheet.flatten([ - fontSemibold14, - { - color: neutral77, - }, - ]), - buttonsContainer: { - flexDirection: "row", - flex: 1, - }, -}); +const descriptionTextCStyle: TextStyle = { + ...fontSemibold14, + color: neutral77, +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/LaunchpadCreateScreen.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/LaunchpadCreateScreen.tsx new file mode 100644 index 0000000000..abc4788118 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/LaunchpadCreateScreen.tsx @@ -0,0 +1,188 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMemo, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { View } from "react-native"; +import { useSelector } from "react-redux"; + +import { BrandText } from "@/components/BrandText"; +import { ScreenContainer } from "@/components/ScreenContainer"; +import { PrimaryButton } from "@/components/buttons/PrimaryButton"; +import { SecondaryButton } from "@/components/buttons/SecondaryButton"; +import { SpacerColumn } from "@/components/spacer"; +import { useFeedbacks } from "@/context/FeedbacksProvider"; +import { useCreateCollection } from "@/hooks/launchpad/useCreateCollection"; +import { useSelectedNetworkInfo } from "@/hooks/useSelectedNetwork"; +import { NetworkFeature } from "@/networks"; +import { + LaunchpadCreateStepKey, + LaunchpadStepper, +} from "@/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/LaunchpadStepper"; +import { LaunchpadAdditional } from "@/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAdditional"; +import { LaunchpadAssetsAndMetadata } from "@/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/LaunchpadAssetsAndMetadata"; +import { LaunchpadBasic } from "@/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadBasic"; +import { LaunchpadDetails } from "@/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadDetails"; +import { LaunchpadMinting } from "@/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMinting"; +import { LaunchpadTeamAndInvestment } from "@/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadTeamAndInvestment"; +import { selectNFTStorageAPI } from "@/store/slices/settings"; +import { ScreenFC, useAppNavigation } from "@/utils/navigation"; +import { neutral33 } from "@/utils/style/colors"; +import { layout } from "@/utils/style/layout"; +import { + CollectionFormValues, + ZodCollectionFormValues, +} from "@/utils/types/launchpad"; + +export const LaunchpadCreateScreen: ScreenFC<"LaunchpadCreate"> = () => { + const navigation = useAppNavigation(); + const selectedNetwork = useSelectedNetworkInfo(); + const { setToast } = useFeedbacks(); + const userIPFSKey = useSelector(selectNFTStorageAPI); + const collectionForm = useForm({ + mode: "all", + defaultValues: { + mintPeriods: [ + { + price: { + denom: selectedNetwork?.currencies[0].denom, + }, + isOpen: true, + }, + ], + assetsMetadatas: { + nftApiKey: userIPFSKey, + }, + }, + resolver: zodResolver(ZodCollectionFormValues), + }); + const { createCollection } = useCreateCollection(); + const [selectedStepKey, setSelectedStepKey] = + useState(1); + const [isLoading, setLoading] = useState(false); + const { setLoadingFullScreen } = useFeedbacks(); + + const stepContent = useMemo(() => { + switch (selectedStepKey) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + case 4: + return ; + case 5: + return ; + case 6: + return ; + default: + return ; + } + }, [selectedStepKey]); + + const onValid = async () => { + setLoading(true); + setLoadingFullScreen(true); + try { + // TODO: Uncomment when the NFT Launchpad MyCollections frontend is merged + // const success = + await createCollection(collectionForm.getValues()); + // TODO: Uncomment when the NFT Launchpad MyCollections frontend is merged + // if (success) navigation.navigate("LaunchpadMyCollections"); + } catch (e) { + console.error("Error creating a NFT collection", e); + } finally { + setLoading(false); + setLoadingFullScreen(false); + } + }; + + const onInvalid = () => { + setToast({ + mode: "normal", + type: "error", + title: "Unable to create the collection", + message: + "Some fields are not correctly filled." + + "\nMaybe from the mapping file, please complete it properly.\nCheck the description for more information.", + }); + }; + + const onPressSubmit = () => collectionForm.handleSubmit(onValid, onInvalid)(); + + return ( + } + forceNetworkFeatures={[NetworkFeature.CosmWasmNFTLaunchpad]} + headerChildren={Apply to Launchpad} + onBackPress={() => navigation.navigate("LaunchpadApply")} + // TODO: Remove after tests + forceNetworkId="teritori-testnet" + > + + + + + {stepContent} + + + + + {selectedStepKey !== 1 && ( + setSelectedStepKey(selectedStepKey - 1)} + /> + )} + + {selectedStepKey === 6 ? ( + + ) : ( + setSelectedStepKey(selectedStepKey + 1)} + /> + )} + + + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/LaunchpadStepper.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/LaunchpadStepper.tsx new file mode 100644 index 0000000000..4a0cc1ddf3 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/LaunchpadStepper.tsx @@ -0,0 +1,245 @@ +import { Dispatch, FC, SetStateAction, useRef } from "react"; +import { useFormContext } from "react-hook-form"; +import { + LayoutChangeEvent, + ScrollView, + TouchableOpacity, + useWindowDimensions, + View, +} from "react-native"; + +import ChevronRightSvg from "@/assets/icons/chevron-right.svg"; +import RejectSVG from "@/assets/icons/reject.svg"; +import { BrandText } from "@/components/BrandText"; +import { SVG } from "@/components/SVG"; +import { PrimaryBox } from "@/components/boxes/PrimaryBox"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { + neutral17, + neutral22, + neutral77, + primaryColor, + primaryTextColor, +} from "@/utils/style/colors"; +import { fontSemibold14 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; +import { CollectionFormValues } from "@/utils/types/launchpad"; +export type LaunchpadCreateStepKey = number; + +interface LaunchpadStepperProps { + selectedStepKey: LaunchpadCreateStepKey; + setSelectedStepKey: Dispatch>; +} + +interface LaunchpadCreateStep { + key: LaunchpadCreateStepKey; + title: string; +} + +const steps: LaunchpadCreateStep[] = [ + { + key: 1, + title: "Basic", + }, + { + key: 2, + title: "Details", + }, + { + key: 3, + title: "Team & Investments", + }, + { + key: 4, + title: "Additional", + }, + { + key: 5, + title: "Minting", + }, + { + key: 6, + title: "Assets & Metadata", + }, +]; + +export const LaunchpadStepper: FC = ({ + selectedStepKey, + setSelectedStepKey, +}) => { + const { width: windowWidth } = useWindowDimensions(); + const scrollViewRef = useRef(null); + const isMobile = useIsMobile(); + const collectionForm = useFormContext(); + + const hasErrors = (stepKey: number) => { + if ( + (stepKey === 1 && + (!!collectionForm.getFieldState("name").error || + !!collectionForm.getFieldState("description").error || + !!collectionForm.getFieldState("symbol").error)) || + !!collectionForm.getFieldState("coverImage").error || + !!collectionForm.getFieldState("assetsMetadatas.nftApiKey").error + ) { + return true; + } + if ( + stepKey === 2 && + (!!collectionForm.getFieldState("websiteLink").error || + !!collectionForm.getFieldState("isDerivativeProject").error || + !!collectionForm.getFieldState("projectTypes").error || + !!collectionForm.getFieldState("isPreviouslyApplied").error || + !!collectionForm.getFieldState("email").error) + ) { + return true; + } + if ( + stepKey === 3 && + (!!collectionForm.getFieldState("teamDescription").error || + !!collectionForm.getFieldState("partnersDescription").error || + !!collectionForm.getFieldState("investDescription").error || + !!collectionForm.getFieldState("investLink").error) + ) { + return true; + } + if ( + stepKey === 4 && + (!!collectionForm.getFieldState("artworkDescription").error || + !!collectionForm.getFieldState("isReadyForMint").error || + !!collectionForm.getFieldState("isDox").error || + !!collectionForm.getFieldState("daoWhitelistCount").error || + !!collectionForm.getFieldState("escrowMintProceedsPeriod").error) + ) { + return true; + } + if ( + stepKey === 5 && + (!!collectionForm.getFieldState("mintPeriods").error || + !!collectionForm.getFieldState("royaltyAddress").error || + !!collectionForm.getFieldState("royaltyPercentage").error) + ) { + return true; + } + if ( + stepKey === 6 && + !!collectionForm.getFieldState("assetsMetadatas").error + ) { + return true; + } + }; + + const onSelectedItemLayout = (e: LayoutChangeEvent) => { + scrollViewRef.current?.scrollTo({ + x: e.nativeEvent.layout.x, + animated: false, + }); + }; + + return ( + + + = 1240 && { justifyContent: "center" }, + { + flexDirection: "row", + width: "100%", + }, + ]} + > + {steps.map((step, index) => { + const isSelected = selectedStepKey === step.key; + return ( + { + if (isSelected) onSelectedItemLayout(e); + }} + onPress={() => setSelectedStepKey(step.key)} + style={{ + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + paddingHorizontal: layout.spacing_x2, + paddingVertical: layout.spacing_x1, + }} + > + + {hasErrors(step.key) && ( + + )} + + {step.key} + + + + {step.title} + + {steps.length !== index + 1 && ( + + )} + + ); + })} + + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAdditional.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAdditional.tsx new file mode 100644 index 0000000000..dac047e026 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAdditional.tsx @@ -0,0 +1,142 @@ +import { FC } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { View } from "react-native"; + +import { BrandText } from "@/components/BrandText"; +import { ErrorText } from "@/components/ErrorText"; +import { SpacerColumn } from "@/components/spacer"; +import { TextInputLaunchpad } from "@/screens/Launchpad/LaunchpadApply/components/inputs/TextInputLaunchpad"; +import { SelectInputLaunchpad } from "@/screens/Launchpad/LaunchpadApply/components/inputs/selectInputs/SelectInputLaunchpad"; +import { neutral55, neutral77 } from "@/utils/style/colors"; +import { + fontSemibold13, + fontSemibold14, + fontSemibold20, +} from "@/utils/style/fonts"; +import { CollectionFormValues } from "@/utils/types/launchpad"; + +export const LaunchpadAdditional: FC = () => { + const collectionForm = useFormContext(); + const escrowMintProceedsPeriod = collectionForm.watch( + "escrowMintProceedsPeriod", + ); + const isReadyForMint = collectionForm.watch("isReadyForMint"); + const isDox = collectionForm.watch("isDox"); + return ( + + + Additional Information + + + Fill the additional information + + + + + label="Please describe your artwork: " + sublabel={ + + + 1. Is it completely original? + + + 2. Who is the artist? + + + 3. How did your team meet the artist? + + + } + placeHolder="Describe here..." + name="artworkDescription" + form={collectionForm} + /> + + + name="isReadyForMint" + control={collectionForm.control} + render={({ field: { onChange } }) => ( + <> + { + onChange(item === "Yes"); + }} + label="Is your collection ready for the mint?" + style={{ zIndex: 3 }} + /> + + {collectionForm.getFieldState("isReadyForMint").error?.message} + + + )} + /> + + + + name="escrowMintProceedsPeriod" + control={collectionForm.control} + render={({ field: { onChange } }) => ( + <> + { + onChange(item); + }} + label="If selected for the launchpad, You will escrow mint proceeds for this time period:" + style={{ zIndex: 2 }} + /> + + { + collectionForm.getFieldState("escrowMintProceedsPeriod").error + ?.message + } + + + )} + /> + + + + name="isDox" + control={collectionForm.control} + render={({ field: { onChange } }) => ( + <> + { + onChange(item === "Yes"); + }} + label="Are you dox or have you planned to dox?" + style={{ zIndex: 1 }} + /> + + {collectionForm.getFieldState("isDox").error?.message} + + + )} + /> + + + + label="We'd love to offer TeritoriDAO members 10% of your whitelist supply if your project is willing. Please let us know how many whitelist spots you'd be willing to allocate our DAO: " + placeHolder="0" + name="daoWhitelistCount" + form={collectionForm} + /> + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/AssetModal.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/AssetModal.tsx new file mode 100644 index 0000000000..cd483bb7bd --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/AssetModal.tsx @@ -0,0 +1,193 @@ +import { FC } from "react"; +import { useFormContext } from "react-hook-form"; +import { View } from "react-native"; + +import { BrandText } from "@/components/BrandText"; +import { OptimizedImage } from "@/components/OptimizedImage"; +import { PrimaryBox } from "@/components/boxes/PrimaryBox"; +import { TertiaryBox } from "@/components/boxes/TertiaryBox"; +import { PrimaryButton } from "@/components/buttons/PrimaryButton"; +import { Label } from "@/components/inputs/TextInputCustom"; +import ModalBase from "@/components/modals/ModalBase"; +import { Separator } from "@/components/separators/Separator"; +import { TextInputLaunchpad } from "@/screens/Launchpad/LaunchpadApply/components/inputs/TextInputLaunchpad"; +import { + neutral22, + neutral33, + neutral77, + neutralFF, + secondaryColor, +} from "@/utils/style/colors"; +import { + fontSemibold14, + fontSemibold16, + fontSemibold20, +} from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; +import { + CollectionAssetsMetadataFormValues, + CollectionAssetsMetadatasFormValues, +} from "@/utils/types/launchpad"; + +export const AssetModal: FC<{ + onClose: () => void; + isVisible: boolean; + elem: CollectionAssetsMetadataFormValues; + elemIndex: number; +}> = ({ onClose, isVisible, elem, elemIndex }) => { + const assetsMetadatasForm = + useFormContext(); + const namePath = `assetsMetadatas.${elemIndex}.name` as const; + const descriptionPath = `assetsMetadatas.${elemIndex}.description` as const; + const externalUrlPath = `assetsMetadatas.${elemIndex}.externalUrl` as const; + const youtubeUrlPath = `assetsMetadatas.${elemIndex}.youtubeUrl` as const; + const attributes = assetsMetadatasForm.watch( + `assetsMetadatas.${elemIndex}.attributes`, + ); + + return ( + + + {elem.image && ( + + )} + + + + Asset #{elemIndex + 1} + + + File name: {elem.image?.fileName} + + + + } + hideMainSeparator + childrenBottom={ + + + + + + + } + > + + + + + name={namePath} + label="Name" + form={assetsMetadatasForm} + placeHolder="Token name" + disabled + /> + + + name={descriptionPath} + label="Description" + form={assetsMetadatasForm} + placeHolder="Token description" + required={false} + disabled + /> + + + name={externalUrlPath} + label="External URL" + form={assetsMetadatasForm} + placeHolder="https://" + required={false} + disabled + /> + + + name={youtubeUrlPath} + label="Youtube URL" + form={assetsMetadatasForm} + placeHolder="https://" + required={false} + disabled + /> + + + + + + {attributes.map((attribute, index) => ( + + + {`${attribute.type}: ${attribute.value}`} + + + ))} + + + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/AssetsAndMetadataIssue.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/AssetsAndMetadataIssue.tsx new file mode 100644 index 0000000000..f0b4613c94 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/AssetsAndMetadataIssue.tsx @@ -0,0 +1,71 @@ +import { FC } from "react"; +import { View } from "react-native"; + +import crossSVG from "@/assets/icons/cross.svg"; +import warningTriangleSVG from "@/assets/icons/warning-triangle.svg"; +import { BrandText } from "@/components/BrandText"; +import { SVG } from "@/components/SVG"; +import { CustomPressable } from "@/components/buttons/CustomPressable"; +import { SpacerColumn, SpacerRow } from "@/components/spacer"; +import { + errorColor, + neutral17, + neutral77, + warningColor, +} from "@/utils/style/colors"; +import { fontSemibold13 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; + +export interface AssetsAndMetadataIssueObject { + title: string; + message: string; + type: "error" | "warning"; +} + +export const AssetsAndMetadataIssue: FC<{ + issue: AssetsAndMetadataIssueObject; + removeIssue: () => void; +}> = ({ issue, removeIssue }) => { + return ( + + + + + + + {issue.title} + + + + + {issue.message} + + + + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/AssetsTab.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/AssetsTab.tsx new file mode 100644 index 0000000000..b21d850c55 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/AssetsTab.tsx @@ -0,0 +1,741 @@ +import { parse } from "papaparse"; +import pluralize from "pluralize"; +import { FC, useEffect, useRef, useState } from "react"; +import { useFieldArray, useFormContext } from "react-hook-form"; +import { SafeAreaView, TouchableOpacity, View } from "react-native"; + +import { AssetModal } from "./AssetModal"; +import { + AssetsAndMetadataIssue, + AssetsAndMetadataIssueObject, +} from "./AssetsAndMetadataIssue"; + +import trashSVG from "@/assets/icons/trash.svg"; +import { BrandText } from "@/components/BrandText"; +import { SelectedFilesPreview } from "@/components/FilePreview/SelectedFilesPreview/SelectedFilesPreview"; +import { SVG } from "@/components/SVG"; +import { FileUploaderSmall } from "@/components/inputs/FileUploaderSmall"; +import { FileUploaderSmallHandle } from "@/components/inputs/FileUploaderSmall/FileUploaderSmall.type"; +import { Separator } from "@/components/separators/Separator"; +import { SpacerColumn, SpacerRow } from "@/components/spacer"; +import { useFeedbacks } from "@/context/FeedbacksProvider"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { IMAGE_MIME_TYPES, TXT_CSV_MIME_TYPES } from "@/utils/mime"; +import { + NUMBERS_COMMA_SEPARATOR_REGEXP, + NUMBERS_REGEXP, + URL_REGEX, +} from "@/utils/regex"; +import { errorColor, neutral33 } from "@/utils/style/colors"; +import { fontSemibold14 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; +import { LocalFileData } from "@/utils/types/files"; +import { + CollectionAssetsAttributeFormValues, + CollectionAssetsMetadataFormValues, + CollectionAssetsMetadatasFormValues, +} from "@/utils/types/launchpad"; + +export const AssetsTab: React.FC = () => { + const isMobile = useIsMobile(); + const { setToast } = useFeedbacks(); + const [selectedElemIndex, setSelectedElemIndex] = useState(); + const assetsMetadatasForm = + useFormContext(); + const { fields, remove } = useFieldArray({ + control: assetsMetadatasForm.control, + name: "assetsMetadatas", + }); + const [assetsMappingDataRows, setAssetsMappingDataRows] = useState< + string[][] + >([]); + const [attributesMappingDataRows, setAttributesMappingDataRows] = useState< + string[][] + >([]); + + const attributesUploaderRef = useRef(null); + const assetsUploaderRef = useRef(null); + const imagesUploaderRef = useRef(null); + + const [assetModalVisible, setAssetModalVisible] = useState(false); + const selectedElem = fields.find((_, index) => index === selectedElemIndex); + const [attributesIssues, setAttributesIssues] = useState< + AssetsAndMetadataIssueObject[] + >([]); + const [assetsIssues, setAssetsIssues] = useState< + AssetsAndMetadataIssueObject[] + >([]); + const [imagesIssues, setImagesIssues] = useState< + AssetsAndMetadataIssueObject[] + >([]); + + const attributesIdsSeparator = ","; + // Assets columns + const fileNameColIndex = 0; + const nameColIndex = 1; + const descriptionColIndex = 2; + const externalURLColIndex = 3; + const youtubeURLColIndex = 4; + const attributesColIndex = 5; + // Attributes (traits) columns + const idColIndex = 0; + const typeColIndex = 1; + const valueColIndex = 2; + + const resetAllIssues = () => { + setAssetsIssues([]); + setAttributesIssues([]); + setImagesIssues([]); + }; + // We keep showing only the warnings if a image or mapping file is selected without error + const resetIssuesErrors = () => { + setAttributesIssues((issues) => + issues.filter((issue) => issue.type !== "error"), + ); + setAssetsIssues((issues) => + issues.filter((issue) => issue.type !== "error"), + ); + setImagesIssues((issues) => + issues.filter((issue) => issue.type !== "error"), + ); + }; + + const resetAll = () => { + setAssetsMappingDataRows([]); + setAttributesMappingDataRows([]); + assetsMetadatasForm.setValue("assetsMetadatas", []); + resetAllIssues(); + attributesUploaderRef.current?.resetFiles(); + assetsUploaderRef.current?.resetFiles(); + imagesUploaderRef.current?.resetFiles(); + }; + + // We ignore the first row since it's the table headings + // We ignore unwanted empty lines from the CSV + const cleanDataRows = (array: string[][]) => + array.filter( + (dataRow, dataRowIndex) => dataRow[0] !== "" && dataRowIndex > 0, + ); + // Converts attributes ids as string to array of ids + const cleanAssetAttributesIds = (ids?: string) => + ids + ?.split(attributesIdsSeparator) + .map((id) => id.trim()) + .filter((id) => NUMBERS_COMMA_SEPARATOR_REGEXP.test(id)) || []; + + // On remove image manually + const onRemoveImage = (index: number) => { + remove(index); + }; + // If all images are removed, we clear the images issues and the input file images + useEffect(() => { + if (!fields.length) { + setImagesIssues([]); + imagesUploaderRef.current?.resetFiles(); + } + }, [fields.length]); + + // On upload attributes CSV mapping file + const onUploadAttributesMapingFile = (files: LocalFileData[]) => { + resetAllIssues(); + setAssetsMappingDataRows([]); + assetsMetadatasForm.setValue("assetsMetadatas", []); + + try { + parse(files[0].file, { + complete: (parseResults) => { + const attributesDataRows = parseResults.data; + + // Controls CSV headings present on the first row. + if ( + attributesDataRows[0][idColIndex] !== "id" || + attributesDataRows[0][valueColIndex] !== "value" || + attributesDataRows[0][typeColIndex] !== "type" + ) { + setAttributesMappingDataRows([]); + + const title = "Invalid attributes mapping file"; + const message = + "Please verify the headings on the first row in your attributes mapping file.\nThis file is ignored.\nCheck the description for more information."; + console.error(title + ".\n" + message); + setAttributesIssues((issues) => [ + ...issues, + { + title, + message, + type: "error", + }, + ]); + return; + } + + // Verifying that all attributes rows have an id, name and value + const missingIdRows: string[][] = []; + const missingTypeRows: string[][] = []; + const missingValueRows: string[][] = []; + const wrongIdRows: string[][] = []; + const rowsIndexesToRemove: number[] = []; + + // Controlling attributes + cleanDataRows(attributesDataRows).forEach((dataRow, dataRowIndex) => { + const hasNoId = !dataRow[idColIndex]?.trim(); + const hasNoValue = !dataRow[valueColIndex]?.trim(); + const hasNoType = !dataRow[typeColIndex]?.trim(); + const hasWrongId = + dataRow[idColIndex]?.trim() && + !NUMBERS_REGEXP.test(dataRow[idColIndex].trim()); + + // Warning if no id in attribute (Ignore attribute) + if (hasNoId) { + missingIdRows.push(dataRow); + } + // Warning if no value in attribute (Ignore attribute) + if (hasNoValue) { + missingValueRows.push(dataRow); + } + // Warning if no type in attribute (Ignore attribute) + if (hasNoType) { + missingTypeRows.push(dataRow); + } + // Warning if id is not a digit (Ignore attribute) + if (hasWrongId) { + wrongIdRows.push(dataRow); + } + // We get the invalidated rows to remove + if (hasNoId || hasNoValue || hasNoType || hasWrongId) { + rowsIndexesToRemove.push(dataRowIndex); + } + }); + + // We remove the wrong rows from parseResults.data (The assets rows) + const result = cleanDataRows(parseResults.data).filter( + (_, index) => !rowsIndexesToRemove.includes(index), + ); + // Soring the final result + setAttributesMappingDataRows(result); + + // Handling warnings + if (missingIdRows.length) { + const title = `Incomplete ${pluralize("attribute", missingIdRows.length)}`; + const message = `Missing "id" in ${pluralize("attribute", missingIdRows.length, true)} that ${pluralize("has", missingIdRows.length)} been ignored.\nPlease complete properly your attributes mapping file.\nCheck the description for more information.`; + console.warn(title + ".\n" + message); + setAttributesIssues((issues) => [ + ...issues, + { + title, + message, + type: "warning", + }, + ]); + } + if (missingTypeRows.length) { + const title = `Incomplete ${pluralize("attribute", missingTypeRows.length)}`; + const message = `Missing "type" in ${pluralize("attribute", missingTypeRows.length, true)} that ${pluralize("has", missingTypeRows.length)} been ignored.\nPlease complete properly your attributes mapping file.\nCheck the description for more information.`; + console.warn(title + ".\n" + message); + setAttributesIssues((issues) => [ + ...issues, + { + title, + message, + type: "warning", + }, + ]); + } + if (missingValueRows.length) { + const title = `Incomplete ${pluralize("attribute", missingValueRows.length)}`; + const message = `Missing "value" in ${pluralize("attribute", missingValueRows.length, true)} that ${pluralize("has", missingValueRows.length)} been ignored.\nPlease complete properly your attributes mapping file.\nCheck the description for more information.`; + console.warn(title + ".\n" + message); + setAttributesIssues((issues) => [ + ...issues, + { + title, + message, + type: "warning", + }, + ]); + } + if (wrongIdRows.length) { + const title = `Wrong id`; + const message = `${pluralize("attribute", wrongIdRows.length, true)} ${pluralize("has", wrongIdRows.length)} a wrong "id" value and ${pluralize("has", wrongIdRows.length)} beed ignored. Only a number is allowed.\nCheck the description for more information.`; + console.warn(title + ".\n" + message); + setAttributesIssues((issues) => [ + ...issues, + { + title, + message, + type: "warning", + }, + ]); + } + }, + }); + } catch (e) { + setAttributesMappingDataRows([]); + + console.error(`${e}`); + setToast({ + title: "Error parsing " + files[0].file.name, + message: `${e}`, + mode: "normal", + type: "error", + }); + } + }; + + // On upload assets CSV mapping file + const onUploadAssetsMappingFile = (files: LocalFileData[]) => { + resetIssuesErrors(); + setAssetsIssues([]); + setImagesIssues([]); + assetsMetadatasForm.setValue("assetsMetadatas", []); + imagesUploaderRef.current?.resetFiles(); + + try { + parse(files[0].file, { + complete: (parseResults) => { + const assetsDataRows = parseResults.data; + const attributesDataRows = attributesMappingDataRows; // attributesMappingDataRows is clean here + + // Controls CSV headings present on the first row. + if ( + assetsDataRows[0][fileNameColIndex] !== "file_name" || + assetsDataRows[0][nameColIndex] !== "name" || + assetsDataRows[0][descriptionColIndex] !== "description" || + assetsDataRows[0][externalURLColIndex] !== "external_url" || + assetsDataRows[0][youtubeURLColIndex] !== "youtube_url" || + assetsDataRows[0][attributesColIndex] !== "attributes" + ) { + setAssetsMappingDataRows([]); + + const title = "Invalid assets mapping file"; + const message = + "Please verify the headings on the first row in your assets mapping file.This file is ignored.\nCheck the description for more information."; + console.error(title + ".\n" + message); + setAssetsIssues((issues) => [ + ...issues, + { + title, + message, + type: "error", + }, + ]); + return; + } + + const missingNameRows: string[][] = []; + const missingAttributesRows: string[][] = []; + const unknownAttributesRowsInAssets: string[][] = []; + const wrongAttributesRowsInAssets: string[][] = []; + const wrongUrlsRowsInAssets: string[][] = []; + const rowsIndexesToRemove: number[] = []; + + // Controlling assets and attributes + cleanDataRows(assetsDataRows).forEach( + (assetDataRow, assetDataRowIndex) => { + const hasNoName = !assetDataRow[nameColIndex]?.trim(); + const hasNoAttribute = !assetDataRow[attributesColIndex]?.trim(); + const hasWrongAttribute = !NUMBERS_COMMA_SEPARATOR_REGEXP.test( + assetDataRow[attributesColIndex], + ); + const hasWrongExternalUrl = + assetDataRow[externalURLColIndex]?.trim() && + !URL_REGEX.test(assetDataRow[externalURLColIndex].trim()); + const hasWrongYoutubeUrl = + assetDataRow[youtubeURLColIndex]?.trim() && + !URL_REGEX.test(assetDataRow[youtubeURLColIndex].trim()); + + // Warning if no name in asset (Ignore asset) + if (hasNoName) { + missingNameRows.push(assetDataRow); + } + // Warning if no attributes in asset (Ignore asset) + if (hasNoAttribute) { + missingAttributesRows.push(assetDataRow); + } + // Else, warning if wrong attributes ids in asset. We want numbers with comma separators (Ignore asset) + else if (hasWrongAttribute) { + wrongAttributesRowsInAssets.push(assetDataRow); + } + // We get unvalidated rows to remove + if (hasNoName || hasNoAttribute || hasWrongAttribute) { + rowsIndexesToRemove.push(assetDataRowIndex); + } + + // Warning if wrong urls in asset (No incidence) + if (hasWrongExternalUrl || hasWrongYoutubeUrl) { + wrongUrlsRowsInAssets.push(assetDataRow); + } + // Warning if unknow attributes ids in asset (No incidence) + const assetAttributesIds = cleanAssetAttributesIds( + assetDataRow[attributesColIndex], + ); + let nbIdsFound = 0; + assetAttributesIds.forEach((id) => { + attributesDataRows.forEach((attributeDataRow) => { + if (id === attributeDataRow[idColIndex]?.trim()) { + nbIdsFound++; + } + }); + }); + if (nbIdsFound < assetAttributesIds.length) { + unknownAttributesRowsInAssets.push(assetDataRow); + } + }, + ); + + // We remove the wrong rows from parseResults.data (The assets rows) + const result = cleanDataRows(assetsDataRows).filter( + (_, index) => !rowsIndexesToRemove.includes(index), + ); + // Storing the final results + setAssetsMappingDataRows(result); + + // Handling warnings + if (missingNameRows.length) { + const title = `Incomplete ${pluralize("asset", missingNameRows.length)}`; + const message = `Missing "name" in ${pluralize("asset", missingNameRows.length, true)} that ${pluralize("has", missingNameRows.length)} been ignored.\nPlease complete properly your assets mapping file.\nCheck the description for more information.`; + console.warn(title + ".\n" + message); + setAssetsIssues((issues) => [ + ...issues, + { + title, + message, + type: "warning", + }, + ]); + } + if (missingAttributesRows.length) { + const title = `Incomplete ${pluralize("asset", missingAttributesRows.length)}`; + const message = `Missing "attributes" in ${pluralize("asset", missingAttributesRows.length, true)} that ${pluralize("has", missingAttributesRows.length)} been ignored.\nPlease complete properly your assets mapping file.\nCheck the description for more information.`; + console.warn(title + ".\n" + message); + setAssetsIssues((issues) => [ + ...issues, + { + title, + message, + type: "warning", + }, + ]); + } + if (wrongAttributesRowsInAssets.length) { + const title = `Wrong attributes`; + const message = `${pluralize("asset", wrongAttributesRowsInAssets.length, true)} ${pluralize("has", wrongAttributesRowsInAssets.length)} a wrong "attributes" value and ${pluralize("has", wrongAttributesRowsInAssets.length)} been ignored. Only numbers with comma separator are allwowed.\nCheck the description for more information.`; + console.warn(title + ".\n" + message); + setAssetsIssues((issues) => [ + ...issues, + { + title, + message, + type: "warning", + }, + ]); + } + if (wrongUrlsRowsInAssets.length) { + const title = `Wrong URLs`; + const message = `${pluralize("asset", wrongUrlsRowsInAssets.length, true)} ${pluralize("has", wrongUrlsRowsInAssets.length)} a wrong "youtube_url" or "external_url" value (No incidence).\nCheck the description for more information.`; + console.warn(title + ".\n" + message); + setAssetsIssues((issues) => [ + ...issues, + { + title, + message, + type: "warning", + }, + ]); + } + if (unknownAttributesRowsInAssets.length) { + const title = `Unknown attributes`; + const message = `${pluralize("asset", unknownAttributesRowsInAssets.length, true)} ${pluralize("has", unknownAttributesRowsInAssets.length)} at least one "attributes" id that doesn't exist in your attributes mapping file. (No incidence)\nCheck the description for more information.`; + console.warn(title + ".\n" + message); + setAssetsIssues((issues) => [ + ...issues, + { + title, + message, + type: "warning", + }, + ]); + } + }, + }); + } catch (e) { + setAssetsMappingDataRows([]); + + console.error(`${e}`); + setToast({ + title: "Error parsing " + files[0].file.name, + message: `${e}`, + mode: "normal", + type: "error", + }); + } + }; + + // On upload images files + const onUploadImages = (images: LocalFileData[]) => { + if (!assetsMappingDataRows.length || !attributesMappingDataRows.length) + return; + resetIssuesErrors(); + setImagesIssues([]); + + const collectionAssetsMetadatas: CollectionAssetsMetadataFormValues[] = []; + + //The rows order in the CSV determines the assets order. + assetsMappingDataRows.forEach((assetDataRow, assetDataRowIndex) => { + images.forEach((image) => { + if (assetDataRow[fileNameColIndex] !== image.file.name) return; + // --- Mapping attributes + const mappedAttributes: CollectionAssetsAttributeFormValues[] = []; + const assetAttributesIds = [ + ...new Set(cleanAssetAttributesIds(assetDataRow[attributesColIndex])), + ]; // We ignore duplicate attributes ids from assets + assetAttributesIds.forEach((assetAttributeId) => { + attributesMappingDataRows.forEach( + (attributeDataRow, attributeDataRowIndex) => { + if (attributeDataRow[idColIndex] === assetAttributeId) { + mappedAttributes.push({ + value: attributeDataRow[valueColIndex], + type: attributeDataRow[typeColIndex], + }); + } + }, + ); + }); + + // --- Mapping assets + const mappedAssets: CollectionAssetsMetadataFormValues = { + image, + name: assetDataRow[nameColIndex], + description: assetDataRow[descriptionColIndex], + externalUrl: assetDataRow[externalURLColIndex], + youtubeUrl: assetDataRow[youtubeURLColIndex], + attributes: mappedAttributes, + }; + collectionAssetsMetadatas.push(mappedAssets); + }); + }); + assetsMetadatasForm.setValue("assetsMetadatas", collectionAssetsMetadatas); + + // Handling warnings + if (collectionAssetsMetadatas.length < images.length) { + const nbUnexpectedImages = + images.length - collectionAssetsMetadatas.length; + const title = `Unexpected ${pluralize("image", nbUnexpectedImages)}`; + const message = `${pluralize("image", nbUnexpectedImages, true)} ${pluralize("is", nbUnexpectedImages)} not expected in your assets mapping file and ${pluralize("has", nbUnexpectedImages)} been ignored.\nCheck the description for more information.`; + console.warn(title + ".\n" + message); + setImagesIssues((issues) => [ + ...issues, + { + title, + message, + type: "warning", + }, + ]); + } + + if (assetsMappingDataRows.length > collectionAssetsMetadatas.length) { + const nbMissingImages = + assetsMappingDataRows.length - collectionAssetsMetadatas.length; + const title = `Missing ${pluralize("image", nbMissingImages)}`; + const message = `${pluralize("image", nbMissingImages, true)} expected in your assets mapping file ${pluralize("is", nbMissingImages)} missing.\nCheck the description for more information.`; + console.warn(title + ".\n" + message); + setImagesIssues((issues) => [ + ...issues, + { + title, + message, + type: "warning", + }, + ]); + } + }; + + return ( + + {/* ===== Issues */} + {attributesIssues.map((issue, index) => ( + + setAttributesIssues((issues) => + issues.filter((_, i) => i !== index), + ) + } + /> + ))} + {assetsIssues.map((issue, index) => ( + + setAssetsIssues((issues) => issues.filter((_, i) => i !== index)) + } + /> + ))} + {imagesIssues.map((issue, index) => ( + + setImagesIssues((issues) => issues.filter((_, i) => i !== index)) + } + /> + ))} + + + {/* ===== Left container */} + + + + {/* Firstly: Attributes */} + + + + + {/* Secondly: Assets */} + + + + + + + {/* Thirdly: Images */} + + + {(!!fields.length || + !!assetsMappingDataRows.length || + !!attributesMappingDataRows.length) && ( + <> + + + + + + )} + + + + + {/* ---- Separator*/} + + + {/* ===== Right container */} + + metadata.image!, + )} + onPressItem={(file, itemIndex) => { + setAssetModalVisible(true); + setSelectedElemIndex(itemIndex); + }} + onPressDeleteItem={onRemoveImage} + /> + + + {selectedElem && selectedElemIndex !== undefined && ( + setAssetModalVisible(false)} + isVisible={assetModalVisible} + elem={selectedElem} + elemIndex={selectedElemIndex} + /> + )} + + + ); +}; + +const ResetAllButton: FC<{ + onPress: () => void; +}> = ({ onPress }) => { + return ( + + + + + Remove all files + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/LaunchpadAssetsAndMetadata.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/LaunchpadAssetsAndMetadata.tsx new file mode 100644 index 0000000000..bb176fbb8d --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/LaunchpadAssetsAndMetadata.tsx @@ -0,0 +1,116 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { FC, useEffect, useState } from "react"; +import { FormProvider, useForm, useFormContext } from "react-hook-form"; +import { View } from "react-native"; + +import { AssetsTab } from "./AssetsTab"; +import { UriTab } from "./UriTab"; + +import { BrandText } from "@/components/BrandText"; +import { SpacerColumn } from "@/components/spacer"; +import { Tabs } from "@/components/tabs/Tabs"; +import { useIsMobile } from "@/hooks/useIsMobile"; +import { neutral33, neutral77, primaryColor } from "@/utils/style/colors"; +import { fontSemibold14, fontSemibold28 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; +import { + CollectionAssetsMetadatasFormValues, + CollectionFormValues, + ZodCollectionAssetsMetadatasFormValues, +} from "@/utils/types/launchpad"; + +const AssetsAndMetadataTabItems = { + assets: { + name: "Upload assets & metadata", + }, + uri: { + name: "Use an existing base URI", + }, +}; + +export const LaunchpadAssetsAndMetadata: FC = () => { + const isMobile = useIsMobile(); + const [selectedTab, setSelectedTab] = + useState("assets"); + const { watch, setValue } = useFormContext(); + const collectionAssetsMetadatas = watch("assetsMetadatas"); + const assetsMetadatasForm = useForm({ + mode: "all", + defaultValues: collectionAssetsMetadatas, // Retrieve assetsMetadatas from collectionForm + resolver: zodResolver(ZodCollectionAssetsMetadatasFormValues), + }); + const assetsMetadatas = assetsMetadatasForm.watch("assetsMetadatas"); + + // Plug assetsMetadatas from assetsMetadatasForm to collectionForm + useEffect(() => { + setValue("assetsMetadatas.assetsMetadatas", assetsMetadatas); + }, [assetsMetadatas, setValue]); + + return ( + + + Assets & Metadata + + + + + Make sure you check out{" "} + + documentation + {" "} + on how to create your collection + + + + + + + {selectedTab === "assets" && ( + + + + )} + + {/*TODO: Handle this ?*/} + {selectedTab === "uri" && } + + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/UriTab.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/UriTab.tsx new file mode 100644 index 0000000000..c7af4a0d00 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadAssetsAndMetadata/UriTab.tsx @@ -0,0 +1,60 @@ +import { FC } from "react"; +import { useFormContext } from "react-hook-form"; +import { View } from "react-native"; + +import { BrandText } from "@/components/BrandText"; +import { SpacerColumn } from "@/components/spacer"; +import { TextInputLaunchpad } from "@/screens/Launchpad/LaunchpadApply/components/inputs/TextInputLaunchpad"; +import { neutral77 } from "@/utils/style/colors"; +import { fontSemibold14 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; +import { CollectionFormValues } from "@/utils/types/launchpad"; + +export const UriTab: FC = () => { + const collectionForm = useFormContext(); + + return ( + + + + + Though Teritori's tr721 contract allows for off-chain metadata + storage, it is recommended to use a decentralized storage solution, + such as IPFS. You may head over to NFT.Storage and upload your + assets & metadata manually to get a base URI for your collection. + + + + + label="Base Token URI" + placeHolder="ipfs://" + name="baseTokenUri" + form={collectionForm} + required={false} + /> + + + name="coverImageUri" + label="Cover Image URI" + placeHolder="ipfs://" + form={collectionForm} + required={false} + /> + + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadBasic.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadBasic.tsx new file mode 100644 index 0000000000..2461810e18 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadBasic.tsx @@ -0,0 +1,137 @@ +import { FC } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { View } from "react-native"; + +import { BrandText } from "@/components/BrandText"; +import { ErrorText } from "@/components/ErrorText"; +import { NetworkSelectorWithLabel } from "@/components/NetworkSelector/NetworkSelectorWithLabel"; +import { FileUploaderSmall } from "@/components/inputs/FileUploaderSmall"; +import { SpacerColumn } from "@/components/spacer"; +import { NetworkFeature } from "@/networks"; +import { TextInputLaunchpad } from "@/screens/Launchpad/LaunchpadApply/components/inputs/TextInputLaunchpad"; +import { IMAGE_MIME_TYPES } from "@/utils/mime"; +import { neutral55, neutral77, primaryColor } from "@/utils/style/colors"; +import { + fontSemibold13, + fontSemibold14, + fontSemibold28, +} from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; +import { CollectionFormValues } from "@/utils/types/launchpad"; + +export const LaunchpadBasic: FC = () => { + const collectionForm = useFormContext(); + const coverImage = collectionForm.watch("coverImage"); + + return ( + + + Create Collection + + + + + Make sure you check out{" "} + + documentation + {" "} + on how to create your collection + + + + + + + label="Name" + placeHolder="My Awesome Collection" + name="name" + form={collectionForm} + /> + + + label="Describe your project: " + sublabel={ + + + 1. What's your concept? + + + 2. How is it different? + + + 3. What's your goal? + + + } + placeHolder="Describe here..." + name="description" + form={collectionForm} + /> + + + label="Symbol" + placeHolder="Symbol" + name="symbol" + form={collectionForm} + valueModifier={(value) => value.toUpperCase()} + /> + + + control={collectionForm.control} + name="coverImage" + render={({ field: { onChange } }) => ( + <> + { + onChange(files[0]); + }} + filesCount={coverImage ? 1 : 0} + mimeTypes={IMAGE_MIME_TYPES} + required + imageToShow={coverImage} + onPressDelete={() => onChange(undefined)} + /> + + {collectionForm.getFieldState("coverImage").error?.message} + + + )} + /> + + + + label="NFT.Storage JWT" + sublabel={ + + Used to upload the cover image and the assets to your NFT Storage + + } + placeHolder="My Awesome Collection" + name="assetsMetadatas.nftApiKey" + form={collectionForm} + /> + + + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadDetails.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadDetails.tsx new file mode 100644 index 0000000000..46fadde159 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadDetails.tsx @@ -0,0 +1,163 @@ +import { FC } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { View } from "react-native"; + +import { MultipleSelectInput } from "../../../components/inputs/selectInputs/MultipleSelectInput"; +import { SelectInputLaunchpad } from "../../../components/inputs/selectInputs/SelectInputLaunchpad"; + +import { BrandText } from "@/components/BrandText"; +import { ErrorText } from "@/components/ErrorText"; +import { SpacerColumn } from "@/components/spacer"; +import { TextInputLaunchpad } from "@/screens/Launchpad/LaunchpadApply/components/inputs/TextInputLaunchpad"; +import { neutral55, neutral77 } from "@/utils/style/colors"; +import { + fontSemibold13, + fontSemibold14, + fontSemibold20, +} from "@/utils/style/fonts"; +import { CollectionFormValues } from "@/utils/types/launchpad"; + +export const LaunchpadDetails: FC = () => { + const collectionForm = useFormContext(); + const projectTypes = collectionForm.watch("projectTypes") || []; + const isDerivativeProject = collectionForm.watch("isDerivativeProject"); + const isPreviouslyApplied = collectionForm.watch("isPreviouslyApplied"); + + return ( + + + Collection details + + + Information about your collection + + + + + label="Website Link" + sublabel={ + + + Your project's website. It must display the project's discord + and twitter, the roadmap/whitepaper and team's information. + Please, be fully transparent to facilitate your project's review + ! + + + } + placeHolder="https://website..." + name="websiteLink" + form={collectionForm} + /> + + + label="Main contact email address: " + placeHolder="contact@email.com" + name="email" + form={collectionForm} + /> + + + name="isDerivativeProject" + control={collectionForm.control} + render={({ field: { onChange } }) => ( + <> + { + onChange(item === "Yes"); + }} + label="Is your project a derivative project?" + style={{ zIndex: 3 }} + /> + + { + collectionForm.getFieldState("isDerivativeProject").error + ?.message + } + + + )} + /> + + + + name="projectTypes" + control={collectionForm.control} + render={({ field: { onChange } }) => ( + <> + { + const selectedProjectTypes = projectTypes.includes(item) + ? projectTypes.filter((data) => data !== item) + : [...projectTypes, item]; + onChange(selectedProjectTypes); + }} + label="Project type:" + sublabel={ + + Multiple answers allowed + + } + style={{ zIndex: 2 }} + /> + + {collectionForm.getFieldState("projectTypes").error?.message} + + + )} + /> + + + + name="isPreviouslyApplied" + control={collectionForm.control} + render={({ field: { onChange } }) => ( + <> + { + onChange(item === "Yes"); + }} + label="Have you previously applied for the same project before?" + style={{ zIndex: 1 }} + /> + + { + collectionForm.getFieldState("isPreviouslyApplied").error + ?.message + } + + + )} + /> + + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/ConfigureRoyaltyDetails.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/ConfigureRoyaltyDetails.tsx new file mode 100644 index 0000000000..2ce7561d20 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/ConfigureRoyaltyDetails.tsx @@ -0,0 +1,60 @@ +import { FC } from "react"; +import { useFormContext } from "react-hook-form"; +import { View } from "react-native"; + +import { BrandText } from "@/components/BrandText"; +import { SpacerColumn } from "@/components/spacer"; +import { TextInputLaunchpad } from "@/screens/Launchpad/LaunchpadApply/components/inputs/TextInputLaunchpad"; +import { neutral55, neutral77 } from "@/utils/style/colors"; +import { + fontSemibold13, + fontSemibold14, + fontSemibold20, +} from "@/utils/style/fonts"; +import { CollectionFormValues } from "@/utils/types/launchpad"; + +export const ConfigureRoyaltyDetails: FC = () => { + const collectionForm = useFormContext(); + + return ( + + + Royalty Details + + + Configure royalties + + + + + label="Payment Address " + placeHolder="teritori123456789qwertyuiopasdfghjklzxcvbnm" + name="royaltyAddress" + sublabel={ + + + Address to receive royalties + + + } + form={collectionForm} + required={false} + /> + + + label="Share Percentage " + placeHolder="8%" + name="royaltyPercentage" + sublabel={ + + + Percentage of royalties to be paid + + + } + form={collectionForm} + required={false} + /> + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriodAccordion.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriodAccordion.tsx new file mode 100644 index 0000000000..43c6f9736b --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriodAccordion.tsx @@ -0,0 +1,46 @@ +import { FC } from "react"; +import { UseFieldArrayRemove, UseFieldArrayUpdate } from "react-hook-form"; + +import { LaunchpadMintPeriodAccordionBottom } from "./LaunchpadMintPeriodAccordionBottom"; +import { LaunchpadMintPeriodAccordionTop } from "./LaunchpadMintPeriodAccordionTop"; + +import { PrimaryBox } from "@/components/boxes/PrimaryBox"; +import { neutral00, neutral22, neutral33 } from "@/utils/style/colors"; +import { + CollectionFormValues, + CollectionMintPeriodFormValues, +} from "@/utils/types/launchpad"; + +export const LaunchpadMintPeriodAccordion: FC<{ + elem: CollectionMintPeriodFormValues; + elemIndex: number; + remove: UseFieldArrayRemove; + update: UseFieldArrayUpdate; + closeAll: () => void; +}> = ({ elem, elemIndex, remove, update, closeAll }) => { + return ( + + + + {elem.isOpen && ( + + )} + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriodAccordionBottom.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriodAccordionBottom.tsx new file mode 100644 index 0000000000..352429ce67 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriodAccordionBottom.tsx @@ -0,0 +1,207 @@ +import { FC } from "react"; +import { + Controller, + UseFieldArrayRemove, + UseFieldArrayUpdate, + useFormContext, +} from "react-hook-form"; +import { View, TouchableOpacity } from "react-native"; + +import trashSVG from "@/assets/icons/trash.svg"; +import { BrandText } from "@/components/BrandText"; +import { ErrorText } from "@/components/ErrorText"; +import { SVG } from "@/components/SVG"; +import { CsvTextRowsInput } from "@/components/inputs/CsvTextRowsInput"; +import { DateTimeInput } from "@/components/inputs/DateTimeInput"; +import { Separator } from "@/components/separators/Separator"; +import { SpacerColumn, SpacerRow } from "@/components/spacer"; +import { useSelectedNetworkId } from "@/hooks/useSelectedNetwork"; +import { getCurrency } from "@/networks"; +import { CurrencyInputLaunchpad } from "@/screens/Launchpad/LaunchpadApply/components/inputs/CurrencyInputLaunchpad/CurrencyInputLaunchpad"; +import { TextInputLaunchpad } from "@/screens/Launchpad/LaunchpadApply/components/inputs/TextInputLaunchpad"; +import { errorColor, neutral55, neutral77 } from "@/utils/style/colors"; +import { + fontSemibold13, + fontSemibold14, + fontSemibold20, +} from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; +import { + CollectionFormValues, + CollectionMintPeriodFormValues, +} from "@/utils/types/launchpad"; + +export const LaunchpadMintPeriodAccordionBottom: FC<{ + elem: CollectionMintPeriodFormValues; + update: UseFieldArrayUpdate; + remove: UseFieldArrayRemove; + elemIndex: number; +}> = ({ elem, elemIndex, remove, update }) => { + // Since the Collection network is the selected network, we use useSelectedNetworkId (See LaunchpadBasic.tsx) + const networkId = useSelectedNetworkId(); + const collectionForm = useFormContext(); + const amountPath = `mintPeriods.${elemIndex}.price.amount` as const; + const startTimePath = `mintPeriods.${elemIndex}.startTime` as const; + const endTimePath = `mintPeriods.${elemIndex}.endTime` as const; + const maxTokensPath = `mintPeriods.${elemIndex}.maxTokens` as const; + const perAddressLimitPath = + `mintPeriods.${elemIndex}.perAddressLimit` as const; + const mintPeriods = collectionForm.watch("mintPeriods"); + const amount = collectionForm.watch(amountPath); + const startTime = collectionForm.watch(startTimePath); + const endTime = collectionForm.watch(endTimePath); + const selectedCurrency = getCurrency(networkId, elem.price.denom); + + return ( + + + name={amountPath} + control={collectionForm.control} + render={({ field: { onChange } }) => ( + <> + { + update(elemIndex, { + ...elem, + price: { ...elem.price, denom: currency.denom }, + }); + }} + onChangeAmountAtomics={(amountAtomics) => { + onChange(amountAtomics); + }} + required={false} + /> + + {collectionForm.getFieldState(amountPath).error?.message} + + + )} + /> + + + + label="Max Tokens" + placeHolder="0" + name={maxTokensPath} + sublabel={ + + + Maximum number of mintable tokens + + + } + form={collectionForm} + required={false} + /> + + + label="Per Address Limit" + placeHolder="0" + name={perAddressLimitPath} + sublabel={ + + + Maximum number of mintable tokens per address + + + } + form={collectionForm} + required={false} + /> + + + name={startTimePath} + control={collectionForm.control} + render={({ field: { onChange } }) => ( + + )} + /> + + + + name={endTimePath} + control={collectionForm.control} + render={({ field: { onChange } }) => ( + + )} + /> + + + + + Whitelist Addresses + + + Select a TXT or CSV file that contains the whitelisted addresses (One + address per line) + + + + + update(elemIndex, { + ...elem, + whitelistAddressesFile: file, + whitelistAddresses: rows, + }) + } + /> + + + { + // Can remove periods only if more than one (There will be least one period left) + (elemIndex > 0 || mintPeriods.length > 1) && ( + <> + + + + remove(elemIndex)} + > + + + + Remove Mint Period + + + + ) + } + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriodAccordionTop.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriodAccordionTop.tsx new file mode 100644 index 0000000000..e0ec55e36e --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriodAccordionTop.tsx @@ -0,0 +1,95 @@ +import { FC } from "react"; +import { UseFieldArrayUpdate } from "react-hook-form"; +import { TouchableOpacity, View } from "react-native"; + +import chevronDownSVG from "@/assets/icons/chevron-down.svg"; +import chevronUpSVG from "@/assets/icons/chevron-up.svg"; +import { BrandText } from "@/components/BrandText"; +import { SVG } from "@/components/SVG"; +import { Separator } from "@/components/separators/Separator"; +import { SpacerColumn } from "@/components/spacer"; +import { secondaryColor } from "@/utils/style/colors"; +import { fontSemibold16 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; +import { + CollectionFormValues, + CollectionMintPeriodFormValues, +} from "@/utils/types/launchpad"; + +export const LaunchpadMintPeriodAccordionTop: FC<{ + elem: CollectionMintPeriodFormValues; + elemIndex: number; + update: UseFieldArrayUpdate; + closeAll: () => void; +}> = ({ elem, elemIndex, update, closeAll }) => { + if (elem.isOpen) { + return ( + update(elemIndex, { ...elem, isOpen: false })} + style={{ + paddingTop: layout.spacing_x1, + paddingHorizontal: layout.spacing_x1, + }} + > + + + {`Period #${elemIndex + 1}`} + + + + + + + + + ); + } else { + return ( + { + closeAll(); + update(elemIndex, { ...elem, isOpen: true }); + }} + > + + + {`Period #${elemIndex + 1}`} + + + + + + + ); + } +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriods.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriods.tsx new file mode 100644 index 0000000000..7879fa7d15 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriods.tsx @@ -0,0 +1,119 @@ +import { FC, Fragment, useCallback } from "react"; +import { useFieldArray, useFormContext } from "react-hook-form"; +import { TouchableOpacity, View } from "react-native"; + +import addSVG from "@/assets/icons/add-secondary.svg"; +import { BrandText } from "@/components/BrandText"; +import { SVG } from "@/components/SVG"; +import { SpacerColumn, SpacerRow } from "@/components/spacer"; +import { useSelectedNetworkInfo } from "@/hooks/useSelectedNetwork"; +import useSelectedWallet from "@/hooks/useSelectedWallet"; +import { getNetworkFeature, NetworkFeature } from "@/networks"; +import { LaunchpadMintPeriodAccordion } from "@/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriodAccordion"; +import { secondaryColor } from "@/utils/style/colors"; +import { fontSemibold14 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; +import { CollectionFormValues } from "@/utils/types/launchpad"; + +export const LaunchpadMintPeriods: FC = () => { + const selectedWallet = useSelectedWallet(); + const networkId = selectedWallet?.networkId || ""; + const collectionForm = useFormContext(); + const selectedNetwork = useSelectedNetworkInfo(); + + const { update, append, remove } = useFieldArray({ + control: collectionForm.control, + name: "mintPeriods", + }); + const mintPeriods = collectionForm.watch("mintPeriods"); + + const closeAll = useCallback(() => { + mintPeriods.map((elem, index) => { + update(index, { ...elem, isOpen: false }); + }); + }, [mintPeriods, update]); + + const createMintPeriod = useCallback(() => { + if (!selectedNetwork) return; + closeAll(); + const feature = getNetworkFeature( + networkId, + NetworkFeature.CosmWasmNFTLaunchpad, + ); + if (!feature) { + throw new Error("This network does not support nft launchpad"); + } + append({ + price: { denom: selectedNetwork.currencies[0].denom, amount: "" }, + maxTokens: "", + perAddressLimit: "", + startTime: 0, + endTime: 0, + isOpen: true, + }); + }, [networkId, closeAll, append, selectedNetwork]); + + return ( + + + {mintPeriods.map((elem, index) => { + return ( + + + + + ); + })} + + + + + + + Add Minting Period + + + + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMinting.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMinting.tsx new file mode 100644 index 0000000000..048c004a3b --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMinting.tsx @@ -0,0 +1,67 @@ +import { FC } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { View } from "react-native"; + +import { ConfigureRoyaltyDetails } from "./ConfigureRoyaltyDetails"; + +import { BrandText } from "@/components/BrandText"; +import { DateTimeInput } from "@/components/inputs/DateTimeInput"; +import { Separator } from "@/components/separators/Separator"; +import { SpacerColumn } from "@/components/spacer"; +import { LaunchpadMintPeriods } from "@/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadMinting/LaunchpadMintPeriods"; +import { neutral77 } from "@/utils/style/colors"; +import { fontSemibold14, fontSemibold20 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; +import { CollectionFormValues } from "@/utils/types/launchpad"; + +export const LaunchpadMinting: FC = () => { + const collectionForm = useFormContext(); + const revealTime = collectionForm.watch("revealTime"); + return ( + + + Minting details + + + Configure the global minting settings + + + + + name="revealTime" + control={collectionForm.control} + render={({ field: { onChange } }) => ( + + )} + /> + + + + + Minting Periods + + + Configure the minting periods, a whitelist can be applied + + + + + + + + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadTeamAndInvestment.tsx b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadTeamAndInvestment.tsx new file mode 100644 index 0000000000..61458ad3ea --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/LaunchpadCreate/components/steps/LaunchpadTeamAndInvestment.tsx @@ -0,0 +1,110 @@ +import { FC } from "react"; +import { useFormContext } from "react-hook-form"; +import { View } from "react-native"; + +import { BrandText } from "@/components/BrandText"; +import { SpacerColumn } from "@/components/spacer"; +import { TextInputLaunchpad } from "@/screens/Launchpad/LaunchpadApply/components/inputs/TextInputLaunchpad"; +import { neutral55, neutral77 } from "@/utils/style/colors"; +import { + fontSemibold13, + fontSemibold14, + fontSemibold20, +} from "@/utils/style/fonts"; +import { CollectionFormValues } from "@/utils/types/launchpad"; + +export const LaunchpadTeamAndInvestment: FC = () => { + const collectionForm = useFormContext(); + + return ( + + + Team & Investments + + + Fill the information about the team and investors + + + + + label="Describe your team: " + sublabel={ + + + 1. How many core members are you? ( Working on the project daily + ) + + + 2. Who does what in your team? + + + 3. Past accomplishments or projects? + + + 4. How did you guys meet? + + + 5. Please add Linkedin links for all your members. + + + } + placeHolder="Describe here..." + name="teamDescription" + form={collectionForm} + /> + + + label="Do you have any partners on the project? " + sublabel={ + + + If yes, who are they? What do they do for you? + + + } + placeHolder="Type here..." + name="partnersDescription" + form={collectionForm} + /> + + + label="What have you invested in this project so far? " + sublabel={ + + + 1. How much upfront capital has been invested? + + + 2. Have you raised outside funding for the project? + + + 3. How long has the project been worked on? + + + 4. Is there a proof of concept or demo to show? + + + } + placeHolder="Type here..." + name="investDescription" + form={collectionForm} + /> + + + label="Investment links and attachments " + sublabel={ + + + Please provide any relevant links regarding your investment. You + can also post a google drive link. + + + } + placeHolder="Type here..." + name="investLink" + form={collectionForm} + /> + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/components/inputs/CurrencyInputLaunchpad/CurrencyInputLaunchpad.tsx b/packages/screens/Launchpad/LaunchpadApply/components/inputs/CurrencyInputLaunchpad/CurrencyInputLaunchpad.tsx new file mode 100644 index 0000000000..c5e9a406ed --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/components/inputs/CurrencyInputLaunchpad/CurrencyInputLaunchpad.tsx @@ -0,0 +1,233 @@ +import { Decimal } from "@cosmjs/math"; +import { FC, Fragment, ReactElement, useRef, useState } from "react"; +import { StyleProp, TextInput, View } from "react-native"; + +import { BrandText } from "@/components/BrandText"; +import { ErrorText } from "@/components/ErrorText"; +import { Box, BoxStyle } from "@/components/boxes/Box"; +import { CustomPressable } from "@/components/buttons/CustomPressable"; +import { Label } from "@/components/inputs/TextInputCustom"; +import { SpacerColumn, SpacerRow } from "@/components/spacer"; +import { useDropdowns } from "@/hooks/useDropdowns"; +import { + allNetworks, + CurrencyInfo, + getNativeCurrency, + getNetwork, +} from "@/networks"; +import { SelectableCurrencySmall } from "@/screens/Launchpad/LaunchpadApply/components/inputs/CurrencyInputLaunchpad/SelectableCurrencySmall"; +import { SelectedCurrencySmall } from "@/screens/Launchpad/LaunchpadApply/components/inputs/CurrencyInputLaunchpad/SelectedCurrencySmall"; +import { validateFloatWithDecimals } from "@/utils/formRules"; +import { + errorColor, + neutral17, + neutral22, + neutral33, + neutral77, + secondaryColor, +} from "@/utils/style/colors"; +import { fontSemibold14 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; + +export const CurrencyInputLaunchpad: FC<{ + label?: string; + placeHolder?: string; + subtitle?: ReactElement; + sublabel?: ReactElement; + required?: boolean; + error?: string; + networkId: string; + onSelectCurrency: (currency: CurrencyInfo) => void; + onChangeAmountAtomics: (amountAtomics: string) => void; + amountAtomics?: string; + currency?: CurrencyInfo; + boxStyle?: StyleProp; +}> = ({ + label, + placeHolder = "0", + sublabel, + subtitle, + required = true, + error, + networkId, + onSelectCurrency, + onChangeAmountAtomics, + boxStyle, + amountAtomics, + currency, +}) => { + const network = getNetwork(networkId); + const currencies: CurrencyInfo[] = network?.currencies || []; + const [selectedCurrency, setSelectedCurrency] = useState( + currency || currencies[0], + ); + const selectedCurrencyNetwork = allNetworks.find( + (network) => + !!network.currencies.find( + (currency) => currency.denom === selectedCurrency?.denom, + ), + ); + const selectedNativeCurrency = getNativeCurrency( + selectedCurrencyNetwork?.id, + selectedCurrency?.denom, + ); + const [value, setValue] = useState( + selectedNativeCurrency && amountAtomics + ? Decimal.fromAtomics( + amountAtomics, + selectedNativeCurrency.decimals, + ).toString() + : "", + ); + + const inputRef = useRef(null); + const [isDropdownOpen, setDropdownState, dropdownRef] = useDropdowns(); + const [hovered, setHovered] = useState(false); + const boxHeight = 40; + + const onChangeText = (text: string) => { + if (!text) { + setValue(""); + onChangeAmountAtomics(""); + return; + } + + if ( + selectedNativeCurrency && + validateFloatWithDecimals(text, selectedNativeCurrency.decimals) + ) { + setValue(text); + onChangeAmountAtomics( + Decimal.fromUserInput( + text.endsWith(".") ? text + "0" : text, + selectedNativeCurrency.decimals, + ).atomics, + ); + } + }; + + const onPressSelectableCurrency = (currency: CurrencyInfo) => { + setValue(""); + setSelectedCurrency(currency); + onChangeAmountAtomics(""); + onSelectCurrency(currency); + setDropdownState(false); + }; + + if (!selectedCurrencyNetwork) + return Invalid network; + if (!selectedNativeCurrency) + return ( + + Invalid native currency + + ); + return ( + setHovered(true)} + onHoverOut={() => setHovered(false)} + onPress={() => inputRef?.current?.focus()} + style={{ zIndex: 1 }} + > + {/*---- Label*/} + {label && ( + <> + + + {subtitle} + + {sublabel && sublabel} + + + )} + + + {/*---- Input*/} + + + + + + {/*---- Selected currency*/} + + + {/*---- Dropdown selectable currencies */} + {currencies?.length && isDropdownOpen && ( + + {currencies?.map((currencyInfo, index) => ( + + + onPressSelectableCurrency(currencyInfo)} + /> + + ))} + + )} + + + {error} + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/components/inputs/CurrencyInputLaunchpad/SelectableCurrencySmall.tsx b/packages/screens/Launchpad/LaunchpadApply/components/inputs/CurrencyInputLaunchpad/SelectableCurrencySmall.tsx new file mode 100644 index 0000000000..fecac28491 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/components/inputs/CurrencyInputLaunchpad/SelectableCurrencySmall.tsx @@ -0,0 +1,42 @@ +import { FC } from "react"; +import { TouchableOpacity, View } from "react-native"; + +import { BrandText } from "@/components/BrandText"; +import { CurrencyIcon } from "@/components/CurrencyIcon"; +import { CurrencyInfo, getNativeCurrency } from "@/networks"; +import { fontSemibold14 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; + +export const SelectableCurrencySmall: FC<{ + onPressItem: () => void; + currency: CurrencyInfo; + networkId: string; +}> = ({ onPressItem, currency, networkId }) => { + return ( + <> + + + + + + {getNativeCurrency(networkId, currency?.denom)?.displayName} + + + + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/components/inputs/CurrencyInputLaunchpad/SelectedCurrencySmall.tsx b/packages/screens/Launchpad/LaunchpadApply/components/inputs/CurrencyInputLaunchpad/SelectedCurrencySmall.tsx new file mode 100644 index 0000000000..0d655e1dbb --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/components/inputs/CurrencyInputLaunchpad/SelectedCurrencySmall.tsx @@ -0,0 +1,73 @@ +import { forwardRef } from "react"; +import { TouchableOpacity, View } from "react-native"; + +import chevronDownSVG from "@/assets/icons/chevron-down.svg"; +import chevronUpSVG from "@/assets/icons/chevron-up.svg"; +import { BrandText } from "@/components/BrandText"; +import { CurrencyIcon } from "@/components/CurrencyIcon"; +import { SVG } from "@/components/SVG"; +import { NativeCurrencyInfo } from "@/networks"; +import { secondaryColor } from "@/utils/style/colors"; +import { fontSemibold14 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; + +export const SelectedCurrencySmall = forwardRef< + View, + { + currency?: NativeCurrencyInfo; + selectedNetworkId: string; + isDropdownOpen: boolean; + setDropdownState: (val: boolean) => void; + disabled?: boolean; + } +>( + ( + { currency, selectedNetworkId, isDropdownOpen, setDropdownState, disabled }, + ref, + ) => { + return ( + + setDropdownState(!isDropdownOpen)} + style={{ + flexDirection: "row", + alignItems: "center", + }} + disabled={disabled} + > + + + + + {currency?.displayName || "ERROR"} + + {!disabled && ( + + )} + + + + + ); + }, +); diff --git a/packages/screens/Launchpad/LaunchpadApply/components/inputs/TextInputLaunchpad.tsx b/packages/screens/Launchpad/LaunchpadApply/components/inputs/TextInputLaunchpad.tsx new file mode 100644 index 0000000000..2a15418c34 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/components/inputs/TextInputLaunchpad.tsx @@ -0,0 +1,98 @@ +import { ReactElement, useRef, useState } from "react"; +import { + FieldValues, + Path, + useController, + UseFormReturn, +} from "react-hook-form"; +import { TextInput, TextInputProps, TextStyle } from "react-native"; + +import { ErrorText } from "@/components/ErrorText"; +import { TertiaryBox } from "@/components/boxes/TertiaryBox"; +import { CustomPressable } from "@/components/buttons/CustomPressable"; +import { Label } from "@/components/inputs/TextInputCustom"; +import { SpacerColumn } from "@/components/spacer"; +import { neutral22, neutral77, secondaryColor } from "@/utils/style/colors"; +import { fontSemibold14 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; + +interface TextInputLaunchpadProps + extends Omit { + label: string; + placeHolder: string; + form: UseFormReturn; + name: Path; + sublabel?: ReactElement; + valueModifier?: (value: string) => string; + required?: boolean; + disabled?: boolean; +} + +export const TextInputLaunchpad = ({ + form, + name, + label, + placeHolder, + sublabel, + valueModifier, + disabled, + required = true, + ...restProps +}: TextInputLaunchpadProps) => { + const inputRef = useRef(null); + const [hovered, setHovered] = useState(false); + const { fieldState, field } = useController({ + name, + control: form.control, + }); + return ( + setHovered(true)} + onHoverOut={() => setHovered(false)} + onPress={() => inputRef?.current?.focus()} + style={{ width: "100%", marginBottom: layout.spacing_x2 }} + disabled={disabled} + > + + {sublabel && sublabel} + + + + valueModifier + ? field.onChange(valueModifier(text)) + : field.onChange(text) + } + value={field.value || ""} + ref={inputRef} + editable={!disabled} + {...restProps} + /> + + + {fieldState.error?.message} + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/components/inputs/selectInputs/MultipleSelectInput.tsx b/packages/screens/Launchpad/LaunchpadApply/components/inputs/selectInputs/MultipleSelectInput.tsx new file mode 100644 index 0000000000..7059171bb3 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/components/inputs/selectInputs/MultipleSelectInput.tsx @@ -0,0 +1,167 @@ +import { FC, ReactElement, useState } from "react"; +import { TouchableOpacity, View, ViewStyle } from "react-native"; + +import chevronDownSVG from "@/assets/icons/chevron-down.svg"; +import chevronUpSVG from "@/assets/icons/chevron-up.svg"; +import { BrandText } from "@/components/BrandText"; +import { Checkbox } from "@/components/Checkbox"; +import { SVG } from "@/components/SVG"; +import { PrimaryBox } from "@/components/boxes/PrimaryBox"; +import { TertiaryBox } from "@/components/boxes/TertiaryBox"; +import { CustomPressable } from "@/components/buttons/CustomPressable"; +import { Label } from "@/components/inputs/TextInputCustom"; +import { Separator } from "@/components/separators/Separator"; +import { SpacerColumn } from "@/components/spacer"; +import { useDropdowns } from "@/hooks/useDropdowns"; +import { + neutral22, + neutral44, + neutral55, + neutral77, + secondaryColor, +} from "@/utils/style/colors"; +import { fontMedium14, fontSemibold14 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; + +interface Props { + style?: ViewStyle; + onDropdownClosed?: () => void; + dropdownOptions: string[]; + placeHolder?: string; + onPressItem: (item: string) => void; + items: string[]; + label: string; + sublabel?: ReactElement; + required?: boolean; +} + +export const MultipleSelectInput: FC = ({ + style, + dropdownOptions, + placeHolder, + items, + label, + onPressItem, + sublabel, + required = true, +}) => { + const [isDropdownOpen, setDropdownState, ref] = useDropdowns(); + const [hovered, setHovered] = useState(false); + + return ( + + setHovered(true)} + onHoverOut={() => setHovered(false)} + onPress={() => setDropdownState(!isDropdownOpen)} + > + + {sublabel && sublabel} + + + + + + 0 ? secondaryColor : neutral77, + }, + ]} + > + {items?.length > 0 ? items.join(", ") : placeHolder} + + + + + + {isDropdownOpen && ( + + {dropdownOptions.map((item, index) => ( + { + onPressItem(item); + }} + key={index} + style={{ + paddingTop: layout.spacing_x1_5, + width: "100%", + }} + > + + + + + {item} + + + {dropdownOptions.length - 1 !== index && ( + <> + + + + )} + + ))} + + )} + + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadApply/components/inputs/selectInputs/SelectInputLaunchpad.tsx b/packages/screens/Launchpad/LaunchpadApply/components/inputs/selectInputs/SelectInputLaunchpad.tsx new file mode 100644 index 0000000000..addeb120f2 --- /dev/null +++ b/packages/screens/Launchpad/LaunchpadApply/components/inputs/selectInputs/SelectInputLaunchpad.tsx @@ -0,0 +1,160 @@ +import { FC, useState } from "react"; +import { TouchableOpacity, View, ViewStyle } from "react-native"; + +import chevronDownSVG from "@/assets/icons/chevron-down.svg"; +import chevronUpSVG from "@/assets/icons/chevron-up.svg"; +import { BrandText } from "@/components/BrandText"; +import { SVG } from "@/components/SVG"; +import { PrimaryBox } from "@/components/boxes/PrimaryBox"; +import { TertiaryBox } from "@/components/boxes/TertiaryBox"; +import { CustomPressable } from "@/components/buttons/CustomPressable"; +import { Label } from "@/components/inputs/TextInputCustom"; +import { Separator } from "@/components/separators/Separator"; +import { SpacerColumn } from "@/components/spacer"; +import { useDropdowns } from "@/hooks/useDropdowns"; +import { + neutral22, + neutral44, + neutral55, + neutral77, + secondaryColor, +} from "@/utils/style/colors"; +import { fontMedium14, fontSemibold14 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; + +interface Props { + style?: ViewStyle; + onDropdownClosed?: () => void; + dropdownOptions: string[]; + placeHolder?: string; + onPressItem: (item: string) => void; + item?: string; + label: string; + required?: boolean; +} + +export const SelectInputLaunchpad: FC = ({ + style, + dropdownOptions, + placeHolder, + item, + label, + onPressItem, + required = true, +}) => { + const [isDropdownOpen, setDropdownState, ref] = useDropdowns(); + const [hovered, setHovered] = useState(false); + + return ( + + setHovered(true)} + onHoverOut={() => setHovered(false)} + onPress={() => setDropdownState(!isDropdownOpen)} + > + + + + + + + {item ? item : placeHolder} + + + + + + {isDropdownOpen && ( + + {dropdownOptions.map((item, index) => ( + { + setDropdownState(false); + onPressItem(item); + }} + key={index} + style={{ + paddingTop: layout.spacing_x1_5, + width: "100%", + }} + > + + {item} + + + {dropdownOptions.length - 1 !== index && ( + <> + + + + )} + + ))} + + )} + + + + ); +}; diff --git a/packages/screens/Launchpad/LaunchpadHome/LaunchpadScreen.tsx b/packages/screens/Launchpad/LaunchpadHome/LaunchpadScreen.tsx index 6337588388..09c1048c09 100644 --- a/packages/screens/Launchpad/LaunchpadHome/LaunchpadScreen.tsx +++ b/packages/screens/Launchpad/LaunchpadHome/LaunchpadScreen.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { View } from "react-native"; import { @@ -7,12 +6,14 @@ import { Sort, SortDirection, } from "@/api/marketplace/v1/marketplace"; +import { BrandText } from "@/components/BrandText"; import { ScreenContainer } from "@/components/ScreenContainer"; import { CollectionsCarouselHeader } from "@/components/carousels/CollectionsCarouselHeader"; import { CollectionGallery } from "@/components/collections/CollectionGallery"; import { useSelectedNetworkId } from "@/hooks/useSelectedNetwork"; import { getNetwork, NetworkFeature } from "@/networks"; import { ScreenFC } from "@/utils/navigation"; +import { fontSemibold20 } from "@/utils/style/fonts"; import { layout } from "@/utils/style/layout"; export const LaunchpadScreen: ScreenFC<"Launchpad"> = () => { @@ -21,6 +22,7 @@ export const LaunchpadScreen: ScreenFC<"Launchpad"> = () => { return ( Launchpad} > = ({ notFound, refetch: refetchCollectionInfo, } = useCollectionInfo(id); - const { setToastError } = useFeedbacks(); + const { setToast } = useFeedbacks(); const { showConnectWalletModal, showNotEnoughFundsModal } = useWalletControl(); const [viewWidth, setViewWidth] = useState(0); @@ -283,11 +282,13 @@ export const MintCollectionScreen: ScreenFC<"MintCollection"> = ({ }); return; } - setToastError(initialToastError); + setToast(initialToast); if (!wallet) { - setToastError({ + setToast({ title: "Error", - message: `no wallet`, + message: "no wallet", + type: "error", + mode: "normal", }); return; } @@ -299,9 +300,11 @@ export const MintCollectionScreen: ScreenFC<"MintCollection"> = ({ await ethereumMint(network, wallet); break; default: - setToastError({ + setToast({ title: "Error", message: `unsupported network ${network?.id}`, + type: "error", + mode: "normal", }); return; } @@ -311,9 +314,11 @@ export const MintCollectionScreen: ScreenFC<"MintCollection"> = ({ setMinted(false); } catch (e) { if (e instanceof Error) { - return setToastError({ + return setToast({ title: "Mint failed", message: prettyError(e), + type: "error", + mode: "normal", }); } console.error(e); @@ -326,7 +331,7 @@ export const MintCollectionScreen: ScreenFC<"MintCollection"> = ({ cosmosMint, ethereumMint, network, - setToastError, + setToast, wallet, showNotEnoughFundsModal, ]); @@ -441,7 +446,7 @@ export const MintCollectionScreen: ScreenFC<"MintCollection"> = ({ }} /> - + {/* Upper section */} = ({ )} - + {mintTermsConditionsURL && ( = ({ margin: layout.spacing_x2, }} > - + {info.image ? ( = ({ /> )} - + = ({ {info.mintStarted ? ( <> {info.mintPhases.map((phase, index) => { - return ; + return ; })} = ({ ); }; -const AttributesCard: React.FC<{ - style?: StyleProp; +const AttributesCard: FC<{ + style?: StyleProp; label: string; value: string; }> = ({ style, label, value }) => { return ( - {value} - + ); }; -const PresaleActivy: React.FC<{ +const PresaleActivity: FC<{ info: MintPhase; }> = ({ info }) => { const now = Long.fromNumber(Date.now() / 1000); @@ -902,7 +908,7 @@ const PresaleActivy: React.FC<{ ); }; -const PhaseCountdown: React.FC<{ +const PhaseCountdown: FC<{ onCountdownEnd?: () => void; startsAt?: number; }> = ({ onCountdownEnd, startsAt }) => { @@ -939,7 +945,7 @@ const PhaseCountdown: React.FC<{ ); }; -const PublicSaleActivity: React.FC<{ +const PublicSaleActivity: FC<{ started?: boolean; startsAt?: number; ended: boolean; @@ -970,7 +976,7 @@ const PublicSaleActivity: React.FC<{ ); }; -const MintNotStartedActivity: React.FC = () => { +const MintNotStartedActivity: FC = () => { return ( { } return true; }; + +export const validateFloatWithDecimals = (value: string, decimals: number) => { + const regexp = new RegExp( + `^([0-9]+[.]?[0-9]{0,${decimals}}|[.][0-9]{1,${decimals}})$`, + ); + return regexp.test(value); +}; diff --git a/packages/utils/ipfs.ts b/packages/utils/ipfs.ts index d61e633e33..92218156cc 100644 --- a/packages/utils/ipfs.ts +++ b/packages/utils/ipfs.ts @@ -34,6 +34,19 @@ const ipfsPathToWeb2URL = (path: string) => { return gatewayURL; }; +export const isIpfsPathValid = (path: string) => { + try { + path = path.substring("ipfs://".length); + const separatorIndex = path.indexOf("/"); + const cidString = + separatorIndex === -1 ? path : path.substring(0, separatorIndex); + const cid = CID.parse(cidString); + return !!cid.toV1().toString(); + } catch { + return false; + } +}; + /** Get the web2 url for a web3 uri or passthrough if not a web3 uri * Only supports ipfs for now */ diff --git a/packages/utils/navigation.ts b/packages/utils/navigation.ts index b13e114a2d..511938851e 100644 --- a/packages/utils/navigation.ts +++ b/packages/utils/navigation.ts @@ -33,6 +33,7 @@ export type RootStackParamList = { Launchpad: undefined; LaunchpadApply: undefined; + LaunchpadCreate: undefined; LaunchpadERC20: undefined; LaunchpadERC20Tokens?: { network?: string }; @@ -216,6 +217,7 @@ const getNavConfig: (homeScreen: keyof RootStackParamList) => NavConfig = ( // ==== Launchpad Launchpad: "launchpad", LaunchpadApply: "launchpad/apply", + LaunchpadCreate: "launchpad/create", // ==== Launchpad ERC20 LaunchpadERC20: "launchpad-erc20", diff --git a/packages/utils/regex.ts b/packages/utils/regex.ts index a21ab7a143..9291042809 100644 --- a/packages/utils/regex.ts +++ b/packages/utils/regex.ts @@ -6,3 +6,5 @@ export const HTML_TAG_REGEXP = /(<([^>]+)>)/gi; export const GIF_URL_REGEX = /https?:\/\/.*\.(gif)(\?.*)?$/; export const NUMBERS_REGEXP = /^\d+$/; export const LETTERS_REGEXP = /^[A-Za-z]+$/; +export const EMAIL_REGEXP = /^[\w-]+@([\w-]+\.)+[\w-]{2,4}$/; +export const NUMBERS_COMMA_SEPARATOR_REGEXP = /^\s*\d+(\s*,\s*\d+)*\s*$/; diff --git a/packages/utils/types/files.ts b/packages/utils/types/files.ts index d058a8da34..a2bd47f6cb 100644 --- a/packages/utils/types/files.ts +++ b/packages/utils/types/files.ts @@ -32,12 +32,18 @@ const ZodBaseFileData = z.object({ isThumbnailImage: z.boolean().optional(), base64Image: z.string().optional(), }); -type BaseFileData = z.infer; -export interface LocalFileData extends BaseFileData { - file: File; - thumbnailFileData?: BaseFileData & { file: File }; -} +export const ZodLocalFileData = z + .object({ + ...ZodBaseFileData.shape, + thumbnailFileData: ZodBaseFileData.extend({ + file: z.instanceof(File), + }).optional(), + }) + .extend({ + file: z.instanceof(File), + }); +export type LocalFileData = z.infer; export const ZodRemoteFileData = z.object({ ...ZodBaseFileData.shape, diff --git a/packages/utils/types/launchpad.ts b/packages/utils/types/launchpad.ts new file mode 100644 index 0000000000..57a88bd84f --- /dev/null +++ b/packages/utils/types/launchpad.ts @@ -0,0 +1,181 @@ +import { z } from "zod"; + +import { CollectionProject } from "@/contracts-clients/nft-launchpad"; +import { DEFAULT_FORM_ERRORS } from "@/utils/errors"; +import { isIpfsPathValid } from "@/utils/ipfs"; +import { + EMAIL_REGEXP, + LETTERS_REGEXP, + NUMBERS_REGEXP, + URL_REGEX, +} from "@/utils/regex"; +import { ZodLocalFileData } from "@/utils/types/files"; +const ZodCoin = z.object({ + amount: z + .string() + .trim() + .min(1, DEFAULT_FORM_ERRORS.required) + .refine( + (value) => !value || NUMBERS_REGEXP.test(value), + DEFAULT_FORM_ERRORS.onlyNumbers, + ) + .optional(), + denom: z.string().trim(), +}); + +export type Coin = z.infer; + +// ===== Shapes to build front objects +const ZodCollectionMintPeriodFormValues = z.object({ + price: ZodCoin, + maxTokens: z + .string() + .trim() + .refine( + (value) => !value || NUMBERS_REGEXP.test(value), + DEFAULT_FORM_ERRORS.onlyNumbers, + ) + .optional(), + perAddressLimit: z + .string() + .trim() + .refine( + (value) => !value || NUMBERS_REGEXP.test(value), + DEFAULT_FORM_ERRORS.onlyNumbers, + ) + .optional(), + startTime: z.number().min(1, DEFAULT_FORM_ERRORS.required), + endTime: z.number().optional(), + whitelistAddressesFile: ZodLocalFileData.optional(), + whitelistAddresses: z.array(z.string()).optional(), + isOpen: z.boolean(), +}); + +export const ZodCollectionAssetsAttributeFormValues = z.object({ + value: z.string().trim().min(1, DEFAULT_FORM_ERRORS.required), + type: z.string().trim().min(1, DEFAULT_FORM_ERRORS.required), +}); + +export const ZodCollectionAssetsMetadataFormValues = z.object({ + image: ZodLocalFileData, + externalUrl: z + .string() + .trim() + // We ignore the URL format control since externalUrl is optional + // .refine( + // (value) => !value || URL_REGEX.test(value), + // DEFAULT_FORM_ERRORS.onlyUrl, + // ) + .optional(), + description: z.string().trim().optional(), + name: z.string().trim().min(1, DEFAULT_FORM_ERRORS.required), + youtubeUrl: z.string().trim().optional(), + attributes: z.array(ZodCollectionAssetsAttributeFormValues), +}); + +export const ZodCollectionAssetsMetadatasFormValues = z.object({ + assetsMetadatas: z.array(ZodCollectionAssetsMetadataFormValues).optional(), + nftApiKey: z.string().trim().min(1, DEFAULT_FORM_ERRORS.required), +}); + +export const ZodCollectionFormValues = z.object({ + name: z.string().trim().min(1, DEFAULT_FORM_ERRORS.required), + description: z.string().trim().min(1, DEFAULT_FORM_ERRORS.required), + symbol: z + .string() + .trim() + .toUpperCase() + .min(1, DEFAULT_FORM_ERRORS.required) + .refine( + (value) => LETTERS_REGEXP.test(value), + DEFAULT_FORM_ERRORS.onlyLetters, + ), + websiteLink: z + .string() + .trim() + .min(1, DEFAULT_FORM_ERRORS.required) + .refine((value) => URL_REGEX.test(value), DEFAULT_FORM_ERRORS.onlyUrl), + email: z + .string() + .trim() + .min(1, DEFAULT_FORM_ERRORS.required) + .refine((value) => EMAIL_REGEXP.test(value), DEFAULT_FORM_ERRORS.onlyEmail), + projectTypes: z.array(z.string().trim()).min(1, DEFAULT_FORM_ERRORS.required), + revealTime: z.number().optional(), + teamDescription: z.string().trim().min(1, DEFAULT_FORM_ERRORS.required), + partnersDescription: z.string().trim().min(1, DEFAULT_FORM_ERRORS.required), + investDescription: z.string().trim().min(1, DEFAULT_FORM_ERRORS.required), + investLink: z.string().trim().min(1, DEFAULT_FORM_ERRORS.required), + artworkDescription: z.string().trim().min(1, DEFAULT_FORM_ERRORS.required), + coverImage: ZodLocalFileData, + isPreviouslyApplied: z.boolean(), + isDerivativeProject: z.boolean(), + isReadyForMint: z.boolean(), + isDox: z.boolean(), + escrowMintProceedsPeriod: z + .string() + .trim() + .min(1, DEFAULT_FORM_ERRORS.required), + daoWhitelistCount: z + .string() + .trim() + .min(1, DEFAULT_FORM_ERRORS.required) + .refine( + (value) => NUMBERS_REGEXP.test(value), + DEFAULT_FORM_ERRORS.onlyNumbers, + ), + mintPeriods: z.array(ZodCollectionMintPeriodFormValues).nonempty(), + royaltyAddress: z.string().trim().optional(), + royaltyPercentage: z + .string() + .trim() + .refine( + (value) => !value || NUMBERS_REGEXP.test(value), + DEFAULT_FORM_ERRORS.onlyNumbers, + ) + .optional(), + assetsMetadatas: ZodCollectionAssetsMetadatasFormValues.optional(), + baseTokenUri: z + .string() + .trim() + .refine( + (value) => !value || isIpfsPathValid(value), + DEFAULT_FORM_ERRORS.onlyIpfsUri, + ) + .optional(), + coverImageUri: z + .string() + .trim() + .refine( + (value) => !value || isIpfsPathValid(value), + DEFAULT_FORM_ERRORS.onlyIpfsUri, + ) + .optional(), +}); + +export type CollectionFormValues = z.infer; + +export type CollectionAssetsAttributeFormValues = z.infer< + typeof ZodCollectionAssetsAttributeFormValues +>; + +export type CollectionMintPeriodFormValues = z.infer< + typeof ZodCollectionMintPeriodFormValues +>; + +export type CollectionAssetsMetadataFormValues = z.infer< + typeof ZodCollectionAssetsMetadataFormValues +>; + +export type CollectionAssetsMetadatasFormValues = z.infer< + typeof ZodCollectionAssetsMetadatasFormValues +>; + +// ===== Shapes to build objects to sent to API +export type CollectionToSubmit = Omit< + CollectionProject, + "deployed_address" | "base_token_uri" | "owner" +>; + +// ===== Shapes to build objects received from API +// ...Coming soon diff --git a/yarn.lock b/yarn.lock index 2f0530e1f2..71c0810d8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14765,6 +14765,29 @@ __metadata: languageName: node linkType: hard +"keccak256@npm:^1.0.6": + version: 1.0.6 + resolution: "keccak256@npm:1.0.6" + dependencies: + bn.js: ^5.2.0 + buffer: ^6.0.3 + keccak: ^3.0.2 + checksum: decafb4b37adcfa6d06b6a5d28546d0d7a9f01ccf4b8cc8963cf8188fcc79a230d7e22988e860813623c602d764259734423e38fd7b9aadfeb409d6928a1d4cf + languageName: node + linkType: hard + +"keccak@npm:^3.0.2": + version: 3.0.4 + resolution: "keccak@npm:3.0.4" + dependencies: + node-addon-api: ^2.0.0 + node-gyp: latest + node-gyp-build: ^4.2.0 + readable-stream: ^3.6.0 + checksum: 2bf27b97b2f24225b1b44027de62be547f5c7326d87d249605665abd0c8c599d774671c35504c62c9b922cae02758504c6f76a73a84234d23af8a2211afaaa11 + languageName: node + linkType: hard + "keyv@npm:^4.0.0, keyv@npm:^4.5.3": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -16329,6 +16352,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^2.0.0": + version: 2.0.2 + resolution: "node-addon-api@npm:2.0.2" + dependencies: + node-gyp: latest + checksum: 31fb22d674648204f8dd94167eb5aac896c841b84a9210d614bf5d97c74ef059cc6326389cf0c54d2086e35312938401d4cc82e5fcd679202503eb8ac84814f8 + languageName: node + linkType: hard + "node-dir@npm:^0.1.17": version: 0.1.17 resolution: "node-dir@npm:0.1.17" @@ -16359,6 +16391,17 @@ __metadata: languageName: node linkType: hard +"node-gyp-build@npm:^4.2.0": + version: 4.8.4 + resolution: "node-gyp-build@npm:4.8.4" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 8b81ca8ffd5fa257ad8d067896d07908a36918bc84fb04647af09d92f58310def2d2b8614d8606d129d9cd9b48890a5d2bec18abe7fcff54818f72bedd3a7d74 + languageName: node + linkType: hard + "node-gyp-build@npm:^4.3.0": version: 4.8.0 resolution: "node-gyp-build@npm:4.8.0" @@ -20466,6 +20509,7 @@ __metadata: graphql-request: ^5 html-to-draftjs: ^1.5.0 immutable: ^4.0.0 + keccak256: ^1.0.6 kubernetes-models: ^4.3.1 leaflet: ^1.9.4 leaflet.markercluster: ^1.5.3