diff --git a/package-lock.json b/package-lock.json index 263b284e7..a0aec3c1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@dcl/crypto": "^3.4.5", "@dcl/hashing": "^3.0.4", "@dcl/mini-rpc": "^1.0.7", - "@dcl/schemas": "^14.0.0", + "@dcl/schemas": "^14.1.0", "@dcl/sdk": "7.5.5", "@dcl/single-sign-on-client": "^0.1.0", "@dcl/ui-env": "^1.5.0", @@ -47,7 +47,7 @@ "decentraland-dapps": "^23.6.1", "decentraland-ecs": "6.12.4-7784644013.commit-f770b3e", "decentraland-experiments": "^1.0.2", - "decentraland-transactions": "^2.15.0", + "decentraland-transactions": "^2.16.0", "decentraland-ui": "^6.9.2", "ethers": "^5.6.8", "file-saver": "^2.0.1", @@ -2748,9 +2748,10 @@ } }, "node_modules/@dcl/schemas": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-14.0.0.tgz", - "integrity": "sha512-w3P/5g/gkYcBUB6+eN1PYdQBaiS8nLh7ldGfUI6GuiHQKvPIC3wS26Dx5i2ukwHrds2koOaqhFdLzHLVDDitQg==", + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-14.1.0.tgz", + "integrity": "sha512-KE7679WNJqlrXH088pvXh2a6YLtDL+PvZYXY8ZXShhEJrnxjUhXGYH1LJckPLy4Tb7GEJBwaCcfYlVVVMrDLeA==", + "license": "Apache-2.0", "dependencies": { "ajv": "^8.11.0", "ajv-errors": "^3.0.0", @@ -11943,9 +11944,9 @@ } }, "node_modules/decentraland-transactions": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/decentraland-transactions/-/decentraland-transactions-2.15.0.tgz", - "integrity": "sha512-vWKaxCldMRc2Uy5kUJnV9ggIyQqGWn766wJr+X+/FeXgjtDvBNMLSdgd2EolbxnZ+DHLWCXsDBmJviRdeJ7d3Q==", + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/decentraland-transactions/-/decentraland-transactions-2.16.0.tgz", + "integrity": "sha512-rvpRnL+qMNkD9cSj6UUb+QsYNEJE57Tr/YuM3IzBpdxdlzlUr4NBkvP5p/ePnbarixgK39iciKhLo7P/t53TkQ==", "dependencies": { "@0xsquid/sdk": "^2.8.13", "@0xsquid/squid-types": "^0.1.78", diff --git a/package.json b/package.json index a9e0101d5..d8eca1a48 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "@dcl/crypto": "^3.4.5", "@dcl/hashing": "^3.0.4", "@dcl/mini-rpc": "^1.0.7", - "@dcl/schemas": "^14.0.0", + "@dcl/schemas": "^14.1.0", "@dcl/sdk": "7.5.5", "@dcl/single-sign-on-client": "^0.1.0", "@dcl/ui-env": "^1.5.0", @@ -41,7 +41,7 @@ "decentraland-dapps": "^23.6.1", "decentraland-ecs": "6.12.4-7784644013.commit-f770b3e", "decentraland-experiments": "^1.0.2", - "decentraland-transactions": "^2.15.0", + "decentraland-transactions": "^2.16.0", "decentraland-ui": "^6.9.2", "ethers": "^5.6.8", "file-saver": "^2.0.1", diff --git a/src/components/CollectionDetailPage/CollectionDetailPage.css b/src/components/CollectionDetailPage/CollectionDetailPage.css index b8f3e8203..aece15d4a 100644 --- a/src/components/CollectionDetailPage/CollectionDetailPage.css +++ b/src/components/CollectionDetailPage/CollectionDetailPage.css @@ -357,3 +357,8 @@ .CollectionDetailPage.popup-mint:before { background-color: var(--smart-grey) !important; } + +.toast-info .body { + max-width: 450px; + overflow: hidden; +} diff --git a/src/components/CollectionDetailPage/CollectionItem/CollectionItem.container.ts b/src/components/CollectionDetailPage/CollectionItem/CollectionItem.container.ts index e8adeedc3..398ffb2b3 100644 --- a/src/components/CollectionDetailPage/CollectionItem/CollectionItem.container.ts +++ b/src/components/CollectionDetailPage/CollectionItem/CollectionItem.container.ts @@ -2,29 +2,40 @@ import { connect } from 'react-redux' import { getAddress } from 'decentraland-dapps/dist/modules/wallet/selectors' import { RootState } from 'modules/common/types' import { openModal } from 'decentraland-dapps/dist/modules/modal/actions' -import { deleteItemRequest } from 'modules/item/actions' -import { getStatusByItemId } from 'modules/item/selectors' +import { + CANCEL_ITEM_ORDER_TRADE_REQUEST, + cancelItemOrderTradeRequest, + CancelItemOrderTradeRequestAction, + deleteItemRequest +} from 'modules/item/actions' +import { getLoading, getStatusByItemId } from 'modules/item/selectors' import { setItems } from 'modules/editor/actions' import { MapStateProps, MapDispatch, MapDispatchProps, OwnProps } from './CollectionItem.types' import CollectionItem from './CollectionItem' import { getIsOffchainPublicItemOrdersEnabled } from 'modules/features/selectors' import { getWallet } from 'modules/wallet/selectors' +import { isLoadingType } from 'decentraland-dapps/dist/modules/loading' const mapState = (state: RootState, ownProps: OwnProps): MapStateProps => { const statusByItemId = getStatusByItemId(state) - + const loadingTradeIds = getLoading(state) + .filter(action => action.type === CANCEL_ITEM_ORDER_TRADE_REQUEST) + .map(action => (action as CancelItemOrderTradeRequestAction).payload.tradeId) return { ethAddress: getAddress(state), status: statusByItemId[ownProps.item.id], wallet: getWallet(state), - isOffchainPublicItemOrdersEnabled: getIsOffchainPublicItemOrdersEnabled(state) + isOffchainPublicItemOrdersEnabled: getIsOffchainPublicItemOrdersEnabled(state), + isCancellingItemOrder: isLoadingType(getLoading(state), CANCEL_ITEM_ORDER_TRADE_REQUEST), + loadingTradeIds } } const mapDispatch = (dispatch: MapDispatch): MapDispatchProps => ({ onOpenModal: (name, metadata) => dispatch(openModal(name, metadata)), onDeleteItem: item => dispatch(deleteItemRequest(item)), - onSetItems: items => dispatch(setItems(items)) + onSetItems: items => dispatch(setItems(items)), + onRemoveFromSale: tradeId => dispatch(cancelItemOrderTradeRequest(tradeId, true)) }) export default connect(mapState, mapDispatch)(CollectionItem) diff --git a/src/components/CollectionDetailPage/CollectionItem/CollectionItem.tsx b/src/components/CollectionDetailPage/CollectionItem/CollectionItem.tsx index 3e59a3458..c7d80643b 100644 --- a/src/components/CollectionDetailPage/CollectionItem/CollectionItem.tsx +++ b/src/components/CollectionDetailPage/CollectionItem/CollectionItem.tsx @@ -25,20 +25,29 @@ const LENGTH_LIMIT = 25 export default function CollectionItem({ onOpenModal, onSetItems, + onRemoveFromSale, item, isOffchainPublicItemOrdersEnabled, collection, status, ethAddress, - wallet + wallet, + loadingTradeIds, + isCancellingItemOrder }: Props) { analytics = getAnalytics() const history = useHistory() const isOnSaleLegacy = wallet && isOnSale(collection, wallet) const isEnableForSaleOffchainMarketplace = wallet && isOffchainPublicItemOrdersEnabled && isEnableForSaleOffchain(collection, wallet) - const shouldAllowPriceEdition = !isOffchainPublicItemOrdersEnabled || isEnableForSaleOffchainMarketplace || isOnSaleLegacy + const shouldAllowPriceEdition = + !isOffchainPublicItemOrdersEnabled || (isEnableForSaleOffchainMarketplace && item.tradeId) || isOnSaleLegacy const handleEditPriceAndBeneficiary = useCallback(() => { + if (isOffchainPublicItemOrdersEnabled && isEnableForSaleOffchainMarketplace) { + onOpenModal('PutForSaleOffchainModal', { itemId: item.id }) + return + } + onOpenModal('EditPriceAndBeneficiaryModal', { itemId: item.id }) }, [item, onOpenModal]) @@ -69,6 +78,13 @@ export default function CollectionItem({ onOpenModal('PutForSaleOffchainModal', { itemId: item.id }) }, []) + const handleRemoveFromSale = useCallback(() => { + if (!item.tradeId) { + return + } + onRemoveFromSale(item.tradeId) + }, [item]) + const renderPrice = useCallback(() => { if (!item.price) { return ( @@ -78,12 +94,12 @@ export default function CollectionItem({ ) } - if (isFree(item)) { - return {t('global.free')} + if (item.price === ethers.constants.MaxUint256.toString() || (isOffchainPublicItemOrdersEnabled && !isOnSaleLegacy && !item.tradeId)) { + return - } - if (item.price === ethers.constants.MaxUint256.toString()) { - return - + if (isFree(item)) { + return {t('global.free')} } return ( @@ -91,7 +107,7 @@ export default function CollectionItem({ {ethers.utils.formatEther(item.price)} ) - }, [item, handleEditPriceAndBeneficiary]) + }, [item, isOnSaleLegacy, isOffchainPublicItemOrdersEnabled, handleEditPriceAndBeneficiary]) const renderItemStatus = useCallback(() => { return status === SyncStatus.UNSYNCED ? ( @@ -214,13 +230,25 @@ export default function CollectionItem({ ) : null} {renderItemStatus()} - {isOffchainPublicItemOrdersEnabled && !isOnSaleLegacy && ( + {isOffchainPublicItemOrdersEnabled && !isOnSaleLegacy && !item.tradeId && ( )} + {isOffchainPublicItemOrdersEnabled && item.tradeId && ( + + + + )} {renderItemContextMenu()} ) diff --git a/src/components/CollectionDetailPage/CollectionItem/CollectionItem.types.ts b/src/components/CollectionDetailPage/CollectionItem/CollectionItem.types.ts index 5b2b26b6a..d0d1c8ffa 100644 --- a/src/components/CollectionDetailPage/CollectionItem/CollectionItem.types.ts +++ b/src/components/CollectionDetailPage/CollectionItem/CollectionItem.types.ts @@ -2,7 +2,7 @@ import { Dispatch } from 'redux' import { Collection } from 'modules/collection/types' import { Item, SyncStatus } from 'modules/item/types' import { openModal, OpenModalAction } from 'decentraland-dapps/dist/modules/modal/actions' -import { deleteItemRequest, DeleteItemRequestAction } from 'modules/item/actions' +import { CancelItemOrderTradeRequestAction, deleteItemRequest, DeleteItemRequestAction } from 'modules/item/actions' import { setItems, SetItemsAction } from 'modules/editor/actions' import { Wallet } from 'decentraland-dapps/dist/modules/wallet' @@ -13,12 +13,18 @@ export type Props = { status: SyncStatus isOffchainPublicItemOrdersEnabled: boolean wallet: Wallet | null + isCancellingItemOrder: boolean + loadingTradeIds: string[] onOpenModal: typeof openModal onDeleteItem: typeof deleteItemRequest onSetItems: typeof setItems + onRemoveFromSale: (tradeId: string) => void } -export type MapStateProps = Pick -export type MapDispatchProps = Pick -export type MapDispatch = Dispatch +export type MapStateProps = Pick< + Props, + 'ethAddress' | 'status' | 'isOffchainPublicItemOrdersEnabled' | 'wallet' | 'isCancellingItemOrder' | 'loadingTradeIds' +> +export type MapDispatchProps = Pick +export type MapDispatch = Dispatch export type OwnProps = Pick diff --git a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.tsx b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.tsx index 1222fa6a6..17ac08824 100644 --- a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.tsx +++ b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.tsx @@ -363,7 +363,7 @@ export default class CreateSingleItemModal extends React.PureComponent { item, collection, isLoading: isLoadingType(getLoading(state), CREATE_ITEM_ORDER_TRADE_REQUEST), + isLoadingCancel: isLoadingType(getLoading(state), CANCEL_ITEM_ORDER_TRADE_REQUEST), error: getError(state) } } const mapDispatch = (dispatch: MapDispatch): MapDispatchProps => ({ onCreateItemOrder: (item: Item, priceInWei: string, beneficiary: string, collection: Collection, expiresAt: Date) => - dispatch(createItemOrderTradeRequest(item, priceInWei, beneficiary, collection, expiresAt)) + dispatch(createItemOrderTradeRequest(item, priceInWei, beneficiary, collection, expiresAt)), + onRemoveFromSale: (tradeId: string) => dispatch(cancelItemOrderTradeRequest(tradeId)) }) export default connect(mapState, mapDispatch)(PutForSaleOffchainModal) diff --git a/src/components/Modals/PutForSaleOffchainModal/PutForSaleOffchainModal.module.css b/src/components/Modals/PutForSaleOffchainModal/PutForSaleOffchainModal.module.css new file mode 100644 index 000000000..191193003 --- /dev/null +++ b/src/components/Modals/PutForSaleOffchainModal/PutForSaleOffchainModal.module.css @@ -0,0 +1,48 @@ +.modalTitle { + font-size: 20px; + font-weight: 700; + line-height: 32px; + text-align: center; +} + +.modalSubtitle { + font-size: 16px; + line-height: 24px; + text-align: center; +} + +:global(.PutForSaleOffchainModal.ui.modal > .content) { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.modalContent { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + max-width: 500px; + padding: 40px 0; +} + +.modalIcon { + background: var(--secondary-text); + border-radius: 50%; + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; +} + +.modalIcon :global(i.icon) { + margin: 0; +} + +.error { + max-width: 100%; + overflow: hidden; + color: var(--error); +} diff --git a/src/components/Modals/PutForSaleOffchainModal/PutForSaleOffchainModal.tsx b/src/components/Modals/PutForSaleOffchainModal/PutForSaleOffchainModal.tsx index 98dd05353..137302820 100644 --- a/src/components/Modals/PutForSaleOffchainModal/PutForSaleOffchainModal.tsx +++ b/src/components/Modals/PutForSaleOffchainModal/PutForSaleOffchainModal.tsx @@ -1,8 +1,22 @@ import { Props } from './PutForSaleOffchainModal.types' +import Modal from 'decentraland-dapps/dist/containers/Modal' import EditPriceAndBeneficiaryModal from '../EditPriceAndBeneficiaryModal/EditPriceAndBeneficiaryModal' import { Item } from 'modules/item/types' +import { Button, Icon, Loader, ModalActions, ModalContent } from 'decentraland-ui' +import styles from './PutForSaleOffchainModal.module.css' +import { t } from 'decentraland-dapps/dist/modules/translation' -export default function PutForSaleOffchainModal({ item, collection, error, metadata, isLoading, onClose, onCreateItemOrder }: Props) { +export default function PutForSaleOffchainModal({ + item, + collection, + error, + metadata, + isLoading, + isLoadingCancel, + onClose, + onCreateItemOrder, + onRemoveFromSale +}: Props) { const handlePutForSale = (_itemId: string, price: string, beneficiary: string, expiresAt = new Date()) => { if (!collection || !item) { console.error('Collection or item not found') @@ -11,6 +25,35 @@ export default function PutForSaleOffchainModal({ item, collection, error, metad onCreateItemOrder(item as Item, price, beneficiary, collection, expiresAt) } + const handleRemoveFromSale = () => { + onRemoveFromSale(item.tradeId!) + } + + if (item.tradeId) { + return ( + + +
+
+ {isLoadingCancel ? : } +
+

{t('put_for_sale_offchain_modal.title')}

+

{t('put_for_sale_offchain_modal.subtitle')}

+
+ {error ? {error} : null} +
+ + + + +
+ ) + } + return ( isLoading: boolean + isLoadingCancel: boolean error: string | null collection?: Collection onCreateItemOrder: typeof createItemOrderTradeRequest + onRemoveFromSale: typeof cancelItemOrderTradeRequest } export type EditPriceAndBeneficiaryModalMetadata = { @@ -17,6 +24,6 @@ export type EditPriceAndBeneficiaryModalMetadata = { } export type OwnProps = Pick -export type MapStateProps = Pick -export type MapDispatchProps = Pick -export type MapDispatch = Dispatch +export type MapStateProps = Pick +export type MapDispatchProps = Pick +export type MapDispatch = Dispatch diff --git a/src/lib/api/marketplace.ts b/src/lib/api/marketplace.ts index 4cf5236b4..c4dfefccf 100644 --- a/src/lib/api/marketplace.ts +++ b/src/lib/api/marketplace.ts @@ -1,10 +1,10 @@ import { gql } from 'apollo-boost' import { config } from 'config' import { createClient } from './graph' +import { BaseAPI } from 'decentraland-dapps/dist/lib' export const MARKETPLACE_GRAPH_URL = config.get('MARKETPLACE_GRAPH_URL', '') const marketplaceGraphClient = createClient(MARKETPLACE_GRAPH_URL) - const BATCH_SIZE = 1000 const getSubdomainQuery = () => gql` @@ -52,7 +52,7 @@ type OwnerByNameQueryResult = { nfts: OwnerByNameTuple[] } -export class MarketplaceAPI { +export class MarketplaceAPI extends BaseAPI { public async fetchENSOwnerByDomain(domains: string[]): Promise> { if (!domains) { return {} @@ -102,6 +102,10 @@ export class MarketplaceAPI { } return results } + + public async fetchCollectionItems(collectionAddress: string) { + return this.request('get', `/items?contractAddress=${collectionAddress}`) + } } -export const marketplace = new MarketplaceAPI() +export const marketplace = new MarketplaceAPI(config.get('MARKETPLACE_API')) diff --git a/src/modules/item/actions.ts b/src/modules/item/actions.ts index 8f80e701e..388eb6b0c 100644 --- a/src/modules/item/actions.ts +++ b/src/modules/item/actions.ts @@ -1,5 +1,5 @@ import { action } from 'typesafe-actions' -import { ChainId, TradeCreation } from '@dcl/schemas' +import { ChainId, Trade } from '@dcl/schemas' import { buildTransactionPayload } from 'decentraland-dapps/dist/modules/transaction/utils' import { PaginationStats } from 'lib/api/pagination' import { FetchCollectionItemsParams } from 'lib/api/builder' @@ -286,9 +286,27 @@ export const CREATE_ITEM_ORDER_TRADE_FAILURE = '[Failure] Create Item Order Trad export const createItemOrderTradeRequest = (item: Item, priceInWei: string, beneficiary: string, collection: Collection, expiresAt: Date) => action(CREATE_ITEM_ORDER_TRADE_REQUEST, { item, priceInWei, beneficiary, collection, expiresAt }) -export const createItemOrderTradeSuccess = (trade: TradeCreation) => action(CREATE_ITEM_ORDER_TRADE_SUCCESS, { trade }) +export const createItemOrderTradeSuccess = (trade: Trade, item: Item, priceInWei: string, beneficiary: string, expiresAt: number) => + action(CREATE_ITEM_ORDER_TRADE_SUCCESS, { trade, priceInWei, beneficiary, expiresAt, item }) export const createItemOrderTradeFailure = (error: string) => action(CREATE_ITEM_ORDER_TRADE_FAILURE, { error }) export type CreateItemOrderTradeRequestAction = ReturnType export type CreateItemOrderTradeSuccessAction = ReturnType export type CreateItemOrderTradeFailureAction = ReturnType + +export const CANCEL_ITEM_ORDER_TRADE_REQUEST = '[Request] Cancel Item Order Trade' +export const CANCEL_ITEM_ORDER_TRADE_SUCCESS = '[Success] Cancel Item Order Trade' +export const CANCEL_ITEM_ORDER_TRADE_FAILURE = '[Failure] Cancel Item Order Trade' + +export const CANCEL_ITEM_ORDER_TRADE_TX_SUCCESS = '[Success] Cancel Item Order Trade Tx' + +export const cancelItemOrderTradeRequest = (tradeId: string, errorToast = false) => + action(CANCEL_ITEM_ORDER_TRADE_REQUEST, { tradeId, errorToast }) +export const cancelItemOrderTradeTxSuccess = (trade: Trade, txHash: string) => + action(CANCEL_ITEM_ORDER_TRADE_TX_SUCCESS, buildTransactionPayload(trade.chainId, txHash, { tradeId: trade.id })) +export const cancelItemOrderTradeSuccess = (tradeId: string) => action(CANCEL_ITEM_ORDER_TRADE_SUCCESS, { tradeId }) +export const cancelItemOrderTradeFailure = (error: string) => action(CANCEL_ITEM_ORDER_TRADE_FAILURE, { error }) + +export type CancelItemOrderTradeRequestAction = ReturnType +export type CancelItemOrderTradeSuccessAction = ReturnType +export type CancelItemOrderTradeFailureAction = ReturnType diff --git a/src/modules/item/reducer.ts b/src/modules/item/reducer.ts index b2ed8ed36..4bd468644 100644 --- a/src/modules/item/reducer.ts +++ b/src/modules/item/reducer.ts @@ -106,7 +106,13 @@ import { CreateItemOrderTradeSuccessAction, CREATE_ITEM_ORDER_TRADE_REQUEST, CREATE_ITEM_ORDER_TRADE_FAILURE, - CREATE_ITEM_ORDER_TRADE_SUCCESS + CREATE_ITEM_ORDER_TRADE_SUCCESS, + CANCEL_ITEM_ORDER_TRADE_REQUEST, + CancelItemOrderTradeRequestAction, + CancelItemOrderTradeSuccessAction, + CancelItemOrderTradeFailureAction, + CANCEL_ITEM_ORDER_TRADE_FAILURE, + CANCEL_ITEM_ORDER_TRADE_SUCCESS } from './actions' import { PublishThirdPartyItemsSuccessAction, @@ -120,6 +126,7 @@ import { toItemObject } from './utils' import { Item, BlockchainRarity } from './types' import { buildCatalystItemURN, buildThirdPartyURN, decodeURN, isThirdPartyCollectionDecodedUrn, URNType } from 'lib/urn' import { CLOSE_MODAL, CloseModalAction } from 'decentraland-dapps/dist/modules/modal/actions' +import { ethers } from 'ethers' export type ItemPaginationData = { ids: string[] @@ -203,6 +210,9 @@ type ItemReducerAction = | CreateItemOrderTradeRequestAction | CreateItemOrderTradeFailureAction | CreateItemOrderTradeSuccessAction + | CancelItemOrderTradeRequestAction + | CancelItemOrderTradeSuccessAction + | CancelItemOrderTradeFailureAction export function itemReducer(state: ItemState = INITIAL_STATE, action: ItemReducerAction): ItemState { switch (action.type) { @@ -225,17 +235,29 @@ export function itemReducer(state: ItemState = INITIAL_STATE, action: ItemReduce case RESET_ITEM_REQUEST: case RESCUE_ITEMS_REQUEST: case DOWNLOAD_ITEM_REQUEST: - case CREATE_ITEM_ORDER_TRADE_REQUEST: { + case CREATE_ITEM_ORDER_TRADE_REQUEST: + case CANCEL_ITEM_ORDER_TRADE_REQUEST: { return { ...state, loading: loadingReducer(state.loading, action) } } case CREATE_ITEM_ORDER_TRADE_SUCCESS: { + const { trade, item, priceInWei, expiresAt, beneficiary } = action.payload return { ...state, error: null, - loading: loadingReducer(state.loading, action) + loading: loadingReducer(state.loading, action), + data: { + ...state.data, + [item.id]: { + ...state.data[item.id], + tradeId: trade.id, + price: priceInWei, + beneficiary: beneficiary, + tradeExpiresAt: expiresAt + } + } } } case FETCH_ORPHAN_ITEM_REQUEST: { @@ -346,7 +368,8 @@ export function itemReducer(state: ItemState = INITIAL_STATE, action: ItemReduce case RESET_ITEM_FAILURE: case RESCUE_ITEMS_FAILURE: case DOWNLOAD_ITEM_FAILURE: - case CREATE_ITEM_ORDER_TRADE_FAILURE: { + case CREATE_ITEM_ORDER_TRADE_FAILURE: + case CANCEL_ITEM_ORDER_TRADE_FAILURE: { return { ...state, loading: loadingReducer(state.loading, action), @@ -569,6 +592,28 @@ export function itemReducer(state: ItemState = INITIAL_STATE, action: ItemReduce }, {} as ItemState['data']) } } + case CANCEL_ITEM_ORDER_TRADE_SUCCESS: { + const { tradeId } = action.payload + return { + ...state, + loading: loadingReducer(state.loading, action), + error: null, + data: Object.values(state.data).reduce((accum, item) => { + if (item.tradeId === tradeId) { + accum[item.id] = { + ...item, + tradeId: undefined, + price: ethers.constants.MaxUint256.toString(), + beneficiary: undefined, + tradeExpiresAt: undefined + } + } else { + accum[item.id] = item + } + return accum + }, {} as ItemState['data']) + } + } default: return state } diff --git a/src/modules/item/sagas.spec.ts b/src/modules/item/sagas.spec.ts index b6053c0f2..b83f8dc95 100644 --- a/src/modules/item/sagas.spec.ts +++ b/src/modules/item/sagas.spec.ts @@ -105,7 +105,8 @@ const builderAPI = { fetchContents: jest.fn(), fetchCollectionItems: jest.fn(), fetchRarities: jest.fn(), - fetchItems: jest.fn() + fetchItems: jest.fn(), + fetchCollection: jest.fn() } as unknown as BuilderAPI let builderClient: BuilderClient @@ -1511,6 +1512,7 @@ describe('when handling the fetch of collection items', () => { }) it('should put a fetchCollectionItemsSuccess action with items and pagination data', () => { return expectSaga(itemSaga, builderAPI, builderClient, tradeService) + .provide([[select(getCollection, item.collectionId!), { isPublished: false } as Collection]]) .dispatch(fetchCollectionItemsRequest(item.collectionId!, { page: 1, limit: paginationData.limit })) .put( fetchCollectionItemsSuccess(item.collectionId!, [item], { @@ -1557,6 +1559,7 @@ describe('when handling the fetch of collection items pages', () => { }) it('should put a fetchCollectionItemsSuccess action with items and pagination data', () => { return expectSaga(itemSaga, builderAPI, builderClient, tradeService) + .provide([[select(getCollection, item.collectionId!), { isPublished: false } as Collection]]) .dispatch( fetchCollectionItemsRequest(item.collectionId!, { page: [1], limit: paginationData.limit, overridePaginationData: false }) ) diff --git a/src/modules/item/sagas.ts b/src/modules/item/sagas.ts index a27debd18..9123d709a 100644 --- a/src/modules/item/sagas.ts +++ b/src/modules/item/sagas.ts @@ -1,10 +1,10 @@ import PQueue from 'p-queue' import { History } from 'history' -import { Contract, providers } from 'ethers' +import { Contract, ethers, providers } from 'ethers' import { LOCATION_CHANGE } from 'connected-react-router' import { takeEvery, call, put, takeLatest, select, take, delay, fork, race, cancelled, getContext } from 'redux-saga/effects' import { channel } from 'redux-saga' -import { ChainId, Network, Entity, EntityType, WearableCategory, TradeCreation } from '@dcl/schemas' +import { ChainId, Network, Entity, EntityType, WearableCategory, TradeCreation, Trade, Item as DCLItem } from '@dcl/schemas' import { ContractName, getContract } from 'decentraland-transactions' import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { ModalState } from 'decentraland-dapps/dist/modules/modal/reducer' @@ -103,7 +103,12 @@ import { CreateItemOrderTradeRequestAction, createItemOrderTradeSuccess, createItemOrderTradeFailure, - CREATE_ITEM_ORDER_TRADE_REQUEST + CREATE_ITEM_ORDER_TRADE_REQUEST, + CANCEL_ITEM_ORDER_TRADE_REQUEST, + cancelItemOrderTradeSuccess, + cancelItemOrderTradeFailure, + CancelItemOrderTradeRequestAction, + cancelItemOrderTradeTxSuccess } from './actions' import { fromRemoteItem } from 'lib/api/transformations' import { isThirdParty } from 'lib/urn' @@ -157,6 +162,7 @@ import { import { ItemPaginationData } from './reducer' import { getSuccessfulDeletedItemToast, getSuccessfulMoveItemToAnotherCollectionToast } from './toasts' import { TradeService } from 'decentraland-dapps/dist/modules/trades/TradeService' +import { marketplace } from 'lib/api/marketplace' export const SAVE_AND_EDIT_FILES_BATCH_SIZE = 8 @@ -185,6 +191,7 @@ export function* itemSaga(legacyBuilder: LegacyBuilderAPI, builder: BuilderClien yield takeEvery(createOrEditProgressChannel, handleCreateOrEditProgress) yield takeEvery(createOrEditCancelledItemsChannel, handleCreateOrEditCancelledItems) yield takeEvery(CREATE_ITEM_ORDER_TRADE_REQUEST, handleCreateItemOrderTradeRequest) + yield takeEvery(CANCEL_ITEM_ORDER_TRADE_REQUEST, handleCancelItemOrderTradeRequest) yield takeLatestCancellable( { initializer: SAVE_MULTIPLE_ITEMS_REQUEST, cancellable: CANCEL_SAVE_MULTIPLE_ITEMS }, handleSaveMultipleItemsRequest @@ -194,9 +201,9 @@ export function* itemSaga(legacyBuilder: LegacyBuilderAPI, builder: BuilderClien function* handleCreateItemOrderTradeRequest(action: CreateItemOrderTradeRequestAction) { const { item, beneficiary, priceInWei, collection, expiresAt } = action.payload try { - const trade: TradeCreation = yield call(createItemOrderTrade, item, priceInWei, beneficiary, collection, expiresAt) - yield call([tradeService, 'addTrade'], trade) - yield put(createItemOrderTradeSuccess(trade)) + const tradeToCreate: TradeCreation = yield call(createItemOrderTrade, item, priceInWei, beneficiary, collection, expiresAt) + const trade: Trade = yield call([tradeService, 'addTrade'], tradeToCreate) + yield put(createItemOrderTradeSuccess(trade, item, priceInWei, beneficiary, expiresAt.getTime())) yield put(closeAllModals()) } catch (error) { yield put(createItemOrderTradeFailure(isErrorWithMessage(error) ? error.message : 'Unknown error')) @@ -283,7 +290,27 @@ export function* itemSaga(legacyBuilder: LegacyBuilderAPI, builder: BuilderClien isFetchingMultiplePages ? page : [page], restOfOptions ) - yield put(fetchCollectionItemsSuccess(collectionId, items, overridePaginationData ? paginationStats : undefined)) + let itemsToSave = items + let collection: Collection | undefined = yield select(getCollection, collectionId) + + if (!collection) { + collection = yield call([legacyBuilder, 'fetchCollection'], collectionId) + } + + if (collection && collection.isPublished) { + const result: { data: DCLItem[] } = yield call([marketplace, 'fetchCollectionItems'], collection.contractAddress!) + itemsToSave = items.map(item => { + const publishedItem = result.data.find(publishedItem => publishedItem.id === `${collection?.contractAddress}-${item.tokenId}`) + return { + ...item, + tradeExpiresAt: publishedItem?.tradeExpiresAt, + tradeId: publishedItem?.tradeId, + beneficiary: publishedItem?.beneficiary || item.beneficiary, + price: publishedItem?.price || item.price + } + }) + } + yield put(fetchCollectionItemsSuccess(collectionId, itemsToSave, overridePaginationData ? paginationStats : undefined)) } catch (error) { yield put(fetchCollectionItemsFailure(collectionId, isErrorWithMessage(error) ? error.message : 'Unknown error')) } @@ -818,6 +845,31 @@ export function* itemSaga(legacyBuilder: LegacyBuilderAPI, builder: BuilderClien yield put(downloadItemFailure(itemId, isErrorWithMessage(error) ? error.message : 'Unknown error')) } } + + function* handleCancelItemOrderTradeRequest(action: CancelItemOrderTradeRequestAction) { + const { tradeId, errorToast } = action.payload + try { + const trade: Trade = yield call([tradeService, 'fetchTrade'], tradeId) + const txHash: string = yield call([tradeService, 'cancel'], trade, ethers.constants.AddressZero) + yield put(cancelItemOrderTradeTxSuccess(trade, txHash)) + yield call(waitForTx, txHash) + yield put(cancelItemOrderTradeSuccess(tradeId)) + } catch (error) { + const errorMessage = isErrorWithMessage(error) ? error.message : 'Unknown error' + yield put(cancelItemOrderTradeFailure(errorMessage)) + if (errorToast) { + yield put( + showToast({ + type: ToastType.ERROR, + title: t('collection_item.cancel_order_error'), + body: errorMessage, + timeout: 10000, + closable: true + }) + ) + } + } + } } export function* handleResetItemRequest(action: ResetItemRequestAction) { diff --git a/src/modules/item/types.ts b/src/modules/item/types.ts index 479d8aa01..7abd5f989 100644 --- a/src/modules/item/types.ts +++ b/src/modules/item/types.ts @@ -124,6 +124,8 @@ export type Item = Omit & { metrics: T extends ItemType.WEARABLE ? ModelMetrics : AnimationMetrics mappings: Partial>> | null isMappingComplete?: boolean + tradeId?: string + tradeExpiresAt?: number } export const isEmoteItemType = (item: Item | Item): item is Item => diff --git a/src/modules/translation/languages/en.json b/src/modules/translation/languages/en.json index a5bb590e0..3d3857be3 100644 --- a/src/modules/translation/languages/en.json +++ b/src/modules/translation/languages/en.json @@ -1737,7 +1737,9 @@ "reset_item": "Reset changes", "move_to_another_collection": "Move to another collection", "preview": "Preview in Editor", - "put_for_sale": "Put up for sale" + "put_for_sale": "Put up for sale", + "remove_from_sale": "Remove from sale", + "cancel_order_error": "Error when removing order for item" }, "collection": { "type": { @@ -2387,5 +2389,11 @@ "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?" + }, + "put_for_sale_offchain_modal": { + "title": "Action needed", + "subtitle": "In order to update the new price, you have to remove the previous sale first", + "cancel": "Cancel", + "reject_old_prices": "Reject old prices" } } diff --git a/src/modules/translation/languages/es.json b/src/modules/translation/languages/es.json index 06343c047..f04d77d05 100644 --- a/src/modules/translation/languages/es.json +++ b/src/modules/translation/languages/es.json @@ -1746,7 +1746,9 @@ "reset_item": "Deshacer cambios", "move_to_another_collection": "Mover a otra colección", "preview": "Preview en el Editor", - "put_for_sale": "Poner en venta" + "put_for_sale": "Poner en venta", + "remove_from_sale": "Eliminar de la venta", + "cancel_order_error": "Error al eliminar el orden para el artículo" }, "collection": { "type": { @@ -2405,5 +2407,11 @@ "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?" + }, + "put_for_sale_offchain_modal": { + "title": "Se necesita acción", + "subtitle": "Para actualizar el nuevo precio, debe eliminar los precios antiguos primero", + "cancel": "Cancelar", + "reject_old_prices": "Rechazar el precio anterior" } } diff --git a/src/modules/translation/languages/zh.json b/src/modules/translation/languages/zh.json index 3070eb835..1ab0b5989 100644 --- a/src/modules/translation/languages/zh.json +++ b/src/modules/translation/languages/zh.json @@ -1725,7 +1725,9 @@ "reset_item": "撤消", "move_to_another_collection": "移动到另一个集合", "preview": "在编辑器中预览", - "put_for_sale": "出售" + "put_for_sale": "出售", + "remove_from_sale": "删除出售", + "cancel_order_error": "删除项目订单时出错" }, "collection": { "type": { @@ -2386,5 +2388,11 @@ "push_changes_modal": { "title": "推送变更", "description": "自上次策展人审核以来,馆藏或其包含的物品已发生更改。{br}为了使这些更改反映在世界上,委员会必须再次审核和批准这些更改。{br }您确定要推送更改以供审核吗?" + }, + "put_for_sale_offchain_modal": { + "title": "需要采取行动", + "subtitle": "为了更新新价格,您必须首先删除先前的销售", + "cancel": "取消", + "reject_old_prices": "拒绝旧价格" } }