From 772b66242ee895a8b4ce61caef92225cb1837c42 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Mon, 7 Oct 2024 18:01:30 -0300 Subject: [PATCH 1/5] feat: Add programmatic selection for linked wearables --- .../CollectionTypeBadge.module.css | 16 + .../CollectionTypeBadge.tsx | 9 + .../Badges/CollectionTypeBadge/index.ts | 1 + .../ThirdPartyKindBadge.module.css | 16 + .../ThirdPartyKindBadge.tsx | 9 + .../Badges/ThirdPartyKindBadge/index.ts | 1 + .../CollectionRow/CollectionRow.tsx | 8 +- src/components/CurationPage/CurationPage.tsx | 1 + ...eateAndEditMultipleItemsModal.container.ts | 12 +- ...CreateAndEditMultipleItemsModal.module.css | 50 + .../CreateAndEditMultipleItemsModal.tsx | 862 ++++++++++-------- .../CreateAndEditMultipleItemsModal.types.ts | 24 +- .../PayPublicationFeeStep.tsx | 81 +- ...PublishWizardCollectionModal.container.tsx | 49 +- .../PublishWizardCollectionModal.tsx | 17 +- .../PublishWizardCollectionModal.types.ts | 13 +- .../ThirdPartyCollectionDetailPage.tsx | 17 +- src/lib/api/builder.ts | 8 +- src/modules/collection/types.ts | 1 + src/modules/thirdParty/actions.ts | 22 +- src/modules/thirdParty/reducer.spec.ts | 63 +- src/modules/thirdParty/reducer.ts | 31 +- src/modules/thirdParty/sagas.spec.ts | 291 ++++-- src/modules/thirdParty/sagas.ts | 35 +- src/modules/thirdParty/selectors.spec.ts | 40 +- src/modules/thirdParty/selectors.ts | 4 +- src/modules/thirdParty/types.ts | 2 +- src/modules/thirdParty/utils.spec.ts | 5 +- src/modules/thirdParty/utils.ts | 5 +- src/modules/translation/languages/en.json | 30 +- src/modules/translation/languages/es.json | 32 +- src/modules/translation/languages/zh.json | 30 +- 32 files changed, 1221 insertions(+), 564 deletions(-) create mode 100644 src/components/Badges/CollectionTypeBadge/CollectionTypeBadge.module.css create mode 100644 src/components/Badges/CollectionTypeBadge/CollectionTypeBadge.tsx create mode 100644 src/components/Badges/CollectionTypeBadge/index.ts create mode 100644 src/components/Badges/ThirdPartyKindBadge/ThirdPartyKindBadge.module.css create mode 100644 src/components/Badges/ThirdPartyKindBadge/ThirdPartyKindBadge.tsx create mode 100644 src/components/Badges/ThirdPartyKindBadge/index.ts diff --git a/src/components/Badges/CollectionTypeBadge/CollectionTypeBadge.module.css b/src/components/Badges/CollectionTypeBadge/CollectionTypeBadge.module.css new file mode 100644 index 000000000..e24f9af6f --- /dev/null +++ b/src/components/Badges/CollectionTypeBadge/CollectionTypeBadge.module.css @@ -0,0 +1,16 @@ +.badge { + padding: 4px 8px 4px 8px; + border-radius: 32px; + width: fit-content; + text-wrap: nowrap; + text-transform: uppercase; + font-weight: 600; +} + +.thirdParty { + background-color: #00a146; +} + +.regular { + background-color: #1764c0; +} diff --git a/src/components/Badges/CollectionTypeBadge/CollectionTypeBadge.tsx b/src/components/Badges/CollectionTypeBadge/CollectionTypeBadge.tsx new file mode 100644 index 000000000..b62301fa8 --- /dev/null +++ b/src/components/Badges/CollectionTypeBadge/CollectionTypeBadge.tsx @@ -0,0 +1,9 @@ +import classNames from 'classnames' +import { t } from 'decentraland-dapps/dist/modules/translation' +import styles from './CollectionTypeBadge.module.css' + +export const CollectionTypeBadge = ({ isThirdParty, className }: { isThirdParty?: boolean; className?: string }) => ( +
+ {isThirdParty ? t('collection_type_badge.third_party') : t('collection_type_badge.regular')} +
+) diff --git a/src/components/Badges/CollectionTypeBadge/index.ts b/src/components/Badges/CollectionTypeBadge/index.ts new file mode 100644 index 000000000..4cc896dc0 --- /dev/null +++ b/src/components/Badges/CollectionTypeBadge/index.ts @@ -0,0 +1 @@ +export * from './CollectionTypeBadge' diff --git a/src/components/Badges/ThirdPartyKindBadge/ThirdPartyKindBadge.module.css b/src/components/Badges/ThirdPartyKindBadge/ThirdPartyKindBadge.module.css new file mode 100644 index 000000000..8d8469b0b --- /dev/null +++ b/src/components/Badges/ThirdPartyKindBadge/ThirdPartyKindBadge.module.css @@ -0,0 +1,16 @@ +.badge { + padding: 4px 8px 4px 8px; + border-radius: 32px; + width: fit-content; + text-wrap: nowrap; + text-transform: uppercase; + font-weight: 600; +} + +.programmatic { + background-color: #691fa9; +} + +.standard { + background-color: #43404a; +} diff --git a/src/components/Badges/ThirdPartyKindBadge/ThirdPartyKindBadge.tsx b/src/components/Badges/ThirdPartyKindBadge/ThirdPartyKindBadge.tsx new file mode 100644 index 000000000..d600608d9 --- /dev/null +++ b/src/components/Badges/ThirdPartyKindBadge/ThirdPartyKindBadge.tsx @@ -0,0 +1,9 @@ +import classNames from 'classnames' +import { t } from 'decentraland-dapps/dist/modules/translation' +import styles from './ThirdPartyKindBadge.module.css' + +export const ThirdPartyKindBadge = ({ isProgrammatic, className }: { isProgrammatic?: boolean; className?: string }) => ( +
+ {isProgrammatic ? t('third_party_kind_badge.programmatic') : t('third_party_kind_badge.standard')} +
+) diff --git a/src/components/Badges/ThirdPartyKindBadge/index.ts b/src/components/Badges/ThirdPartyKindBadge/index.ts new file mode 100644 index 000000000..75b2f4924 --- /dev/null +++ b/src/components/Badges/ThirdPartyKindBadge/index.ts @@ -0,0 +1 @@ +export * from './ThirdPartyKindBadge' diff --git a/src/components/CurationPage/CollectionRow/CollectionRow.tsx b/src/components/CurationPage/CollectionRow/CollectionRow.tsx index 63da34b93..33dd77f26 100644 --- a/src/components/CurationPage/CollectionRow/CollectionRow.tsx +++ b/src/components/CurationPage/CollectionRow/CollectionRow.tsx @@ -9,11 +9,12 @@ import { isThirdPartyCollection } from 'modules/collection/utils' import { CurationStatus } from 'modules/curations/types' import CollectionStatus from 'components/CollectionStatus' import CollectionImage from 'components/CollectionImage' +import { CollectionTypeBadge } from 'components/Badges/CollectionTypeBadge' +import { ThirdPartyKindBadge } from 'components/Badges/ThirdPartyKindBadge' import { AssignModalOperationType } from 'components/Modals/EditCurationAssigneeModal/EditCurationAssigneeModal.types' import Profile from 'components/Profile' import { formatDistanceToNow } from 'lib/date' import { Props } from './CollectionRow.types' - import './CollectionRow.css' export default function CollectionRow(props: Props) { @@ -91,7 +92,10 @@ export default function CollectionRow(props: Props) { -
{isThirdPartyCollection(collection) ? t('collection_row.type_third_party') : t('collection_row.type_standard')}
+ +
+ +
{isThirdPartyCollection(collection) ? '-' : }
diff --git a/src/components/CurationPage/CurationPage.tsx b/src/components/CurationPage/CurationPage.tsx index b59a07f3a..b7717a2d1 100644 --- a/src/components/CurationPage/CurationPage.tsx +++ b/src/components/CurationPage/CurationPage.tsx @@ -274,6 +274,7 @@ export default class CurationPage extends React.PureComponent { {t('collection_row.collection')} {t('collection_row.type')} + {t('collection_row.kind')} {t('collection_row.owner')} {t('collection_row.date')} {t('collection_row.status')} diff --git a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.container.ts b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.container.ts index 59b5a7565..ffe8b7259 100644 --- a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.container.ts +++ b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.container.ts @@ -11,26 +11,34 @@ import { import { getError } from 'modules/item/selectors' import { Collection } from 'modules/collection/types' import { getCollection } from 'modules/collection/selectors' -import { getIsLinkedWearablesV2Enabled } from 'modules/features/selectors' +import { getIsLinkedWearablesPaymentsEnabled, getIsLinkedWearablesV2Enabled } from 'modules/features/selectors' +import { setThirdPartyKindRequest } from 'modules/thirdParty/actions' +import { getCollectionThirdParty, isSettingThirdPartyType } from 'modules/thirdParty/selectors' +import { isTPCollection } from 'modules/collection/utils' import { MapStateProps, MapDispatchProps, MapDispatch, OwnProps } from './CreateAndEditMultipleItemsModal.types' -import CreateAndEditMultipleItemsModal from './CreateAndEditMultipleItemsModal' +import { CreateAndEditMultipleItemsModal } from './CreateAndEditMultipleItemsModal' const mapState = (state: RootState, ownProps: OwnProps): MapStateProps => { const collection: Collection | null = ownProps.metadata.collectionId ? getCollection(state, ownProps.metadata.collectionId) : null + const thirdParty = collection && isTPCollection(collection) ? getCollectionThirdParty(state, collection) : null return { collection, + thirdParty, + isSettingThirdPartyType: isSettingThirdPartyType(state), error: getError(state), savedItemsFiles: getSavedItemsFiles(state), notSavedItemsFiles: getNotSavedItemsFiles(state), cancelledItemsFiles: getCanceledItemsFiles(state), saveMultipleItemsState: getMultipleItemsSaveState(state), isLinkedWearablesV2Enabled: getIsLinkedWearablesV2Enabled(state), + isLinkedWearablesPaymentsEnabled: getIsLinkedWearablesPaymentsEnabled(state), saveItemsProgress: getProgress(state) } } const mapDispatch = (dispatch: MapDispatch): MapDispatchProps => ({ + onSetThirdPartyType: (thirdPartyId: string, isProgrammatic: boolean) => dispatch(setThirdPartyKindRequest(thirdPartyId, isProgrammatic)), onSaveMultipleItems: builtItems => dispatch(saveMultipleItemsRequest(builtItems)), onCancelSaveMultipleItems: () => dispatch(cancelSaveMultipleItems()), onModalUnmount: () => dispatch(clearSaveMultipleItems()) diff --git a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.module.css b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.module.css index 76928a53c..1a60ab19d 100644 --- a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.module.css +++ b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.module.css @@ -92,3 +92,53 @@ .saveItemsError { color: var(--primary); } + +/* Third party type selector */ + +.selector { + display: flex; + flex-direction: column; + gap: 16px; +} + +.item { + background-color: #43404a; + padding: 24px; + border-radius: 8px; + display: flex; + gap: 24px; + flex-direction: row; +} + +.item img { + width: 225px; + height: 120px; +} + +.description { + display: flex; + flex-direction: column; + gap: 16px; +} + +.title { + font-size: 20px; + font-weight: 700; + margin-bottom: 6px; +} + +.subtitle { + font-size: 14px; +} + +.action { + display: flex; + justify-content: end; +} + +.loader { + display: flex; + align-items: center; + justify-content: center; + min-height: 421px; +} diff --git a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.tsx b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.tsx index 9f86b4fd8..799f84a7d 100644 --- a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.tsx +++ b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.tsx @@ -1,4 +1,5 @@ -import * as React from 'react' +import { useState, FC, useMemo, useCallback, useEffect } from 'react' +import { ethers } from 'ethers' import PQueue from 'p-queue' import { ItemFactory, @@ -14,7 +15,7 @@ import { MAX_EMOTE_FILE_SIZE } from '@dcl/builder-client' import Dropzone, { DropzoneState } from 'react-dropzone' -import { Button, Icon, Message, ModalNavigation, Progress, Table } from 'decentraland-ui' +import { Button, Icon, Loader, Message, ModalNavigation, Progress, Table } from 'decentraland-ui' import { isErrorWithMessage } from 'decentraland-dapps/dist/lib/error' import Modal from 'decentraland-dapps/dist/containers/Modal' import { getAnalytics } from 'decentraland-dapps/dist/modules/analytics/utils' @@ -39,413 +40,427 @@ import { generateCatalystImage, getModelPath } from 'modules/item/utils' import { ThumbnailFileTooBigError } from 'modules/item/errors' import ItemImport from 'components/ItemImport' import { InfoIcon } from 'components/InfoIcon' +// These images are place-holders for the real ones +import collectionsImage from '../../../images/collections.png' +import linkedCollectionsImage from '../../../images/linked-collections.png' +import { useThirdPartyPrice } from '../PublishWizardCollectionModal/hooks' import { CreateOrEditMultipleItemsModalType, ImportedFile, ImportedFileType, ItemCreationView, Props, - RejectedFile, - State + RejectedFile } from './CreateAndEditMultipleItemsModal.types' import styles from './CreateAndEditMultipleItemsModal.module.css' const WEARABLES_ZIP_INFRA_URL = config.get('WEARABLES_ZIP_INFRA_URL', '') const AMOUNT_OF_FILES_TO_PROCESS_SIMULTANEOUSLY = 4 -export default class CreateAndEditMultipleItemsModal extends React.PureComponent { - analytics = getAnalytics() - state = { - view: ItemCreationView.IMPORT, - loadingFilesProgress: 0, - importedFiles: {} as Record> - } - - private isViewClosable = (): boolean => { - const { view } = this.state - return view === ItemCreationView.IMPORTING || view === ItemCreationView.COMPLETED - } - - private getValidFiles = (): BuiltFile[] => { - const { importedFiles } = this.state - return Object.values(importedFiles).filter(file => file.type === ImportedFileType.ACCEPTED) as BuiltFile[] - } - - private getRejectedFiles = (): RejectedFile[] => { - const { importedFiles } = this.state - return Object.values(importedFiles).filter(file => file.type === ImportedFileType.REJECTED) as RejectedFile[] - } - - static getDerivedStateFromProps(props: Props, state: State): State | null { - const isCancelled = props.saveMultipleItemsState === MultipleItemsSaveState.CANCELLED +export const CreateAndEditMultipleItemsModal: FC = (props: Props) => { + const { + thirdParty, + collection, + metadata, + isLinkedWearablesV2Enabled, + isLinkedWearablesPaymentsEnabled, + saveMultipleItemsState, + savedItemsFiles, + notSavedItemsFiles, + cancelledItemsFiles, + saveItemsProgress, + error, + onCancelSaveMultipleItems, + onModalUnmount, + onSaveMultipleItems, + onSetThirdPartyType, + onClose + } = props + const analytics = getAnalytics() + const [view, setView] = useState(ItemCreationView.IMPORT) + const [loadingFilesProgress, setLoadingFilesProgress] = useState(0) + const [importedFiles, setImportedFiles] = useState>>({}) + const [finishedState, setFinishedState] = useState<{ + state: MultipleItemsSaveState + savedFiles: string[] + notSavedFiles: string[] + cancelledFiles: string[] + }>() + const { thirdPartyPrice, isFetchingPrice, fetchThirdPartyPrice } = useThirdPartyPrice() + + const isViewClosable = useMemo(() => view === ItemCreationView.IMPORTING || view === ItemCreationView.COMPLETED, []) + const validFiles = useMemo( + () => Object.values(importedFiles).filter(file => file.type === ImportedFileType.ACCEPTED) as BuiltFile[], + [importedFiles] + ) + const rejectedFiles = useMemo( + () => Object.values(importedFiles).filter(file => file.type === ImportedFileType.REJECTED) as RejectedFile[], + [importedFiles] + ) + const isCreating = useMemo( + () => (metadata.type ?? CreateOrEditMultipleItemsModalType.CREATE) === CreateOrEditMultipleItemsModalType.CREATE, + [metadata.type] + ) + const operationTypeKey = useMemo(() => (isCreating ? 'create' : 'edit'), [isCreating]) + const modalTitle = useMemo(() => t(`create_and_edit_multiple_items_modal.${operationTypeKey}.title`), [operationTypeKey, t]) + const modalSubtitle = useMemo(() => (isCreating ? null : t('create_and_edit_multiple_items_modal.edit.subtitle')), [isCreating, t]) + useEffect(() => { + const isCancelled = saveMultipleItemsState === MultipleItemsSaveState.CANCELLED const hasFinished = - props.saveMultipleItemsState === MultipleItemsSaveState.FINISHED_SUCCESSFULLY || - props.saveMultipleItemsState === MultipleItemsSaveState.FINISHED_UNSUCCESSFULLY + saveMultipleItemsState === MultipleItemsSaveState.FINISHED_SUCCESSFULLY || + saveMultipleItemsState === MultipleItemsSaveState.FINISHED_UNSUCCESSFULLY + if (view !== ItemCreationView.COMPLETED && (isCancelled || hasFinished)) { + setFinishedState({ + state: saveMultipleItemsState, + savedFiles: savedItemsFiles, + notSavedFiles: notSavedItemsFiles, + cancelledFiles: cancelledItemsFiles + }) + setView(ItemCreationView.COMPLETED) + } - if (state.view !== ItemCreationView.COMPLETED && (isCancelled || hasFinished)) { - return { ...state, view: ItemCreationView.COMPLETED } + return () => { + onModalUnmount() } + }, [saveMultipleItemsState, savedItemsFiles, notSavedItemsFiles, cancelledItemsFiles, view, onModalUnmount]) - return null - } + useEffect(() => { + if (isLinkedWearablesPaymentsEnabled && view === ItemCreationView.THIRD_PARTY_KIND_SELECTOR && !thirdPartyPrice && !isFetchingPrice) { + return fetchThirdPartyPrice() as unknown as void + } + }, [view, isLinkedWearablesPaymentsEnabled, thirdPartyPrice, isFetchingPrice, fetchThirdPartyPrice]) - componentWillUnmount(): void { - const { onModalUnmount } = this.props - onModalUnmount() - } + const handleRejectedFiles = useCallback((rejectedFiles: File[]): void => { + setImportedFiles(prev => ({ + ...prev, + ...rejectedFiles.reduce((accum, file) => { + accum[file.name] = { + type: ImportedFileType.REJECTED, + fileName: file.name, + reason: t('create_and_edit_multiple_items_modal.wrong_file_extension') + } + return accum + }, {} as Record>) + })) - private handleRejectedFiles = (rejectedFiles: File[]): void => { - this.setState({ - importedFiles: { - ...this.state.importedFiles, - ...rejectedFiles.reduce((accum, file) => { - accum[file.name] = { - type: ImportedFileType.REJECTED, - fileName: file.name, - reason: t('create_and_edit_multiple_items_modal.wrong_file_extension') - } - return accum - }, {} as Record>) - } - }) + setView(ItemCreationView.REVIEW) + }, []) - this.setState({ - view: ItemCreationView.REVIEW - }) - } - - processAcceptedFile = async (file: File) => { - const { collection, metadata, isLinkedWearablesV2Enabled } = this.props - try { - const fileArrayBuffer = await file.arrayBuffer() - const loadedFile = await loadFile(file.name, new Blob([new Uint8Array(fileArrayBuffer)])) - // Multiple files must contain an asset file - if (!loadedFile.wearable) { - throw new Error(t('create_and_edit_multiple_items_modal.wearable_file_not_found')) + const handleSetThirdPartyType = useCallback( + (type: boolean) => { + if (thirdParty && thirdParty.isProgrammatic !== type) { + onSetThirdPartyType(thirdParty.id, type) + } else { + onClose() } + }, + [thirdParty, onSetThirdPartyType, onClose] + ) + + const processAcceptedFile = useCallback( + async (file: File) => { + try { + const fileArrayBuffer = await file.arrayBuffer() + const loadedFile = await loadFile(file.name, new Blob([new Uint8Array(fileArrayBuffer)])) + // Multiple files must contain an asset file + if (!loadedFile.wearable) { + throw new Error(t('create_and_edit_multiple_items_modal.wearable_file_not_found')) + } + + const itemFactory = new ItemFactory().fromConfig(loadedFile.wearable, loadedFile.content) + + let thumbnail: Blob | null = loadedFile.content[THUMBNAIL_PATH] - const itemFactory = new ItemFactory().fromConfig(loadedFile.wearable, loadedFile.content) - - let thumbnail: Blob | null = loadedFile.content[THUMBNAIL_PATH] - - if (!thumbnail) { - const modelPath = getModelPath(loadedFile.wearable.data.representations) - const url = URL.createObjectURL(loadedFile.content[modelPath]) - const data = await getModelData(url, { - width: 1024, - height: 1024, - extension: getExtension(modelPath) || undefined, - engine: EngineType.BABYLON - }) - URL.revokeObjectURL(url) - thumbnail = dataURLToBlob(data.image) if (!thumbnail) { - throw new Error(t('create_and_edit_multiple_items_modal.thumbnail_file_not_generated')) - } - } else { - const thumbnailImageType = await getImageType(thumbnail) - if (thumbnailImageType !== ImageType.PNG) { - throw new Error(t('create_and_edit_multiple_items_modal.wrong_thumbnail_format')) + const modelPath = getModelPath(loadedFile.wearable.data.representations) + const url = URL.createObjectURL(loadedFile.content[modelPath]) + const data = await getModelData(url, { + width: 1024, + height: 1024, + extension: getExtension(modelPath) || undefined, + engine: EngineType.BABYLON + }) + URL.revokeObjectURL(url) + thumbnail = dataURLToBlob(data.image) + if (!thumbnail) { + throw new Error(t('create_and_edit_multiple_items_modal.thumbnail_file_not_generated')) + } + } else { + const thumbnailImageType = await getImageType(thumbnail) + if (thumbnailImageType !== ImageType.PNG) { + throw new Error(t('create_and_edit_multiple_items_modal.wrong_thumbnail_format')) + } } - } - // Process the thumbnail so it fits our requirements - thumbnail = dataURLToBlob(await convertImageIntoWearableThumbnail(thumbnail)) + // Process the thumbnail so it fits our requirements + thumbnail = dataURLToBlob(await convertImageIntoWearableThumbnail(thumbnail)) - if (!thumbnail) { - throw new Error(t('create_and_edit_multiple_items_modal.thumbnail_file_not_generated')) - } - - if (thumbnail.size > MAX_THUMBNAIL_FILE_SIZE) { - throw new ThumbnailFileTooBigError() - } + if (!thumbnail) { + throw new Error(t('create_and_edit_multiple_items_modal.thumbnail_file_not_generated')) + } - itemFactory.withThumbnail(thumbnail) + if (thumbnail.size > MAX_THUMBNAIL_FILE_SIZE) { + throw new ThumbnailFileTooBigError() + } - // Set the UNIQUE rarity so all items have this rarity as default although TP items don't require rarity - itemFactory.withRarity(Rarity.UNIQUE) + itemFactory.withThumbnail(thumbnail) - // Override collection id if specified in the modal's metadata - if (metadata.collectionId) { - itemFactory.withCollectionId(metadata.collectionId) - } + // Set the UNIQUE rarity so all items have this rarity as default although TP items don't require rarity + itemFactory.withRarity(Rarity.UNIQUE) - // Generate or set the correct URN for the items taking into consideration the selected collection - const decodedCollectionUrn: DecodedURN | null = collection?.urn ? decodeURN(collection.urn) : null - // Check if the collection is a third party collection - if (decodedCollectionUrn && isThirdPartyCollectionDecodedUrn(decodedCollectionUrn)) { - const decodedUrn: DecodedURN | null = loadedFile.wearable.id ? decodeURN(loadedFile.wearable.id) : null - const thirdPartyTokenId = - loadedFile.wearable.id && decodedUrn && decodedUrn.type === URNType.COLLECTIONS_THIRDPARTY - ? decodedUrn.thirdPartyTokenId ?? null - : null - - // Check if the decoded collections match a the collection level - if (decodedUrn && !decodedCollectionsUrnAreEqual(decodedCollectionUrn, decodedUrn)) { - throw new Error(t('create_and_edit_multiple_items_modal.invalid_urn')) + // Override collection id if specified in the modal's metadata + if (metadata.collectionId) { + itemFactory.withCollectionId(metadata.collectionId) } - // In case the collection is linked to a smart contract, the mappings must be present - if (isLinkedWearablesV2Enabled && collection?.linkedContractAddress && collection.linkedContractNetwork) { - if (!loadedFile.wearable.mapping) { - throw new Error(t('create_and_edit_multiple_items_modal.missing_mapping')) + // Generate or set the correct URN for the items taking into consideration the selected collection + const decodedCollectionUrn: DecodedURN | null = collection?.urn ? decodeURN(collection.urn) : null + // Check if the collection is a third party collection + if (decodedCollectionUrn && isThirdPartyCollectionDecodedUrn(decodedCollectionUrn)) { + const decodedUrn: DecodedURN | null = loadedFile.wearable.id ? decodeURN(loadedFile.wearable.id) : null + const thirdPartyTokenId = + loadedFile.wearable.id && decodedUrn && decodedUrn.type === URNType.COLLECTIONS_THIRDPARTY + ? decodedUrn.thirdPartyTokenId ?? null + : null + + // Check if the decoded collections match a the collection level + if (decodedUrn && !decodedCollectionsUrnAreEqual(decodedCollectionUrn, decodedUrn)) { + throw new Error(t('create_and_edit_multiple_items_modal.invalid_urn')) } - // Build the mapping with the linked contract address and network - itemFactory.withMappings({ - [collection.linkedContractNetwork]: { - [collection.linkedContractAddress]: [loadedFile.wearable.mapping] + + // In case the collection is linked to a smart contract, the mappings must be present + if (isLinkedWearablesV2Enabled && collection?.linkedContractAddress && collection.linkedContractNetwork) { + if (!loadedFile.wearable.mapping) { + throw new Error(t('create_and_edit_multiple_items_modal.missing_mapping')) } - }) - } + // Build the mapping with the linked contract address and network + itemFactory.withMappings({ + [collection.linkedContractNetwork]: { + [collection.linkedContractAddress]: [loadedFile.wearable.mapping] + } + }) + } - // Build the third party item URN in accordance ot the collection URN - if (isThirdPartyCollectionDecodedUrn(decodedCollectionUrn)) { - itemFactory.withUrn( - buildThirdPartyURN( - decodedCollectionUrn.thirdPartyName, - decodedCollectionUrn.thirdPartyCollectionId, - thirdPartyTokenId ?? getDefaultThirdPartyUrnSuffix(loadedFile.wearable.name) + // Build the third party item URN in accordance ot the collection URN + if (isThirdPartyCollectionDecodedUrn(decodedCollectionUrn)) { + itemFactory.withUrn( + buildThirdPartyURN( + decodedCollectionUrn.thirdPartyName, + decodedCollectionUrn.thirdPartyCollectionId, + thirdPartyTokenId ?? getDefaultThirdPartyUrnSuffix(loadedFile.wearable.name) + ) ) - ) + } } - } - const builtItem = await itemFactory.build() - if (!this.isCreating()) { - const { id: _id, ...itemWithoutId } = builtItem.item - builtItem.item = itemWithoutId as LocalItem - } + const builtItem = await itemFactory.build() + if (!isCreating) { + const { id: _id, ...itemWithoutId } = builtItem.item + builtItem.item = itemWithoutId as LocalItem + } - // Generate catalyst image as part of the item - const catalystImage = await generateCatalystImage(builtItem.item, { thumbnail: builtItem.newContent[THUMBNAIL_PATH] }) - builtItem.newContent[IMAGE_PATH] = catalystImage.content - builtItem.item.contents[IMAGE_PATH] = catalystImage.hash + // Generate catalyst image as part of the item + const catalystImage = await generateCatalystImage(builtItem.item, { thumbnail: builtItem.newContent[THUMBNAIL_PATH] }) + builtItem.newContent[IMAGE_PATH] = catalystImage.content + builtItem.item.contents[IMAGE_PATH] = catalystImage.hash - return { type: ImportedFileType.ACCEPTED, ...builtItem, fileName: file.name } - } catch (error) { - if (!(error instanceof FileTooBigErrorBuilderClient)) { - return { - type: ImportedFileType.REJECTED, - fileName: file.name, - reason: isErrorWithMessage(error) ? error.message : 'Unknown error' + return { type: ImportedFileType.ACCEPTED, ...builtItem, fileName: file.name } + } catch (error) { + if (!(error instanceof FileTooBigErrorBuilderClient)) { + return { + type: ImportedFileType.REJECTED, + fileName: file.name, + reason: isErrorWithMessage(error) ? error.message : 'Unknown error' + } } - } - - const fileName = error.geFileName() - const maxSize = error.getMaxFileSize() - const type = error.getType() - let reason: string = '' - - if (type === FileType.THUMBNAIL) { - reason = t('create_single_item_modal.error.thumbnail_file_too_big', { - maxSize: `${toMB(maxSize)}MB` - }) - } else if (type === FileType.WEARABLE) { - reason = t('create_and_edit_multiple_items_modal.max_file_size', { - name: fileName, - max: `${toMB(MAX_WEARABLE_FILE_SIZE)}` - }) - } else if (type === FileType.SKIN) { - reason = t('create_and_edit_multiple_items_modal.max_file_size_skin', { - name: fileName, - max: `${toMB(MAX_SKIN_FILE_SIZE)}` - }) - } else if (type === FileType.EMOTE) { - reason = t('create_and_edit_multiple_items_modal.max_file_size_emote', { - name: fileName, - max: `${toMB(MAX_EMOTE_FILE_SIZE)}` - }) - } - return { - type: ImportedFileType.REJECTED, - fileName, - reason: reason - } - } - } + const fileName = error.geFileName() + const maxSize = error.getMaxFileSize() + const type = error.getType() + let reason: string = '' - private handleFilesImport = async (acceptedFiles: File[]): Promise => { - this.setState({ - view: ItemCreationView.IMPORTING - }) + if (type === FileType.THUMBNAIL) { + reason = t('create_single_item_modal.error.thumbnail_file_too_big', { + maxSize: `${toMB(maxSize)}MB` + }) + } else if (type === FileType.WEARABLE) { + reason = t('create_and_edit_multiple_items_modal.max_file_size', { + name: fileName, + max: `${toMB(MAX_WEARABLE_FILE_SIZE)}` + }) + } else if (type === FileType.SKIN) { + reason = t('create_and_edit_multiple_items_modal.max_file_size_skin', { + name: fileName, + max: `${toMB(MAX_SKIN_FILE_SIZE)}` + }) + } else if (type === FileType.EMOTE) { + reason = t('create_and_edit_multiple_items_modal.max_file_size_emote', { + name: fileName, + max: `${toMB(MAX_EMOTE_FILE_SIZE)}` + }) + } - const queue = new PQueue({ concurrency: AMOUNT_OF_FILES_TO_PROCESS_SIMULTANEOUSLY }) - queue.on('next', () => { - this.setState({ - loadingFilesProgress: Math.round(((acceptedFiles.length - (queue.size + queue.pending)) * 100) / acceptedFiles.length) + return { + type: ImportedFileType.REJECTED, + fileName, + reason: reason + } + } + }, + [collection, metadata, isLinkedWearablesV2Enabled, isCreating] + ) + + const handleFilesImport = useCallback( + async (acceptedFiles: File[]) => { + setView(ItemCreationView.IMPORTING) + const queue = new PQueue({ concurrency: AMOUNT_OF_FILES_TO_PROCESS_SIMULTANEOUSLY }) + queue.on('next', () => { + setLoadingFilesProgress(Math.round(((acceptedFiles.length - (queue.size + queue.pending)) * 100) / acceptedFiles.length)) }) - }) - const promisesToProcess = acceptedFiles.map(file => () => this.processAcceptedFile(file)) - const importedFiles: ImportedFile[] = await queue.addAll(promisesToProcess) - this.setState({ - importedFiles: { - ...this.state.importedFiles, + const promisesToProcess = acceptedFiles.map(file => () => processAcceptedFile(file)) + const importedFiles: ImportedFile[] = await queue.addAll(promisesToProcess) + setImportedFiles(prev => ({ + ...prev, ...importedFiles.reduce((accum, file) => { accum[file.fileName] = file return accum }, {} as Record>) - }, - view: ItemCreationView.REVIEW - }) - } - - private handleFilesUpload = (): void => { - const { collection, onSaveMultipleItems } = this.props - const files = this.getValidFiles() - onSaveMultipleItems(files) - this.setState({ - view: ItemCreationView.UPLOADING - }) - this.analytics.track(`${this.isCreating() ? 'Create' : 'Edit'} TP Items`, { - items: files.map(file => file.item.id), + })) + setView(ItemCreationView.REVIEW) + }, + [processAcceptedFile] + ) + + const handleFilesUpload = useCallback((): void => { + onSaveMultipleItems(validFiles) + setView(ItemCreationView.UPLOADING) + analytics.track(`${isCreating ? 'Create' : 'Edit'} TP Items`, { + items: validFiles.map(file => file.item.id), collectionId: collection?.id }) - } - - private onRejectedFilesClear = (): void => { - this.setState({ - importedFiles: { - ...Object.entries(this.state.importedFiles) - .filter(entry => entry[1].type !== ImportedFileType.REJECTED) - .reduce((accum, entry) => { - accum[entry[0]] = entry[1] - return accum - }, {} as Record>) - } + }, [validFiles, collection, isCreating, onSaveMultipleItems]) + + const onRejectedFilesClear = useCallback((): void => { + setImportedFiles({ + ...Object.entries(importedFiles) + .filter(entry => entry[1].type !== ImportedFileType.REJECTED) + .reduce((accum, entry) => { + accum[entry[0]] = entry[1] + return accum + }, {} as Record>) }) - } - - private renderDropZone = (props: DropzoneState) => { - // TODO: Upgrade react-dropzone to a newer version to avoid the linting error: unbound-method - // eslint-disable-next-line @typescript-eslint/unbound-method - const { open, getRootProps, getInputProps } = props - - const validFiles = this.getValidFiles() - const rejectedFiles = this.getRejectedFiles() - - return ( - <> - -
- -
-
- {rejectedFiles.length > 0 ? ( - - - - {t('create_and_edit_multiple_items_modal.invalid_title')} - - - - - - - {rejectedFiles.map(({ fileName, reason }, index) => { - return ( - - - {fileName} - - {reason} - - ) - })} - -
- ) : null} - {validFiles.length > 0 ? ( - - {rejectedFiles.length > 0 ? ( + }, [importedFiles]) + + const renderDropZone = useCallback( + (props: DropzoneState) => { + // TODO: Upgrade react-dropzone to a newer version to avoid the linting error: unbound-method + // eslint-disable-next-line @typescript-eslint/unbound-method + const { open, getRootProps, getInputProps } = props + + return ( + <> + +
+ +
+
+ {rejectedFiles.length > 0 ? ( +
- {t('create_and_edit_multiple_items_modal.valid_title')} + {t('create_and_edit_multiple_items_modal.invalid_title')} + + + - ) : null} - - {validFiles.map(({ fileName }, index) => { - return ( - - {fileName} + + {rejectedFiles.map(({ fileName, reason }, index) => { + return ( + + + {fileName} + + {reason} + + ) + })} + +
+ ) : null} + {validFiles.length > 0 ? ( + + {rejectedFiles.length > 0 ? ( + + + {t('create_and_edit_multiple_items_modal.valid_title')} - ) - })} - -
- ) : null} + + ) : null} + + {validFiles.map(({ fileName }, index) => { + return ( + + {fileName} + + ) + })} + + + ) : null} +
+ {rejectedFiles.length > 0 ? ( +
+ + {t('create_and_edit_multiple_items_modal.only_valid_items_info')} +
+ ) : null}
- {rejectedFiles.length > 0 ? ( -
- - {t('create_and_edit_multiple_items_modal.only_valid_items_info')} -
- ) : null} - -
- - - - - - ) - } - - private renderReviewTable = () => { - const { onClose } = this.props - return ( + + + + + + + ) + }, + [onRejectedFilesClear, rejectedFiles, validFiles, handleFilesUpload, t] + ) + + const renderReviewTable = useCallback( + () => ( <> - + - ) - } - - private isCreating = () => { - const { - metadata: { type = CreateOrEditMultipleItemsModalType.CREATE } - } = this.props - return type === CreateOrEditMultipleItemsModalType.CREATE - } - - private getOperationTypeKey = () => { - return this.isCreating() ? 'create' : 'edit' - } + ), + [modalTitle, modalSubtitle, onClose, handleFilesImport, handleRejectedFiles, renderDropZone] + ) - private getModalTitle = () => { - return t(`create_and_edit_multiple_items_modal.${this.getOperationTypeKey()}.title`) - } - - private getModalSubtitle = () => { - return this.isCreating() ? null : t('create_and_edit_multiple_items_modal.edit.subtitle') - } - - private renderImportView = () => { - const { onClose } = this.props - return ( + const renderImportView = useCallback( + () => ( <> - + - ) - } + ), + [modalTitle, modalSubtitle, onClose, handleFilesImport, handleRejectedFiles, t] + ) - private renderProgressBar(progress: number, label: string, cancel?: () => void) { - return ( + const renderThirdPartyKindSelector = useCallback( + () => ( + <> + + + {isFetchingPrice || !thirdPartyPrice ? ( +
+ +
+ ) : ( +
+ {[ + { + title: t('create_and_edit_multiple_items_modal.third_party_kind_selector.standard.title'), + subtitle: t('create_and_edit_multiple_items_modal.third_party_kind_selector.standard.subtitle', { + price: ethers.utils.formatEther(thirdPartyPrice?.item.usd ?? 0) + }), + img: collectionsImage, + action: () => handleSetThirdPartyType(false) + }, + { + title: t('create_and_edit_multiple_items_modal.third_party_kind_selector.programmatic.title'), + subtitle: t('create_and_edit_multiple_items_modal.third_party_kind_selector.programmatic.subtitle', { + price: ethers.utils.formatEther(thirdPartyPrice?.programmatic.usd ?? 0) + }), + img: linkedCollectionsImage, + action: () => handleSetThirdPartyType(true) + } + ].map(({ title, subtitle, img, action }, index) => ( +
+ +
+

{title}

+
{subtitle}
+
+ +
+
+
+ ))} +
+ )} +
+ + ), + [modalTitle, modalSubtitle, isFetchingPrice, thirdPartyPrice, onClose, handleSetThirdPartyType, t] + ) + + const renderProgressBar = useCallback( + (progress: number, label: string, cancel?: () => void) => ( <> - +
{label}
@@ -485,11 +551,12 @@ export default class CreateAndEditMultipleItemsModal extends React.PureComponent
- ) - } + ), + [modalTitle, modalSubtitle, t] + ) - private renderItemsTableSection(title: string, items: string[]) { - return ( + const renderItemsTableSection = useCallback( + (title: string, items: string[]) => (
@@ -506,22 +573,34 @@ export default class CreateAndEditMultipleItemsModal extends React.PureComponent
- ) - } + ), + [] + ) + + const handleDone = useCallback(() => { + if (isLinkedWearablesPaymentsEnabled && thirdParty && !thirdParty.published) { + setView(ItemCreationView.THIRD_PARTY_KIND_SELECTOR) + } else { + onClose() + } + }, [onClose, isLinkedWearablesPaymentsEnabled, thirdParty]) + + const renderCompleted = useCallback(() => { + const hasFinishedSuccessfully = finishedState?.state === MultipleItemsSaveState.FINISHED_SUCCESSFULLY + const hasBeenCancelled = finishedState?.state === MultipleItemsSaveState.CANCELLED + const hasFailed = finishedState?.state === MultipleItemsSaveState.FINISHED_UNSUCCESSFULLY - private renderCompleted() { - const { onClose, savedItemsFiles, notSavedItemsFiles, cancelledItemsFiles, saveMultipleItemsState, error } = this.props - const hasFinishedSuccessfully = saveMultipleItemsState === MultipleItemsSaveState.FINISHED_SUCCESSFULLY - const hasBeenCancelled = saveMultipleItemsState === MultipleItemsSaveState.CANCELLED - const hasFailed = saveMultipleItemsState === MultipleItemsSaveState.FINISHED_UNSUCCESSFULLY + const notSavedItemsFiles = finishedState?.notSavedFiles ?? [] + const savedItemsFiles = finishedState?.savedFiles ?? [] + const cancelledItemsFiles = finishedState?.cancelledFiles ?? [] let title: string if (hasFinishedSuccessfully) { - title = t(`create_and_edit_multiple_items_modal.${this.getOperationTypeKey()}.successful_title`) + title = t(`create_and_edit_multiple_items_modal.${operationTypeKey}.successful_title`) } else if (hasFailed) { - title = t(`create_and_edit_multiple_items_modal.${this.getOperationTypeKey()}.failed_title`) + title = t(`create_and_edit_multiple_items_modal.${operationTypeKey}.failed_title`) } else { - title = t(`create_and_edit_multiple_items_modal.${this.getOperationTypeKey()}.cancelled_title`) + title = t(`create_and_edit_multiple_items_modal.${operationTypeKey}.cancelled_title`) } return ( @@ -530,67 +609,72 @@ export default class CreateAndEditMultipleItemsModal extends React.PureComponent

{!notSavedItemsFiles.length - ? t(`create_and_edit_multiple_items_modal.${this.getOperationTypeKey()}.finished_successfully_subtitle`, { + ? t(`create_and_edit_multiple_items_modal.${operationTypeKey}.finished_successfully_subtitle`, { number_of_items: savedItemsFiles.length }) - : t(`create_and_edit_multiple_items_modal.${this.getOperationTypeKey()}.finished_partial_successfully_subtitle`, { + : t(`create_and_edit_multiple_items_modal.${operationTypeKey}.finished_partial_successfully_subtitle`, { number_of_items: savedItemsFiles.length, number_of_failed_items: notSavedItemsFiles.length })}

{hasFailed ? : null} {hasBeenCancelled && cancelledItemsFiles.length > 0 - ? this.renderItemsTableSection(t('create_and_edit_multiple_items_modal.cancelled_items_table_title'), cancelledItemsFiles) + ? renderItemsTableSection(t('create_and_edit_multiple_items_modal.cancelled_items_table_title'), cancelledItemsFiles) : null} {hasBeenCancelled || notSavedItemsFiles.length > 0 - ? this.renderItemsTableSection(t('create_and_edit_multiple_items_modal.not_saved_items_table_title'), notSavedItemsFiles) + ? renderItemsTableSection(t('create_and_edit_multiple_items_modal.not_saved_items_table_title'), notSavedItemsFiles) : null} {savedItemsFiles.length > 0 - ? this.renderItemsTableSection( - t(`create_and_edit_multiple_items_modal.${this.getOperationTypeKey()}.saved_items_table_title`), + ? renderItemsTableSection( + t(`create_and_edit_multiple_items_modal.${operationTypeKey}.saved_items_table_title`), savedItemsFiles ) : null}
- ) - } - - private renderView() { - const { view, loadingFilesProgress } = this.state - const { onCancelSaveMultipleItems, saveItemsProgress } = this.props - const validFiles = this.getValidFiles() + }, [onClose, handleDone, operationTypeKey, finishedState, error]) + const renderedView = useMemo(() => { switch (view) { case ItemCreationView.IMPORT: - return this.renderImportView() + return renderImportView() case ItemCreationView.IMPORTING: - return this.renderProgressBar(loadingFilesProgress, t('create_and_edit_multiple_items_modal.importing_files_progress_label')) + return renderProgressBar(loadingFilesProgress, t('create_and_edit_multiple_items_modal.importing_files_progress_label')) case ItemCreationView.REVIEW: - return this.renderReviewTable() + return renderReviewTable() case ItemCreationView.UPLOADING: - return this.renderProgressBar( + return renderProgressBar( saveItemsProgress, t('create_and_edit_multiple_items_modal.uploading_items_progress_label', { number_of_items: validFiles.length }), onCancelSaveMultipleItems ) case ItemCreationView.COMPLETED: - return this.renderCompleted() + return renderCompleted() + case ItemCreationView.THIRD_PARTY_KIND_SELECTOR: + return renderThirdPartyKindSelector() } - } - - public render() { - const { name, onClose } = this.props - - return ( - - {this.renderView()} - - ) - } + }, [ + view, + loadingFilesProgress, + validFiles, + onCancelSaveMultipleItems, + saveItemsProgress, + renderImportView, + renderProgressBar, + renderReviewTable, + renderCompleted, + renderThirdPartyKindSelector + ]) + + return ( + + {renderedView} + + ) } diff --git a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.types.ts b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.types.ts index 6bfb9141e..bee534c53 100644 --- a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.types.ts +++ b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.types.ts @@ -17,6 +17,8 @@ import { } from 'modules/ui/createMultipleItems/selectors' import { BuiltFile } from 'modules/item/types' import { Collection } from 'modules/collection/types' +import { SetThirdPartyTypeRequestAction } from 'modules/thirdParty/actions' +import { ThirdParty } from 'modules/thirdParty/types' export enum CreateOrEditMultipleItemsModalType { CREATE, @@ -32,7 +34,8 @@ export enum ItemCreationView { IMPORTING, REVIEW, UPLOADING, - COMPLETED + COMPLETED, + THIRD_PARTY_KIND_SELECTOR } export enum ImportedFileType { ACCEPTED, @@ -46,23 +49,21 @@ export type CreateAndEditMultipleItemsModalMetadata = { type?: CreateOrEditMultipleItemsModalType } -export type State = { - view: ItemCreationView - loadingFilesProgress: number - importedFiles: Record> -} - export type Props = Omit & { collection: Collection | null + thirdParty: ThirdParty | null error: string | null onSaveMultipleItems: typeof saveMultipleItemsRequest onCancelSaveMultipleItems: typeof cancelSaveMultipleItems onModalUnmount: typeof clearSaveMultipleItems + onSetThirdPartyType: (thirdPartyId: string, isProgrammatic: boolean) => unknown savedItemsFiles: ReturnType notSavedItemsFiles: ReturnType cancelledItemsFiles: ReturnType saveMultipleItemsState: ReturnType isLinkedWearablesV2Enabled: boolean + isLinkedWearablesPaymentsEnabled: boolean + isSettingThirdPartyType: boolean saveItemsProgress: number metadata: CreateAndEditMultipleItemsModalMetadata } @@ -78,6 +79,11 @@ export type MapStateProps = Pick< | 'saveItemsProgress' | 'collection' | 'isLinkedWearablesV2Enabled' + | 'isSettingThirdPartyType' + | 'isLinkedWearablesPaymentsEnabled' + | 'thirdParty' +> +export type MapDispatchProps = Pick +export type MapDispatch = Dispatch< + SaveMultipleItemsRequestAction | CancelSaveMultipleItemsAction | ClearStateSaveMultipleItemsAction | SetThirdPartyTypeRequestAction > -export type MapDispatchProps = Pick -export type MapDispatch = Dispatch diff --git a/src/components/Modals/PublishWizardCollectionModal/PayPublicationFeeStep/PayPublicationFeeStep.tsx b/src/components/Modals/PublishWizardCollectionModal/PayPublicationFeeStep/PayPublicationFeeStep.tsx index b77fe906c..460f888ca 100644 --- a/src/components/Modals/PublishWizardCollectionModal/PayPublicationFeeStep/PayPublicationFeeStep.tsx +++ b/src/components/Modals/PublishWizardCollectionModal/PayPublicationFeeStep/PayPublicationFeeStep.tsx @@ -23,7 +23,7 @@ const MultipleItemImages: React.FC<{ referenceItem: Item }> = ({ referenceItem } ) export const PayPublicationFeeStep: React.FC< - Props & { onNextStep: (paymentMethod: PaymentMethod) => void; onPrevStep: () => void } + Props & { onNextStep: (paymentMethod: PaymentMethod, priceToPayInWei: string) => void; onPrevStep: () => void } > = props => { const { collection, @@ -44,11 +44,11 @@ export const PayPublicationFeeStep: React.FC< const isThirdParty = useMemo(() => isTPCollection(collection), [collection]) const availableSlots = useMemo(() => thirdParty?.availableSlots ?? 0, [thirdParty?.availableSlots]) const amountOfItemsToPublish = useMemo( - () => (itemsToPublish.length - availableSlots > 0 ? itemsToPublish.length - availableSlots : 0), - [itemsToPublish, availableSlots] + () => (thirdParty?.isProgrammatic ? 0 : itemsToPublish.length - availableSlots > 0 ? itemsToPublish.length - availableSlots : 0), + [thirdParty, itemsToPublish, availableSlots] ) const amountOfItemsAlreadyPayed = useMemo( - () => amountOfItemsToPublish - itemsToPublish.length, + () => (thirdParty?.isProgrammatic ? itemsToPublish.length : amountOfItemsToPublish - itemsToPublish.length), [amountOfItemsToPublish, itemsToPublish.length] ) const amountOfItemsAlreadyPublishedWithChanges = useMemo(() => itemsWithChanges.length, [itemsWithChanges]) @@ -57,19 +57,26 @@ export const PayPublicationFeeStep: React.FC< () => (thirdParty?.isProgrammatic ? price?.programmatic?.usd : price?.item.usd) ?? '0', [thirdParty?.isProgrammatic, price?.item.usd, price?.programmatic?.usd] ) - const totalPriceMANA = useMemo( - () => - thirdParty?.isProgrammatic - ? price?.programmatic?.mana ?? '0' - : ethers.BigNumber.from(price?.item.mana ?? '0') - .mul(itemsToPublish.length) - .toString(), - [price?.item.mana, itemsToPublish, thirdParty?.isProgrammatic] - ) - const totalPriceUSD = useMemo( - () => (thirdParty?.isProgrammatic ? priceUSD : ethers.BigNumber.from(priceUSD).mul(itemsToPublish.length).toString()), - [priceUSD, itemsToPublish] - ) + const totalPriceMANA = useMemo(() => { + // Programmatic third parties should pay for the items only once, when they're being published + if (thirdParty?.isProgrammatic && !thirdParty?.published) { + return price?.programmatic?.mana ?? '0' + } else if (thirdParty?.isProgrammatic && thirdParty?.published) { + return '0' + } + return ethers.BigNumber.from(price?.item.mana ?? '0') + .mul(itemsToPublish.length) + .toString() + }, [price?.item.mana, itemsToPublish, thirdParty?.isProgrammatic]) + const totalPriceUSD = useMemo(() => { + // Programmatic third parties should pay for the items only once, when they're being published + if (thirdParty?.isProgrammatic && !thirdParty?.published) { + return priceUSD + } else if (thirdParty?.isProgrammatic && thirdParty?.published) { + return '0' + } + return ethers.BigNumber.from(priceUSD).mul(itemsToPublish.length).toString() + }, [priceUSD, itemsToPublish]) const hasInsufficientMANA = useMemo( () => !!wallet && wallet.networks.MATIC.mana < Number(ethers.utils.formatEther(totalPriceMANA)), [wallet, totalPriceMANA] @@ -110,12 +117,18 @@ export const PayPublicationFeeStep: React.FC< } const handleBuyWithMana = useCallback(() => { - onNextStep(PaymentMethod.MANA) - }, [onNextStep]) + const priceToPayInWei = thirdParty + ? ethers.utils.parseUnits((Number(ethers.utils.formatEther(ethers.BigNumber.from(totalPriceMANA))) * 1.005).toString()).toString() + : totalPriceMANA + onNextStep(PaymentMethod.MANA, priceToPayInWei) + }, [!!thirdParty, totalPriceMANA, onNextStep]) const handleBuyWithFiat = useCallback(() => { - onNextStep(PaymentMethod.FIAT) - }, [onNextStep]) + const priceToPayInWei = ethers.utils + .parseUnits((Number(ethers.utils.formatEther(ethers.BigNumber.from(totalPriceMANA))) * 1.005).toString()) + .toString() + onNextStep(PaymentMethod.FIAT, priceToPayInWei) + }, [onNextStep, totalPriceMANA]) return ( @@ -132,7 +145,7 @@ export const PayPublicationFeeStep: React.FC< {t('publish_wizard_collection_modal.pay_publication_fee_step.title')} - {t('publish_wizard_collection_modal.pay_publication_fee_step.subtitle', { + {t(`publish_wizard_collection_modal.pay_publication_fee_step.${thirdParty ? 'third_parties' : 'standard'}.subtitle`, { collection_name: {collection.name}, count: amountOfItemsToPublish, currency: 'USD', @@ -148,7 +161,9 @@ export const PayPublicationFeeStep: React.FC< {t('publish_wizard_collection_modal.pay_publication_fee_step.quantity')} - {t('publish_wizard_collection_modal.pay_publication_fee_step.fee_per_item')} + {!thirdParty?.isProgrammatic ? ( + {t('publish_wizard_collection_modal.pay_publication_fee_step.fee_per_item')} + ) : null} {t('publish_wizard_collection_modal.pay_publication_fee_step.total_in_usd', { currency: Currency.USD })} @@ -166,9 +181,11 @@ export const PayPublicationFeeStep: React.FC< )} {t('publish_wizard_collection_modal.pay_publication_fee_step.items', { count: amountOfItemsToPublish })}
- - {Currency.USD} {toFixedMANAValue(ethers.utils.formatEther(priceUSD))} - + {!thirdParty?.isProgrammatic ? ( + + {Currency.USD} {toFixedMANAValue(ethers.utils.formatEther(priceUSD))} + + ) : null} {Currency.USD} {toFixedMANAValue(ethers.utils.formatEther(totalPriceUSD))} @@ -187,7 +204,7 @@ export const PayPublicationFeeStep: React.FC< ) : ( )} - {t('publish_wizard_collection_modal.pay_publication_fee_step.items', { count: amountOfItemsToPublish })} + {t('publish_wizard_collection_modal.pay_publication_fee_step.items', { count: amountOfItemsAlreadyPayed })} {t('publish_wizard_collection_modal.pay_publication_fee_step.already_payed')} @@ -237,8 +254,14 @@ export const PayPublicationFeeStep: React.FC< ) : null} diff --git a/src/components/Modals/PublishWizardCollectionModal/PublishWizardCollectionModal.container.tsx b/src/components/Modals/PublishWizardCollectionModal/PublishWizardCollectionModal.container.tsx index d5bcc39d5..70a3e8ecd 100644 --- a/src/components/Modals/PublishWizardCollectionModal/PublishWizardCollectionModal.container.tsx +++ b/src/components/Modals/PublishWizardCollectionModal/PublishWizardCollectionModal.container.tsx @@ -56,7 +56,7 @@ export default (props: OwnProps) => { const thirdPartyPublishingError = useSelector(getThirdPartyError, shallowEqual) const isThirdParty = useMemo(() => collection && isTPCollection(collection), [collection]) const thirdParty = useSelector( - (state: RootState) => (collection && isThirdParty ? getCollectionThirdParty(state, collection) : undefined), + (state: RootState) => (collection && isThirdParty ? getCollectionThirdParty(state, collection) : null), shallowEqual ) @@ -74,40 +74,46 @@ export default (props: OwnProps) => { shallowEqual ) - const itemsToPublish = isThirdParty ? thirdPartyItemsToPublish : allCollectionItems + const itemsToPublish = thirdParty ? thirdPartyItemsToPublish : allCollectionItems const itemsWithChanges = thirdPartyItemsToPushChanges const price = useMemo(() => { - if (isThirdParty) { + if (thirdParty) { return thirdPartyPrice } else if (rarities[0]?.prices?.USD && rarities[0]?.prices?.MANA) { // The UI is designed in a way that considers that all rarities have the same price, so only using the first one // as reference for the prices is enough. return { item: { usd: rarities[0].prices.USD, mana: rarities[0].prices.MANA } } } - }, [thirdPartyPrice, rarities[0]?.prices?.USD, rarities[0]?.prices?.MANA]) + }, [thirdParty, thirdPartyPrice, rarities[0]?.prices?.USD, rarities[0]?.prices?.MANA]) const onFetchRarities = useCallback(() => dispatch(fetchRaritiesRequest()), [dispatch, fetchRaritiesRequest]) const onPublish = useCallback( - (email: string, subscribeToNewsletter: boolean, paymentMethod: PaymentMethod, cheque?: Cheque, maxSlotPrice?: string) => { - return isThirdParty - ? thirdParty && - dispatch( - publishAndPushChangesThirdPartyItemsRequest( - thirdParty, - itemsToPublish, - itemsWithChanges, - cheque, - email, - subscribeToNewsletter, - maxSlotPrice - ) + ( + email: string, + subscribeToNewsletter: boolean, + paymentMethod: PaymentMethod, + cheque?: Cheque, + maxSlotPrice?: string, + minSlots?: string + ) => { + return thirdParty + ? dispatch( + publishAndPushChangesThirdPartyItemsRequest( + thirdParty, + itemsToPublish, + itemsWithChanges, + cheque, + email, + subscribeToNewsletter, + maxSlotPrice, + minSlots ) + ) : dispatch(publishCollectionRequest(collection, itemsToPublish, email, subscribeToNewsletter, paymentMethod)) }, [ - isThirdParty, thirdParty, collection, itemsToPublish, @@ -119,11 +125,11 @@ export default (props: OwnProps) => { ) const isPublishingFinished = !!collection.forumLink && thirdPartyItemsToPublish.length === 0 && thirdPartyItemsToPushChanges.length === 0 - const publishingStatus = isThirdParty ? thirdPartyPublishingStatus : standardPublishingStatus + const publishingStatus = thirdParty ? thirdPartyPublishingStatus : standardPublishingStatus const isPublishing = publishingStatus === AuthorizationStepStatus.WAITING || publishingStatus === AuthorizationStepStatus.PROCESSING const PublishWizardCollectionModal = useMemo( - () => (isThirdParty ? AuthorizedPublishWizardThirdPartyCollectionModal : AuthorizedPublishWizardCollectionModal), - [isThirdParty] + () => (thirdParty ? AuthorizedPublishWizardThirdPartyCollectionModal : AuthorizedPublishWizardCollectionModal), + [thirdParty] ) return ( @@ -143,6 +149,7 @@ export default (props: OwnProps) => { isPublishCollectionsWertEnabled={isPublishCollectionsWertEnabled} onPublish={onPublish} onFetchPrice={isThirdParty ? fetchThirdPartyPrice : onFetchRarities} + thirdParty={thirdParty} /> ) } diff --git a/src/components/Modals/PublishWizardCollectionModal/PublishWizardCollectionModal.tsx b/src/components/Modals/PublishWizardCollectionModal/PublishWizardCollectionModal.tsx index 35fe10f4a..05907c993 100644 --- a/src/components/Modals/PublishWizardCollectionModal/PublishWizardCollectionModal.tsx +++ b/src/components/Modals/PublishWizardCollectionModal/PublishWizardCollectionModal.tsx @@ -102,18 +102,12 @@ export const PublishWizardCollectionModal: React.FC { + (paymentMethod: PaymentMethod, priceToPayInWei: string) => { if (!itemPrice?.item.mana) { return } - // Compute the required allowance in MANA required to publish the collection or items - // This does not take into consideration programmatic third party collections - const requiredAllowanceInWei = ethers.utils.parseUnits( - (Number(ethers.utils.formatEther(ethers.BigNumber.from(itemPrice.item.mana).mul(itemsToPublish.length))) * 1.005).toString() - ) - - if (paymentMethod === PaymentMethod.FIAT || requiredAllowanceInWei === ethers.BigNumber.from('0')) { - onPublish(emailAddress, subscribeToNewsletter, paymentMethod, cheque, requiredAllowanceInWei.toString()) + if (paymentMethod === PaymentMethod.FIAT || priceToPayInWei === ethers.BigNumber.from('0').toString()) { + onPublish(emailAddress, subscribeToNewsletter, paymentMethod, cheque, priceToPayInWei, itemPrice.programmatic?.minSlots) return } const contractName = isThirdParty ? ContractName.ThirdPartyRegistry : ContractName.CollectionManager @@ -130,9 +124,10 @@ export const PublishWizardCollectionModal: React.FC onPublish(emailAddress, subscribeToNewsletter, paymentMethod, cheque, requiredAllowanceInWei.toString()) + onAuthorized: () => + onPublish(emailAddress, subscribeToNewsletter, paymentMethod, cheque, priceToPayInWei, itemPrice.programmatic?.minSlots) }) }, [ diff --git a/src/components/Modals/PublishWizardCollectionModal/PublishWizardCollectionModal.types.ts b/src/components/Modals/PublishWizardCollectionModal/PublishWizardCollectionModal.types.ts index c2b5f4095..1714cb2a8 100644 --- a/src/components/Modals/PublishWizardCollectionModal/PublishWizardCollectionModal.types.ts +++ b/src/components/Modals/PublishWizardCollectionModal/PublishWizardCollectionModal.types.ts @@ -25,13 +25,15 @@ export type Price = { usd: string // MANA in WEI mana: string + // Slots in Ether + minSlots: string } } export type Props = Omit & { metadata: PublishCollectionModalMetadata wallet: Wallet - thirdParty?: ThirdParty + thirdParty: ThirdParty | null collection: Collection itemsToPublish: Item[] itemsWithChanges: Item[] @@ -43,7 +45,14 @@ export type Props = Omit & { isPublishingFinished: boolean isPublishCollectionsWertEnabled: boolean publishingStatus: AuthorizationStepStatus - onPublish: (email: string, subscribeToNewsletter: boolean, paymentMethod: PaymentMethod, cheque?: Cheque, maxPrice?: string) => unknown + onPublish: ( + email: string, + subscribeToNewsletter: boolean, + paymentMethod: PaymentMethod, + cheque?: Cheque, + maxPrice?: string, + minSlots?: string + ) => unknown onFetchPrice: () => unknown } diff --git a/src/components/ThirdPartyCollectionDetailPage/ThirdPartyCollectionDetailPage.tsx b/src/components/ThirdPartyCollectionDetailPage/ThirdPartyCollectionDetailPage.tsx index b95e305e9..263b63c45 100644 --- a/src/components/ThirdPartyCollectionDetailPage/ThirdPartyCollectionDetailPage.tsx +++ b/src/components/ThirdPartyCollectionDetailPage/ThirdPartyCollectionDetailPage.tsx @@ -1,6 +1,5 @@ import { useHistory } from 'react-router' import { useCallback, useEffect, useMemo, useState } from 'react' -import classNames from 'classnames' import { Header, Icon, @@ -31,6 +30,8 @@ import BuilderIcon from 'components/Icon' import Back from 'components/Back' import { shorten } from 'lib/address' import { CopyToClipboard } from 'components/CopyToClipboard' +import { CollectionTypeBadge } from 'components/Badges/CollectionTypeBadge' +import { ThirdPartyKindBadge } from 'components/Badges/ThirdPartyKindBadge' import CollectionContextMenu from './CollectionContextMenu' import CollectionPublishButton from './CollectionPublishButton' import CollectionItem from './CollectionItem' @@ -266,18 +267,8 @@ export default function ThirdPartyCollectionDetailPage({ {!collection.isPublished && }
-
- {t('third_party_collection_detail_page.type')} -
- {thirdParty.isProgrammatic ? ( -
- {t('third_party_collection_detail_page.programmatic')} -
- ) : ( -
- {t('third_party_collection_detail_page.standard')} -
- )} + +
diff --git a/src/lib/api/builder.ts b/src/lib/api/builder.ts index 39063eabd..be8415a7f 100644 --- a/src/lib/api/builder.ts +++ b/src/lib/api/builder.ts @@ -116,6 +116,7 @@ export type RemoteCollection = { // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents linked_contract_network: ContractNetwork | null is_mapping_complete: boolean + is_programmatic?: boolean } export type RemoteProject = { @@ -458,7 +459,8 @@ function fromRemoteCollection(remoteCollection: RemoteCollection) { linkedContractNetwork: remoteCollection.linked_contract_network || undefined, isMappingComplete: remoteCollection.is_mapping_complete, createdAt: +new Date(remoteCollection.created_at), - updatedAt: +new Date(remoteCollection.updated_at) + updatedAt: +new Date(remoteCollection.updated_at), + isProgrammatic: remoteCollection.is_programmatic } if (remoteCollection.salt) collection.salt = remoteCollection.salt @@ -1052,6 +1054,10 @@ export class BuilderAPI extends BaseAPI { ) } + setThirdPartyKind = (thirdPartyId: string, isProgrammatic: boolean) => { + return this.request('PATCH', `/thirdParties/${thirdPartyId}`, { params: { isProgrammatic } }) + } + deleteVirtualThirdParty = async (thirdPartId: string): Promise => { await this.request('delete', `/thirdParties/${thirdPartId}`) } diff --git a/src/modules/collection/types.ts b/src/modules/collection/types.ts index 8c333afaf..8028d0bdd 100644 --- a/src/modules/collection/types.ts +++ b/src/modules/collection/types.ts @@ -23,6 +23,7 @@ export type Collection = { createdAt: number updatedAt: number isMappingComplete?: boolean + isProgrammatic?: boolean } export enum CollectionType { diff --git a/src/modules/thirdParty/actions.ts b/src/modules/thirdParty/actions.ts index 13161e43d..6ff54aa13 100644 --- a/src/modules/thirdParty/actions.ts +++ b/src/modules/thirdParty/actions.ts @@ -135,7 +135,8 @@ export const publishAndPushChangesThirdPartyItemsRequest = ( cheque?: Cheque, email?: string, subscribeToNewsletter?: boolean, - maxSlotPrice?: string + maxSlotPrice?: string, + minSlots?: string ) => action(PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_REQUEST, { thirdParty, @@ -144,7 +145,8 @@ export const publishAndPushChangesThirdPartyItemsRequest = ( cheque, email, subscribeToNewsletter, - maxSlotPrice + maxSlotPrice, + minSlots }) export const publishAndPushChangesThirdPartyItemsSuccess = ( @@ -238,3 +240,19 @@ export const CLEAR_THIRD_PARTY_ERRORS = '[Request] Clear Third Party Errors' export const clearThirdPartyErrors = () => action(CLEAR_THIRD_PARTY_ERRORS) export type ClearThirdPartyErrorsAction = ReturnType + +// Set Third Party Type + +export const SET_THIRD_PARTY_KIND_REQUEST = '[Request] Set Third Party Kind' +export const SET_THIRD_PARTY_KIND_SUCCESS = '[Success] Set Third Party Kind' +export const SET_THIRD_PARTY_KIND_FAILURE = '[Failure] Set Third Party Kind' + +export const setThirdPartyKindRequest = (thirdPartyId: ThirdParty['id'], isProgrammatic: ThirdParty['isProgrammatic']) => + action(SET_THIRD_PARTY_KIND_REQUEST, { thirdPartyId, isProgrammatic }) +export const setThirdPartyKindSuccess = (thirdPartyId: ThirdParty['id'], isProgrammatic: ThirdParty['isProgrammatic']) => + action(SET_THIRD_PARTY_KIND_SUCCESS, { thirdPartyId, isProgrammatic }) +export const setThirdPartyKindFailure = (error: string) => action(SET_THIRD_PARTY_KIND_FAILURE, { error }) + +export type SetThirdPartyTypeRequestAction = ReturnType +export type SetThirdPartyTypeSuccessAction = ReturnType +export type SetThirdPartyTypeFailureAction = ReturnType diff --git a/src/modules/thirdParty/reducer.spec.ts b/src/modules/thirdParty/reducer.spec.ts index 07f910c82..67e08e034 100644 --- a/src/modules/thirdParty/reducer.spec.ts +++ b/src/modules/thirdParty/reducer.spec.ts @@ -23,7 +23,10 @@ import { fetchThirdPartyFailure, FINISH_PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_REQUEST, finishPublishAndPushChangesThirdPartyItemsSuccess, - finishPublishAndPushChangesThirdPartyItemsFailure + finishPublishAndPushChangesThirdPartyItemsFailure, + setThirdPartyKindRequest, + setThirdPartyKindSuccess, + setThirdPartyKindFailure } from './actions' import { INITIAL_STATE, thirdPartyReducer, ThirdPartyState } from './reducer' import { ThirdParty } from './types' @@ -353,3 +356,61 @@ describe('when reducing a FETCH_THIRD_PARTY_FAILURE action', () => { }) }) }) + +describe('when reducing a SET_THIRD_PARTY_TYPE_REQUEST action', () => { + it('should add the action to the loading array', () => { + expect(thirdPartyReducer(state, setThirdPartyKindRequest(thirdParty.id, true))).toEqual({ + ...INITIAL_STATE, + loading: [setThirdPartyKindRequest(thirdParty.id, true)] + }) + }) +}) + +describe('when reducing a SET_THIRD_PARTY_TYPE_SUCCESS action', () => { + beforeEach(() => { + state = { + ...state, + data: { + [thirdParty.id]: thirdParty + }, + loading: [setThirdPartyKindRequest(thirdParty.id, true)], + error: 'anError' + } + }) + + it('should remove the corresponding request action from the loading state, clear the error and set the third party isProgrammatic property with the value given in the action', () => { + expect(thirdPartyReducer(state, setThirdPartyKindSuccess(thirdParty.id, true))).toEqual({ + ...INITIAL_STATE, + error: null, + data: { + ...state.data, + [thirdParty.id]: { + ...state.data[thirdParty.id], + isProgrammatic: true + } + }, + loading: [] + }) + }) +}) + +describe('when reducing a SET_THIRD_PARTY_TYPE_FAILURE action', () => { + let error: string + + beforeEach(() => { + error = 'anError' + state = { + ...state, + error: null, + loading: [setThirdPartyKindRequest(thirdParty.id, true)] + } + }) + + it('should remove the corresponding request action from the loading state and set the error', () => { + expect(thirdPartyReducer(state, setThirdPartyKindFailure(error))).toEqual({ + ...INITIAL_STATE, + loading: [], + error + }) + }) +}) diff --git a/src/modules/thirdParty/reducer.ts b/src/modules/thirdParty/reducer.ts index 5877878a1..800fc7bbc 100644 --- a/src/modules/thirdParty/reducer.ts +++ b/src/modules/thirdParty/reducer.ts @@ -42,7 +42,13 @@ import { FinishPublishAndPushChangesThirdPartyItemsSuccessAction, PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_SUCCESS, FinishPublishAndPushChangesThirdPartyItemsFailureAction, - FINISH_PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_REQUEST + FINISH_PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_REQUEST, + SetThirdPartyTypeRequestAction, + SetThirdPartyTypeFailureAction, + SetThirdPartyTypeSuccessAction, + SET_THIRD_PARTY_KIND_SUCCESS, + SET_THIRD_PARTY_KIND_FAILURE, + SET_THIRD_PARTY_KIND_REQUEST } from './actions' import { ThirdParty } from './types' @@ -82,9 +88,13 @@ type ThirdPartyReducerAction = | FetchThirdPartyRequestAction | FetchThirdPartySuccessAction | FetchThirdPartyFailureAction + | SetThirdPartyTypeRequestAction + | SetThirdPartyTypeFailureAction + | SetThirdPartyTypeSuccessAction export function thirdPartyReducer(state: ThirdPartyState = INITIAL_STATE, action: ThirdPartyReducerAction): ThirdPartyState { switch (action.type) { + case SET_THIRD_PARTY_KIND_REQUEST: case DEPLOY_BATCHED_THIRD_PARTY_ITEMS_REQUEST: case FETCH_THIRD_PARTY_AVAILABLE_SLOTS_REQUEST: case FETCH_THIRD_PARTY_REQUEST: @@ -155,12 +165,26 @@ export function thirdPartyReducer(state: ThirdPartyState = INITIAL_STATE, action error: null } } - case PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_SUCCESS: + case PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_SUCCESS: { return { ...state, loading: loadingReducer(loadingReducer(state.loading, action), { type: FINISH_PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_REQUEST }) } - + } + case SET_THIRD_PARTY_KIND_SUCCESS: { + return { + ...state, + data: { + ...state.data, + [action.payload.thirdPartyId]: { + ...state.data[action.payload.thirdPartyId], + isProgrammatic: action.payload.isProgrammatic + } + }, + loading: loadingReducer(state.loading, action), + error: null + } + } case DEPLOY_BATCHED_THIRD_PARTY_ITEMS_SUCCESS: { return { ...state, @@ -168,6 +192,7 @@ export function thirdPartyReducer(state: ThirdPartyState = INITIAL_STATE, action error: null } } + case SET_THIRD_PARTY_KIND_FAILURE: case FETCH_THIRD_PARTY_FAILURE: case DISABLE_THIRD_PARTY_FAILURE: case FETCH_THIRD_PARTY_AVAILABLE_SLOTS_FAILURE: diff --git a/src/modules/thirdParty/sagas.spec.ts b/src/modules/thirdParty/sagas.spec.ts index c5aaa4b51..448d6410d 100644 --- a/src/modules/thirdParty/sagas.spec.ts +++ b/src/modules/thirdParty/sagas.spec.ts @@ -46,7 +46,10 @@ import { finishPublishAndPushChangesThirdPartyItemsSuccess, finishPublishAndPushChangesThirdPartyItemsFailure, publishAndPushChangesThirdPartyItemsSuccess, - clearThirdPartyErrors + clearThirdPartyErrors, + setThirdPartyKindSuccess, + setThirdPartyKindRequest, + setThirdPartyKindFailure } from './actions' import { mockedItem } from 'specs/item' import { getCollection } from 'modules/collection/selectors' @@ -87,6 +90,7 @@ const mockBuilder = { updateItemCurationStatus: jest.fn(), deleteVirtualThirdParty: jest.fn(), fetchContents: jest.fn(), + setThirdPartyKind: jest.fn(), saveTOS: jest.fn() } as any as BuilderAPI @@ -652,6 +656,7 @@ describe('when publishing & pushing changes to third party items', () => { describe('and the max slot price is defined', () => { let contractData: ContractData let missingSlots: string + let minSlots: string beforeEach(() => { maxSlotPrice = '1' @@ -692,63 +697,207 @@ describe('when publishing & pushing changes to third party items', () => { }) describe('and the add third parties transaction succeeds', () => { - it('should send the transaction to create the third party, wait for it to finish and delete the virtual third party', () => { - return expectSaga(thirdPartySaga, mockBuilder, mockCatalystClient) - .provide([ - [select(getCollection, item.collectionId), collection], - [select(getIsLinkedWearablesPaymentsEnabled), linkedWearablesPaymentsEnabled], - [call(getChainIdByNetwork, Network.MATIC), ChainId.MATIC_AMOY], - [call(getContract, ContractName.ThirdPartyRegistry, ChainId.MATIC_AMOY), contractData], - [matchers.call.fn(sendTransaction), '0x123'], - // Next handler mocks - [call(waitForTx, '0x123'), undefined], - [retry(20, 5000, mockBuilder.deleteVirtualThirdParty, thirdParty.id), undefined], - [call(getPublishItemsSignature, thirdParty.id, qty), { signature, salt, qty }], - [ - call([mockBuilder, mockBuilder.publishTPCollection], item.collectionId!, [item.id], { signature, qty, salt }), - { collection, items: itemsToPublish, itemCurations } - ] - ]) - .call( - sendTransaction, - contractData, - 'addThirdParties', - [ + describe('and the third party is programmatic', () => { + beforeEach(() => { + thirdParty.isProgrammatic = true + }) + + describe('and the amount of items to publish is lower than the minimum amount of slots the programmatic third party should have', () => { + beforeEach(() => { + minSlots = '2' + }) + + it('should send the transaction to create the third party with the minimum amount of slots, wait for it to finish and delete the virtual third party', () => { + return expectSaga(thirdPartySaga, mockBuilder, mockCatalystClient) + .provide([ + [select(getCollection, item.collectionId), collection], + [select(getIsLinkedWearablesPaymentsEnabled), linkedWearablesPaymentsEnabled], + [call(getChainIdByNetwork, Network.MATIC), ChainId.MATIC_AMOY], + [call(getContract, ContractName.ThirdPartyRegistry, ChainId.MATIC_AMOY), contractData], + [matchers.call.fn(sendTransaction), '0x123'], + // Next handler mocks + [call(waitForTx, '0x123'), undefined], + [retry(20, 5000, mockBuilder.deleteVirtualThirdParty, thirdParty.id), undefined], + [call(getPublishItemsSignature, thirdParty.id, qty), { signature, salt, qty }], + [ + call([mockBuilder, mockBuilder.publishTPCollection], item.collectionId!, [item.id], { signature, qty, salt }), + { collection, items: itemsToPublish, itemCurations } + ] + ]) + .call( + sendTransaction, + contractData, + 'addThirdParties', + [ + [ + thirdParty.id, + convertThirdPartyMetadataToRawMetadata(thirdParty.name, thirdParty.description, thirdParty.contracts), + 'Disabled', + thirdParty.managers, + [true], + minSlots + ] + ], + [thirdParty.isProgrammatic], + [maxSlotPrice] + ) + .put( + publishAndPushChangesThirdPartyItemsSuccess( + thirdParty, + collection, + itemsToPublish, + [], + undefined, + '0x123', + ChainId.MATIC_AMOY + ) + ) + .dispatch( + publishAndPushChangesThirdPartyItemsRequest( + thirdParty, + itemsToPublish, + [], + undefined, + email, + subscribeToNewsletter, + maxSlotPrice, + minSlots + ) + ) + .run({ silenceTimeout: true }) + }) + }) + + describe('and the amount of items to publish is higher or equal than the minimum amount of slots the programmatic third party should have', () => { + beforeEach(() => { + minSlots = '0' + }) + + it('should send the transaction to create the third party with amount of slots to publish, wait for it to finish and delete the virtual third party', () => { + return expectSaga(thirdPartySaga, mockBuilder, mockCatalystClient) + .provide([ + [select(getCollection, item.collectionId), collection], + [select(getIsLinkedWearablesPaymentsEnabled), linkedWearablesPaymentsEnabled], + [call(getChainIdByNetwork, Network.MATIC), ChainId.MATIC_AMOY], + [call(getContract, ContractName.ThirdPartyRegistry, ChainId.MATIC_AMOY), contractData], + [matchers.call.fn(sendTransaction), '0x123'], + // Next handler mocks + [call(waitForTx, '0x123'), undefined], + [retry(20, 5000, mockBuilder.deleteVirtualThirdParty, thirdParty.id), undefined], + [call(getPublishItemsSignature, thirdParty.id, qty), { signature, salt, qty }], + [ + call([mockBuilder, mockBuilder.publishTPCollection], item.collectionId!, [item.id], { signature, qty, salt }), + { collection, items: itemsToPublish, itemCurations } + ] + ]) + .call( + sendTransaction, + contractData, + 'addThirdParties', + [ + [ + thirdParty.id, + convertThirdPartyMetadataToRawMetadata(thirdParty.name, thirdParty.description, thirdParty.contracts), + 'Disabled', + thirdParty.managers, + [true], + missingSlots + ] + ], + [thirdParty.isProgrammatic], + [maxSlotPrice] + ) + .put( + publishAndPushChangesThirdPartyItemsSuccess( + thirdParty, + collection, + itemsToPublish, + [], + undefined, + '0x123', + ChainId.MATIC_AMOY + ) + ) + .dispatch( + publishAndPushChangesThirdPartyItemsRequest( + thirdParty, + itemsToPublish, + [], + undefined, + email, + subscribeToNewsletter, + maxSlotPrice, + minSlots + ) + ) + .run({ silenceTimeout: true }) + }) + }) + }) + + describe('and the third party is not programmatic', () => { + beforeEach(() => { + thirdParty.isProgrammatic = false + }) + + it('should send the transaction to create the third party, wait for it to finish and delete the virtual third party', () => { + return expectSaga(thirdPartySaga, mockBuilder, mockCatalystClient) + .provide([ + [select(getCollection, item.collectionId), collection], + [select(getIsLinkedWearablesPaymentsEnabled), linkedWearablesPaymentsEnabled], + [call(getChainIdByNetwork, Network.MATIC), ChainId.MATIC_AMOY], + [call(getContract, ContractName.ThirdPartyRegistry, ChainId.MATIC_AMOY), contractData], + [matchers.call.fn(sendTransaction), '0x123'], + // Next handler mocks + [call(waitForTx, '0x123'), undefined], + [retry(20, 5000, mockBuilder.deleteVirtualThirdParty, thirdParty.id), undefined], + [call(getPublishItemsSignature, thirdParty.id, qty), { signature, salt, qty }], [ - thirdParty.id, - convertThirdPartyMetadataToRawMetadata(thirdParty.name, thirdParty.description, thirdParty.contracts), - 'Disabled', - thirdParty.managers, - [true], - missingSlots + call([mockBuilder, mockBuilder.publishTPCollection], item.collectionId!, [item.id], { signature, qty, salt }), + { collection, items: itemsToPublish, itemCurations } ] - ], - [thirdParty.isProgrammatic], - [maxSlotPrice] - ) - .put( - publishAndPushChangesThirdPartyItemsSuccess( - thirdParty, - collection, - itemsToPublish, - [], - undefined, - '0x123', - ChainId.MATIC_AMOY + ]) + .call( + sendTransaction, + contractData, + 'addThirdParties', + [ + [ + thirdParty.id, + convertThirdPartyMetadataToRawMetadata(thirdParty.name, thirdParty.description, thirdParty.contracts), + 'Disabled', + thirdParty.managers, + [true], + missingSlots + ] + ], + [thirdParty.isProgrammatic], + [maxSlotPrice] ) - ) - .dispatch( - publishAndPushChangesThirdPartyItemsRequest( - thirdParty, - itemsToPublish, - [], - undefined, - email, - subscribeToNewsletter, - maxSlotPrice + .put( + publishAndPushChangesThirdPartyItemsSuccess( + thirdParty, + collection, + itemsToPublish, + [], + undefined, + '0x123', + ChainId.MATIC_AMOY + ) ) - ) - .run({ silenceTimeout: true }) + .dispatch( + publishAndPushChangesThirdPartyItemsRequest( + thirdParty, + itemsToPublish, + [], + undefined, + email, + subscribeToNewsletter, + maxSlotPrice + ) + ) + .run({ silenceTimeout: true }) + }) }) }) }) @@ -1203,6 +1352,40 @@ describe('when handling the disabling of a third party', () => { }) }) +describe('when handling setting a third party kind', () => { + let thirdPartyId: string + + beforeEach(() => { + thirdPartyId = 'aThirdPartyId' + }) + + describe('and the request succeeds', () => { + it('should put the set third party kind success action and close the modal', () => { + return expectSaga(thirdPartySaga, mockBuilder, mockCatalystClient) + .provide([[call([mockBuilder, 'setThirdPartyKind'], thirdPartyId, true), Promise.resolve()]]) + .put(setThirdPartyKindSuccess(thirdPartyId, true)) + .dispatch(setThirdPartyKindRequest(thirdPartyId, true)) + .run({ silenceTimeout: true }) + }) + }) + + describe('and the request fails', () => { + let error: string + + beforeEach(() => { + error = 'anError' + }) + + it('should put the set third party kind failure action and close the modal', () => { + return expectSaga(thirdPartySaga, mockBuilder, mockCatalystClient) + .provide([[call([mockBuilder, 'setThirdPartyKind'], thirdPartyId, true), Promise.reject(new Error(error))]]) + .put(setThirdPartyKindFailure(error)) + .dispatch(setThirdPartyKindRequest(thirdPartyId, true)) + .run({ silenceTimeout: true }) + }) + }) +}) + describe('when handling the closing a modal', () => { let modalName: string diff --git a/src/modules/thirdParty/sagas.ts b/src/modules/thirdParty/sagas.ts index be57022ca..443cc1aab 100644 --- a/src/modules/thirdParty/sagas.ts +++ b/src/modules/thirdParty/sagas.ts @@ -1,7 +1,7 @@ import PQueue from 'p-queue' import { channel } from 'redux-saga' import { takeLatest, takeEvery, call, put, select, race, take, retry } from 'redux-saga/effects' -import { Contract, providers } from 'ethers' +import { Contract, ethers, providers } from 'ethers' import { Authenticator, AuthIdentity } from '@dcl/crypto' import { ChainId, Network } from '@dcl/schemas' import { CatalystClient } from 'dcl-catalyst-client' @@ -88,7 +88,11 @@ import { finishPublishAndPushChangesThirdPartyItemsFailure, FINISH_PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_FAILURE, FINISH_PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_SUCCESS, - clearThirdPartyErrors + clearThirdPartyErrors, + SetThirdPartyTypeRequestAction, + SET_THIRD_PARTY_KIND_REQUEST, + setThirdPartyKindFailure, + setThirdPartyKindSuccess } from './actions' import { convertThirdPartyMetadataToRawMetadata, getPublishItemsSignature } from './utils' import { Cheque, ThirdParty } from './types' @@ -118,6 +122,7 @@ export function* thirdPartySaga(builder: BuilderAPI, catalystClient: CatalystCli yield takeEvery(PUBLISH_THIRD_PARTY_ITEMS_SUCCESS, handlePublishThirdPartyItemSuccess) yield takeLatest(REVIEW_THIRD_PARTY_REQUEST, handleReviewThirdPartyRequest) yield takeEvery(DISABLE_THIRD_PARTY_REQUEST, handleDisableThirdPartyRequest) + yield takeEvery(SET_THIRD_PARTY_KIND_REQUEST, handleSetThirdPartyKind) yield takeEvery(actionProgressChannel, handleUpdateApprovalFlowProgress) yield takeEvery( [ @@ -302,7 +307,7 @@ export function* thirdPartySaga(builder: BuilderAPI, catalystClient: CatalystCli } function* handlePublishAndPushChangesThirdPartyItemRequest(action: PublishAndPushChangesThirdPartyItemsRequestAction) { - const { thirdParty, maxSlotPrice, itemsToPublish, itemsWithChanges, email, subscribeToNewsletter, cheque } = action.payload + const { thirdParty, minSlots, maxSlotPrice, itemsToPublish, itemsWithChanges, email, subscribeToNewsletter, cheque } = action.payload const collectionId = itemsToPublish.length > 0 ? getCollectionId(itemsToPublish) : getCollectionId(itemsWithChanges) const collection: ReturnType = yield select(getCollection, collectionId) const isLinkedWearablesPaymentsEnabled = (yield select(getIsLinkedWearablesPaymentsEnabled)) as ReturnType< @@ -347,6 +352,17 @@ export function* thirdPartySaga(builder: BuilderAPI, catalystClient: CatalystCli const thirdPartyContract: ContractData = yield call(getContract, ContractName.ThirdPartyRegistry, maticChainId) // If the third party has not been published before create a new one with the required slots if (!thirdParty.published) { + let slotsToPublish: string + if (thirdParty.isProgrammatic) { + // When publishing a programmatic third party collection, we need to publish the maximum number between the + // missing slots and the minimum amount of slots required to publish a programmatic third party. + slotsToPublish = ethers.BigNumber.from(missingSlots).gt(minSlots ?? '0') + ? missingSlots.toString() + : (minSlots ?? '0').toString() + } else { + slotsToPublish = missingSlots.toString() + } + txHash = yield call( sendTransaction as any, thirdPartyContract, @@ -358,7 +374,7 @@ export function* thirdPartySaga(builder: BuilderAPI, catalystClient: CatalystCli 'Disabled', thirdParty.managers, Array.from({ length: thirdParty.managers.length }, () => true), - missingSlots.toString() + slotsToPublish ] ], [thirdParty.isProgrammatic], @@ -548,4 +564,15 @@ export function* thirdPartySaga(builder: BuilderAPI, catalystClient: CatalystCli yield put(disableThirdPartyFailure(isErrorWithMessage(error) ? error.message : 'Unknown error')) } } + + function* handleSetThirdPartyKind(action: SetThirdPartyTypeRequestAction) { + const { thirdPartyId, isProgrammatic } = action.payload + try { + yield call([builder, 'setThirdPartyKind'], thirdPartyId, isProgrammatic) + yield put(setThirdPartyKindSuccess(thirdPartyId, isProgrammatic)) + yield put(closeModal('CreateAndEditMultipleItemsModal')) + } catch (error) { + yield put(setThirdPartyKindFailure(isErrorWithMessage(error) ? error.message : 'Unknown error')) + } + } } diff --git a/src/modules/thirdParty/selectors.spec.ts b/src/modules/thirdParty/selectors.spec.ts index 3e039356c..9360e93a9 100644 --- a/src/modules/thirdParty/selectors.spec.ts +++ b/src/modules/thirdParty/selectors.spec.ts @@ -9,7 +9,8 @@ import { fetchThirdPartyRequest, FINISH_PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_REQUEST, PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_SUCCESS, - publishAndPushChangesThirdPartyItemsRequest + publishAndPushChangesThirdPartyItemsRequest, + setThirdPartyKindRequest } from './actions' import { isThirdPartyManager, @@ -22,7 +23,8 @@ import { isDisablingThirdParty, hasPendingDisableThirdPartyTransaction, isLoadingThirdParty, - getThirdPartyPublishStatus + getThirdPartyPublishStatus, + isSettingThirdPartyType } from './selectors' import { ThirdParty } from './types' import { INITIAL_STATE } from './reducer' @@ -639,4 +641,38 @@ describe('Third Party selectors', () => { }) }) }) + + describe('when setting the third party type', () => { + describe('and the third party type is being set', () => { + beforeEach(() => { + baseState = { + ...baseState, + thirdParty: { + ...baseState.thirdParty, + loading: [setThirdPartyKindRequest(thirdParty1.id, true)] + } + } + }) + + it('should return true', () => { + expect(isSettingThirdPartyType(baseState)).toBe + }) + + describe('and the third party type is not being set', () => { + beforeEach(() => { + baseState = { + ...baseState, + thirdParty: { + ...baseState.thirdParty, + loading: [] + } + } + }) + + it('should return false', () => { + expect(isSettingThirdPartyType(baseState)).toBe(false) + }) + }) + }) + }) }) diff --git a/src/modules/thirdParty/selectors.ts b/src/modules/thirdParty/selectors.ts index 88ed042b4..19e71a455 100644 --- a/src/modules/thirdParty/selectors.ts +++ b/src/modules/thirdParty/selectors.ts @@ -20,7 +20,8 @@ import { FETCH_THIRD_PARTY_REQUEST, FetchThirdPartyRequestAction, PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_SUCCESS, - FINISH_PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_REQUEST + FINISH_PUBLISH_AND_PUSH_CHANGES_THIRD_PARTY_ITEMS_REQUEST, + SET_THIRD_PARTY_KIND_REQUEST } from './actions' import { getThirdPartyForCollection, getThirdPartyForItem, isUserManagerOfThirdParty } from './utils' @@ -70,6 +71,7 @@ export const isLoadingThirdParty = (state: RootState, id: ThirdParty['id']): boo export const isFetchingAvailableSlots = (state: RootState): boolean => isLoadingType(getLoading(state), FETCH_THIRD_PARTY_AVAILABLE_SLOTS_REQUEST) +export const isSettingThirdPartyType = (state: RootState): boolean => isLoadingType(getLoading(state), SET_THIRD_PARTY_KIND_REQUEST) export const isDeployingBatchedThirdPartyItems = (state: RootState): boolean => isLoadingType(getLoading(state), DEPLOY_BATCHED_THIRD_PARTY_ITEMS_REQUEST) diff --git a/src/modules/thirdParty/types.ts b/src/modules/thirdParty/types.ts index 7b6a48488..8cbbe5c92 100644 --- a/src/modules/thirdParty/types.ts +++ b/src/modules/thirdParty/types.ts @@ -36,5 +36,5 @@ export type Slot = { export type ThirdPartyPrice = { item: { usd: string; mana: string } - programmatic: { usd: string; mana: string } + programmatic: { usd: string; mana: string; minSlots: string } } diff --git a/src/modules/thirdParty/utils.spec.ts b/src/modules/thirdParty/utils.spec.ts index 03c43d6ab..46ddc0e31 100644 --- a/src/modules/thirdParty/utils.spec.ts +++ b/src/modules/thirdParty/utils.spec.ts @@ -339,7 +339,7 @@ describe('when getting the third party price', () => { beforeEach(() => { mockedGetRate.mockResolvedValueOnce(ethers.BigNumber.from('500000000000000000')) mockedItemSlotPrice.mockResolvedValueOnce(ethers.BigNumber.from('100000000000000000000')) - mockedProgrammaticBasePurchasedSlots.mockResolvedValueOnce(ethers.BigNumber.from('200000000000000000000')) + mockedProgrammaticBasePurchasedSlots.mockResolvedValueOnce(ethers.BigNumber.from('200')) }) it('should return the third party price', () => { @@ -350,7 +350,8 @@ describe('when getting the third party price', () => { }, programmatic: { usd: '20000000000000000000000', - mana: '40000000000000000000000' + mana: '40000000000000000000000', + minSlots: '200' } }) }) diff --git a/src/modules/thirdParty/utils.ts b/src/modules/thirdParty/utils.ts index 249327b6f..3176a2eeb 100644 --- a/src/modules/thirdParty/utils.ts +++ b/src/modules/thirdParty/utils.ts @@ -120,8 +120,9 @@ export async function getThirdPartyPrice(): Promise { mana: ethers.utils.parseEther(itemSlotPriceInUsd.div(manaToUsdPrice).toString()).toString() }, programmatic: { - usd: programmaticPriceInSlots.mul(itemSlotPriceInUsd).div(ethers.constants.WeiPerEther).toString(), - mana: programmaticPriceInSlots.mul(itemSlotPriceInUsd).div(manaToUsdPrice).toString() + usd: programmaticPriceInSlots.mul(itemSlotPriceInUsd).toString(), + minSlots: programmaticPriceInSlots.toString(), + mana: programmaticPriceInSlots.mul(itemSlotPriceInUsd).div(manaToUsdPrice).mul(ethers.constants.WeiPerEther).toString() } } } diff --git a/src/modules/translation/languages/en.json b/src/modules/translation/languages/en.json index a5bb590e0..ef41358fe 100644 --- a/src/modules/translation/languages/en.json +++ b/src/modules/translation/languages/en.json @@ -393,6 +393,17 @@ "finished_partial_successfully_subtitle": "You've updated {number_of_items} items but {number_of_failed_items} failed. Please review the URNs provided", "saved_items_table_title": "Updated items" }, + "third_party_kind_selector": { + "standard": { + "title": "Standard Linked Wearables", + "subtitle": "Hand made wearables. Each wearable was made individually. Submission costs {price} USD per wearable." + }, + "programmatic": { + "title": "Programmatic Linked Wearables", + "subtitle": "Parts or traits of each wearable were made by hand, but the mix of traits is done by code. Submission costs {price} USD per collection." + }, + "action": "Select" + }, "wrong_thumbnail_format": "The thumbnail.png file is not formatted as a PNG image.", "missing_mapping": "The item is missing a mapping.", "wrong_file_extension": "File extension is not correct.", @@ -1575,6 +1586,7 @@ "owner": "Owner", "link": "Link", "type": "Type", + "kind": "Kind", "type_third_party": "Linked", "type_standard": "Standard", "review_request": "Review request", @@ -1642,9 +1654,6 @@ "slots_short": "Slots", "slots_long": "Publishing points", "migration_banner": "One or many items in your collection must be migrated to new version", - "type": "Linked Wearables", - "programmatic": "Programmatic", - "standard": "Standard", "synced_filter": { "all": "All items", "synced": "Synced", @@ -1883,7 +1892,12 @@ }, "pay_publication_fee_step": { "title": "Confirm items", - "subtitle": "You are about to send for review the collection \"{collection_name}\" that includes {count} {count, plural, one {item} other {items}}. In order to support the Curators Committee and the DAO treasure, there is a publication fee of {currency} {publication_fee} per item (paid in MANA).", + "third_parties": { + "subtitle": "You are about to send for review the collection \"{collection_name}\" that includes {count} {count, plural, one {item} other {items}}. In order to support the Curators Committee and the DAO treasure, there is a publication fee of {currency} {publication_fee} per collection (paid in MANA) only once." + }, + "standard": { + "subtitle": "You are about to send for review the collection \"{collection_name}\" that includes {count} {count, plural, one {item} other {items}}. In order to support the Curators Committee and the DAO treasure, there is a publication fee of {currency} {publication_fee} per item (paid in MANA)." + }, "learn_more": "Learn about the publication fee", "already_payed": "Already payed but not published", "already_published": "Already published", @@ -2387,5 +2401,13 @@ "push_changes_modal": { "title": "Push Changes", "description": "Changes have been made to the collection or the items contained by it since the last time a curator has reviewed them.{br}In order to have this changes reflected in world, they have to be reviewed and approved again by the committee.{br}Are you sure you want to push the changes for review?" + }, + "collection_type_badge": { + "third_party": "Linked Wearables", + "regular": "Regular" + }, + "third_party_kind_badge": { + "programmatic": "Programmatic", + "standard": "Standard" } } diff --git a/src/modules/translation/languages/es.json b/src/modules/translation/languages/es.json index 06343c047..e0fccf70b 100644 --- a/src/modules/translation/languages/es.json +++ b/src/modules/translation/languages/es.json @@ -392,6 +392,17 @@ "finished_partial_successfully_subtitle": "Agregaste {number_of_items} items pero {number_of_failed_items} fallaron. Por favor revise las URNs subidas", "saved_items_table_title": "Items actualizados" }, + "third_party_kind_selector": { + "standard": { + "title": "Wearables externamente vinculados estándar", + "subtitle": "Wearables hechos a mano. Cada weawrable fue hecho individualmente. El costo de revisión es de {price} USD por wearable." + }, + "programmatic": { + "title": "Wearables externamente vinculados programáticos", + "subtitle": "Las partes o características de cada wearable se hicieron a mano, pero la mezcla de características se hace mediante código. El costo de revisión es de {price} USD por colección." + }, + "action": "Seleccionar" + }, "wrong_thumbnail_format": "El archivo thumbnail.png no tiene el formato de una imagen PNG.", "missing_mapping": "El item no contiene un mapeo.", "wrong_file_extension": "La extensión del artchivo no es correcta.", @@ -1584,6 +1595,7 @@ "owner": "Dueño", "link": "Enlace", "type": "Tipo", + "kind": "Clase", "type_third_party": "Colección Externa", "type_standard": "Estándar", "review_request": "Solicitud de revisión", @@ -1649,9 +1661,6 @@ "slots_short": "Slots", "slots_long": "Puntos de publicación", "migration_banner": "Uno o múltiples items en tu colección necesitan ser migrados a la nueva versión", - "type": "Externamente vinculadas", - "programmatic": "Programáticas", - "standard": "Estándar", "synced_filter": { "all": "Todos los items", "synced": "Sincronizados", @@ -1880,7 +1889,7 @@ }, "third_parties": { "first": "Garantizo que los artículos enviados cumplen plenamente con los Términos de uso y las Políticas de contenido, que reconozco y acepto.", - "second": "Confirmo que tengo permisos de IP para crear Linked Wearables basados en esta colección NFT externa.", + "second": "Confirmo que tengo permisos de IP para crear Wearables externamente vinculados basados en esta colección NFT externa.", "third": "Reconozco que la DAO puede negarse a aceptar los artículos basándose en infracciones a los Términos de uso y las Políticas de contenido. También reconozco que la tarifa de publicación no se reembolsará en caso de rechazo ya que su finalidad es compensar a los curadores por su tiempo y trabajo." }, "subscription": { @@ -1892,7 +1901,12 @@ }, "pay_publication_fee_step": { "title": "Confirmar ítems", - "subtitle": "Estás a punto de enviar para revisión la colección \"{collection_name}\" que incluye {count, plural, one {item} other {items}}. Para apoyar al Comité de Curadores y al tesoro de la DAO, hay una tarifa de publicación de {currency} {publication_fee} por ítem (pagada en MANA).", + "third_parties": { + "subtitle": "Estás a punto de enviar para revisión la colección \"{collection_name}\" que incluye {count, plural, one {item} other {items}}. Para apoyar al Comité de Curadores y al tesoro de la DAO, hay una tarifa de publicación de {currency} {publication_fee} por colección (pagado en MANA) una única vez." + }, + "standard": { + "subtitle": "Estás a punto de enviar para revisión la colección \"{collection_name}\" que incluye {count, plural, one {item} other {items}}. Para apoyar al Comité de Curadores y al tesoro de la DAO, hay una tarifa de publicación de {currency} {publication_fee} por ítem (pagada en MANA)." + }, "learn_more": "Aprender sobre la tarifa de publicación", "already_payed": "Ya pagados, pero no publicados", "already_published": "Ya publicados", @@ -2405,5 +2419,13 @@ "push_changes_modal": { "title": "Enviar Cambios", "description": "Se han realizado cambios en la colección o en los elementos que contiene desde la última vez que un curador los revisó.{br}Para que estos cambios se reflejen en el mundo, el comité debe revisarlos y aprobarlos nuevamente.{br}¿Está seguro de que desea enviar los cambios para su revisión?" + }, + "collection_type_badge": { + "third_party": "Wearables exteneramente vinculados", + "regular": "Regular" + }, + "third_party_kind_badge": { + "programmatic": "Programáticas", + "standard": "Estándar" } } diff --git a/src/modules/translation/languages/zh.json b/src/modules/translation/languages/zh.json index 3070eb835..93a16127d 100644 --- a/src/modules/translation/languages/zh.json +++ b/src/modules/translation/languages/zh.json @@ -386,6 +386,17 @@ "finished_partial_successfully_subtitle": "您已更新 {number_of_items} 个项目,但 {number_of_failed_items} 个失败。请查看提供的 URN", "saved_items_table_title": "更新项目" }, + "third_party_kind_selector": { + "standard": { + "title": "标准链接可穿戴物品", + "subtitle": "手工制作的可穿戴物品。每件可穿戴物品都是单独制作的。每件可穿戴物品的提交费用为 {price} 美元。" + }, + "programmatic": { + "title": "程序化链接的可穿戴物品", + "subtitle": "每件可穿戴物品的部件或特征都是手工制作的,但特征的混合是通过代码完成的。提交每件收藏品的费用为 {price} 美元。" + }, + "action": "选择" + }, "wrong_thumbnail_format": "thumbnail.png 文件未格式化为 PNG 图像。", "missing_mapping": "该项缺少映射。", "wrong_file_extension": "文件扩展名不正确。", @@ -1563,6 +1574,7 @@ "owner": "所有者", "link": "关联", "type": "类型", + "kind": "班级", "type_third_party": "链接的", "type_standard": "标准", "review_request": "审查请求", @@ -1628,9 +1640,6 @@ "slots_short": "插槽", "slots_long": "出版点", "migration_banner": "您收藏中的一个或多个项目必须迁移到新版本", - "type": "链接的可穿戴物品", - "programmatic": "程序化", - "standard": "标准的", "synced_filter": { "all": "所有项目", "synced": "同步的", @@ -1873,7 +1882,12 @@ }, "pay_publication_fee_step": { "title": "确认项目", - "subtitle": "您即将发送审查的集合 \"{collection_name}\" 包括 {count} 个项目。为了支持策展人委员会和DAO宝藏,每个项目的出版费用为 {currency} {publication_fee}(用MANA支付)。", + "third_parties": { + "subtitle": "您即将发送包含 {count} {count, plural, one {item} other {items}} 的收藏集 \"{collection_name}\" 进行审核。为了支持策展委员会和 DAO 宝藏,每个收藏集仅需支付一次出版费 {currency} {publication_fee}(以 MANA 支付)。" + }, + "standard": { + "subtitle": "您即将发送审查的集合 \"{collection_name}\" 包括 {count} 个项目。为了支持策展人委员会和DAO宝藏,每个项目的出版费用为 {currency} {publication_fee}(用MANA支付)。" + }, "learn_more": "了解出版费", "already_payed": "已付款但未发布", "already_published": "已发布", @@ -2386,5 +2400,13 @@ "push_changes_modal": { "title": "推送变更", "description": "自上次策展人审核以来,馆藏或其包含的物品已发生更改。{br}为了使这些更改反映在世界上,委员会必须再次审核和批准这些更改。{br }您确定要推送更改以供审核吗?" + }, + "collection_type_badge": { + "third_party": "链接可穿戴物品", + "regular": "常规的" + }, + "third_party_kind_badge": { + "programmatic": "程序化", + "standard": "标准" } } From 5b309ba4847e33134b251a9794efbce376bc44da Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Mon, 7 Oct 2024 18:04:19 -0300 Subject: [PATCH 2/5] fix: Test --- .../Modals/PublishWizardCollectionModal/hooks.spec.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Modals/PublishWizardCollectionModal/hooks.spec.tsx b/src/components/Modals/PublishWizardCollectionModal/hooks.spec.tsx index f870c6e5d..4d926c298 100644 --- a/src/components/Modals/PublishWizardCollectionModal/hooks.spec.tsx +++ b/src/components/Modals/PublishWizardCollectionModal/hooks.spec.tsx @@ -53,7 +53,8 @@ describe('when using the third party price hook', () => { }, programmatic: { mana: '3', - usd: '4' + usd: '4', + minSlots: '1' } } From a038bb8b20059388b8466aab8fcd45bcff5ef939 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Tue, 8 Oct 2024 09:18:40 -0300 Subject: [PATCH 3/5] feat: Add correct image for programmatic and standard third parties --- .../CreateAndEditMultipleItemsModal.module.css | 5 +++-- .../CreateAndEditMultipleItemsModal.tsx | 9 ++++----- src/images/programmatic.webp | Bin 0 -> 1916 bytes src/images/standard.webp | Bin 0 -> 2146 bytes 4 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 src/images/programmatic.webp create mode 100644 src/images/standard.webp diff --git a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.module.css b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.module.css index 1a60ab19d..876f3a1a0 100644 --- a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.module.css +++ b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.module.css @@ -108,11 +108,12 @@ display: flex; gap: 24px; flex-direction: row; + align-items: center; } .item img { width: 225px; - height: 120px; + height: 88px; } .description { @@ -133,7 +134,7 @@ .action { display: flex; - justify-content: end; + justify-content: start; } .loader { diff --git a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.tsx b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.tsx index 799f84a7d..925e5c797 100644 --- a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.tsx +++ b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.tsx @@ -40,9 +40,8 @@ import { generateCatalystImage, getModelPath } from 'modules/item/utils' import { ThumbnailFileTooBigError } from 'modules/item/errors' import ItemImport from 'components/ItemImport' import { InfoIcon } from 'components/InfoIcon' -// These images are place-holders for the real ones -import collectionsImage from '../../../images/collections.png' -import linkedCollectionsImage from '../../../images/linked-collections.png' +import standardKindImage from '../../../images/standard.webp' +import programmaticKindImage from '../../../images/programmatic.webp' import { useThirdPartyPrice } from '../PublishWizardCollectionModal/hooks' import { CreateOrEditMultipleItemsModalType, @@ -502,7 +501,7 @@ export const CreateAndEditMultipleItemsModal: FC = (props: Props) => { subtitle: t('create_and_edit_multiple_items_modal.third_party_kind_selector.standard.subtitle', { price: ethers.utils.formatEther(thirdPartyPrice?.item.usd ?? 0) }), - img: collectionsImage, + img: standardKindImage, action: () => handleSetThirdPartyType(false) }, { @@ -510,7 +509,7 @@ export const CreateAndEditMultipleItemsModal: FC = (props: Props) => { subtitle: t('create_and_edit_multiple_items_modal.third_party_kind_selector.programmatic.subtitle', { price: ethers.utils.formatEther(thirdPartyPrice?.programmatic.usd ?? 0) }), - img: linkedCollectionsImage, + img: programmaticKindImage, action: () => handleSetThirdPartyType(true) } ].map(({ title, subtitle, img, action }, index) => ( diff --git a/src/images/programmatic.webp b/src/images/programmatic.webp new file mode 100644 index 0000000000000000000000000000000000000000..057ef2afb36e11a3dd2c9e08e15ac7043c21dd31 GIT binary patch literal 1916 zcmV-?2ZQ)hNk&F=2LJ$9MM6+kP&il$0000G0002w0037206|PpNE!hE00DqjZExM? zgCGck5D0+~2xhP_a2Tu%8U}?B2!S97hM?Vglci|C+K6D}wv8hia~*82egW_y`h41% zaTUYm>=WLa`ioQm)oKyx57XuBGemvn6RcL5|84-wy-sjIPTe+Cuu*su zuae~4|L==n=5iAmduH#AbC0GhLi4Yg8X4Q{f65T}Is3KHNy$Csm9)|shmfhr;{1X) zN}E-l`SFsvvSq^DP}$E@#oXAu2N|Mi+YQ69U*uTkjl(1|XJ7EX&E=J_`+fCEhMkBe zFEe78_tk$$kEg*bQ;HO0YtWGh>o=MUavbXNX)|XawG8RHd1rjc^G>ojjHoXVJ2(LP z5i7=6h+LHjzuyw;Pj{!5?RpOl4-~JeC}n}27Ng0m>K`G<+GUSU)98@Rdg1pYfwI6+ zEX*xpTriW(Y5~6+dE2Ug2XcG>0&T?JQlK&fJiY}&^A&2PD>n2Ohus~$db(YoMCbDv z|MP%107EnW;139#cjWW`{rwcOm47m6+uw)_JvvAgons2)dVKk9==UzfZ-`Xpe*1=MN&2^`v=%+ z!^nYuMuio-`s%DufQbJDpMy3@wEEReMLT28qp-$0#7Jm19w}S?JHtFro>B9dvcph+ zv^S_89F^f64PyXy7Nx$mcK}d{QRsweZI@s+_0u><(q{wF>ieL+3xuq?=q=3A47A8~ zsy^U5+(@4v*(JbheRK!lX%#K7$VY?vKa4Wep(VCZ$Er7G|L29Q)PPYV2P{U=FtUoO zS)B!Ea~1Xd+(M&%O!pA?F^^pv?FO(;3%Fds;mJ&0A zr$+3VK8eNW_*chd zVH-^Q!lN$LkHx>*1$d8;&QC;T3tDn>gCF|@%5a}WJ7Bc~?bq2qCBy(E>FihY#Rz1M z;3;9zJ?NZTvJg-L5Bj_;y84+=UVjCVs+qM;8U;giskIU3G^yo}yhET&uIJQx1V}As zyRx~D>EJZaTn14sW&i#(*U85>UAN6zL3h>RjWh^^ z{T3_7kGVC^ief%{8BwM_ABeFZ$eRNcO*$@-&(9z;$mcXj6g&V2eJssi|Nix$1v1g5 z#G6}&6rjKi%@m4>iwQgm<&3RPM{O0&((j3JYp%TjFy{Ibp9BOot=qn zvXM{KeAatB%B~>VtAW7%&!Kd>rc0;m&)`l>WVc=2Z>8AOsG=J?9%JXq97A?ivrRRY z7UG#5vQ85iSUu;jibmf#!wI`sWa6vFxAJE_W)xMLTfs`zN8ly|Z)q>MQ*!pOUF+~~ zaOD%1i{t#kHu%{!U)VnZ`2WL&Fj|KzuM3I%tKv!romJkhna@d-{UWs?kAGK;=1^aZ^&BaMwrs4RO7MYK$ZTI?QxBdB5Q)j~~i!_QgpZNu1bmp&)OD^{g}s8!{F#Ww7m z;5IxQ9gRzK_ojvrF&ds5>bl+{xOe0jiLtyeKYl#c4bYRy#Fv61cq?&%2W5z1B{wTM z7LD2;Bx`pnk>05TQBJ)J`ql=d#Ea@<7p^#Ml_B-l!~FFe;>aE0gO2xzSnkpzg8u}+ zZ);C0xu1W>aFpC89PEo)d9WD+mEbWd07y%PjUp`hMn0V`l%lh=wFuxPaP@k7C$*$o zy$Mm;{q#D9DtP42iHbOkd4^3Z-ao_0W-VkvIP%}D0X5#W*;WPEi3$OUjC`G464sUJ z-RPJ&oJ%gP@NvM_J8==X?B)N!#>jDXGvf|SDTwES(3IHQRk!*i004LUisC?00000A ClBOd7 literal 0 HcmV?d00001 diff --git a/src/images/standard.webp b/src/images/standard.webp new file mode 100644 index 0000000000000000000000000000000000000000..eab146388e5c4e8cfbef5d55e113bdb2e18ac016 GIT binary patch literal 2146 zcmV-o2%Yy*Nk&Fm2mk&bUc=3j`23Bc7dEW0_MR+k>Z_e(YB zv(kXYbzf~iW>in8sb!yJAX*_Dqa64U5 zoMh8z)STr02W>3*F2R_y-1Iwho|jlA=Xw8TaHhLR)R`W?7*#crGgVE1!MT<=)VUsk zO4c05N>+tB*)@)xEJtC>B?@u6X1cm4dcIb=N)&~!jjkbz;qc$#zr%lr{|^8Cwd$?4 zDTRu=`$ZCH{^F)tl11)Q8u^q)evw>|aemTz2RF?G zHGgI65@Ge%Zl^rhUd(+;BVW?Ucg-R<%_6rhi`>UF@;;5$KaMnsT8A7mMGozX9NGjq zG~>PrGa?J-7i&GzK(kt&U(qiXnqov0b1u0$EW)kc#c zR@rH4`WXNf~>>!y0Ozn1U(u%tKeqcm}#>h zY@Lmmwkqx_Wvh)4t80(o`?VS&wy%TWN>)%fAPfco0PrvXodGJy0k8l*kw&0PC8MJu zqS3kluo4MrZsBvmTU;#oI3B!7*`_;n9J~#b{|9uXO+(15=SOg~m8V4aL0*ozeoE7# zi&(Bs?(XSF0V|Z`HCU3g=%Uq_HvXlrs6*I0--$&t!e(Y>W@cMi;E^EghG!IuY=G3g z501}@ebXiTvpyhEOz@V(`C?6`w390z@L)mOiMMg^bU*B5BvW(gMR(EU!C`>ymm{m& zE2)M!Fzp9(PZZ=%XVnKHBI5Lw=sr0`*A&oSKs!8d1NPbJIUpF0SbC-7M*6I+nlG63 z@B4wR#(w6bhjEwaT&_r%RUxP-;6t<17fzAwZkoy($EDDUb5qWXoXsP3Y6lbpN5no! z(kDv2H!pyAYQy_InL$<-21A8n4A77%MgC%0p6WY;3hdF9WNBWPds>G4I2s>Jf z_f#gV&^EV$Tm1CsO|-JGe?uVNu#k37+)`I5x2*@9@{UQ}-QC?N=yG(T+fIrtUXs0G zGczq>Uc4fP8ynW{QOP^GfB^pdT#~kSMv%ZIWNdWyRY!mK8~^=?fBlc||J+F8auMK8 z-`UN7e?R~22j-f*anf2r^3jMT+A7`w7&8J0mMgYcCCr8HtM6<|QNOky)0U7gNK_<` z7JC_F9&IQfOnn3z@I! zkY@F`)8)dlcrjyd>+Br_Zc%u>x-X3r?cH)DuY zf1@46Q_#Am#ZW5rGgf9qD)ed5!(LIP9zb;RF4PC&b7q3&3!oN5DmE=32SGC#pvZjCon zlQV3{;Cb^B09adQdpBkp*_}zM@Dp4NwZtW}n&weKjBX&Z{yUGLeAvk2L>Cb1T5l-`G3zJeF~u<1uel|;`CrOb1mxqzD^7Z+27Tt;Nbz%0UsTYQ4lS|_I;gp z+$L1qRm~3H&`+sVBJbv*G@Qi*f3 zYocQ-Qs*cP%^DsZf#=``HL@+s5sfJ7f{4skstAt<3r9nXEF+CRyN#eB^SC92j}Cb4 zOJzE(vWW~i?bu-LD;jUalD+IyaAkJ0H}*x}YIWT`lo7;Mg$tz4zu7OMeCch4b4t#c ztIots$2^?;=B6~4;1eC{=VX~N3>ZtVyf)j@;m*-pt2I!Np$buw`aq}=?1d_Tt4)8i zBP=?)q Date: Tue, 8 Oct 2024 09:21:24 -0300 Subject: [PATCH 4/5] fix: Styles --- .../CreateAndEditMultipleItemsModal.module.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.module.css b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.module.css index 876f3a1a0..ca54f4a28 100644 --- a/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.module.css +++ b/src/components/Modals/CreateAndEditMultipleItemsModal/CreateAndEditMultipleItemsModal.module.css @@ -119,7 +119,6 @@ .description { display: flex; flex-direction: column; - gap: 16px; } .title { @@ -130,11 +129,13 @@ .subtitle { font-size: 14px; + color: #cfcdd4; } .action { display: flex; justify-content: start; + margin-top: 16px; } .loader { From c054fb2214337719a10f9082b3b35f813bdbb5a3 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Tue, 8 Oct 2024 15:50:38 -0300 Subject: [PATCH 5/5] fix: Correct price --- .../PayPublicationFeeStep/PayPublicationFeeStep.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/Modals/PublishWizardCollectionModal/PayPublicationFeeStep/PayPublicationFeeStep.tsx b/src/components/Modals/PublishWizardCollectionModal/PayPublicationFeeStep/PayPublicationFeeStep.tsx index 460f888ca..bfba86251 100644 --- a/src/components/Modals/PublishWizardCollectionModal/PayPublicationFeeStep/PayPublicationFeeStep.tsx +++ b/src/components/Modals/PublishWizardCollectionModal/PayPublicationFeeStep/PayPublicationFeeStep.tsx @@ -44,11 +44,16 @@ export const PayPublicationFeeStep: React.FC< const isThirdParty = useMemo(() => isTPCollection(collection), [collection]) const availableSlots = useMemo(() => thirdParty?.availableSlots ?? 0, [thirdParty?.availableSlots]) const amountOfItemsToPublish = useMemo( - () => (thirdParty?.isProgrammatic ? 0 : itemsToPublish.length - availableSlots > 0 ? itemsToPublish.length - availableSlots : 0), + () => + thirdParty?.isProgrammatic && thirdParty.published + ? 0 + : itemsToPublish.length - availableSlots > 0 + ? itemsToPublish.length - availableSlots + : 0, [thirdParty, itemsToPublish, availableSlots] ) const amountOfItemsAlreadyPayed = useMemo( - () => (thirdParty?.isProgrammatic ? itemsToPublish.length : amountOfItemsToPublish - itemsToPublish.length), + () => (thirdParty?.isProgrammatic && thirdParty.published ? itemsToPublish.length : amountOfItemsToPublish - itemsToPublish.length), [amountOfItemsToPublish, itemsToPublish.length] ) const amountOfItemsAlreadyPublishedWithChanges = useMemo(() => itemsWithChanges.length, [itemsWithChanges])