diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 8964e2fbe..6419516b8 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.0-development", "dependencies": { "@dcl/crypto": "^3.0.0", - "@dcl/schemas": "^6.15.0", + "@dcl/schemas": "^6.18.0", "@dcl/ui-env": "^1.2.0", "@ethersproject/providers": "^5.6.2", "classnames": "^2.3.1", @@ -30,13 +30,14 @@ "react": "^17.0.2", "react-countup": "^6.2.0", "react-dom": "^17.0.2", + "react-intersection-observer": "^9.4.3", "react-lazy-load-image-component": "^1.5.6", "react-redux": "^7.2.4", "react-router-dom": "^5.2.0", "react-scripts": "^4.0.3", - "react-virtualized-auto-sizer": "^1.0.9", - "react-window": "^1.8.8", - "react-window-infinite-loader": "^1.0.8", + "react-virtualized-auto-sizer": "^1.0.17", + "react-window": "^1.8.9", + "react-window-infinite-loader": "^1.0.9", "recharts": "^2.3.2", "redux": "^4.1.1", "redux-logger": "^3.0.6", @@ -2027,9 +2028,9 @@ } }, "node_modules/@dcl/schemas": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-6.15.0.tgz", - "integrity": "sha512-JepCFaNcaeTrONVh/TlCsajhogKhSfLjL1pAJfKIbRU/3tcZpfyWP2vka+s53pYVQbufQ1+MJzKIuG4+urC/dA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-6.18.0.tgz", + "integrity": "sha512-RxI6a1m2LpT2c28lBWxIJE/TZg7ynAUtBzyN/5g3hatohfoZmzbwSms0iTzfITXYQZX9EOHz3z6uTLw9mweTBw==", "dependencies": { "ajv": "^8.11.0", "ajv-errors": "^3.0.0", @@ -20350,6 +20351,14 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" }, + "node_modules/react-intersection-observer": { + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.3.tgz", + "integrity": "sha512-WNRqMQvKpupr6MzecAQI0Pj0+JQong307knLP4g/nBex7kYfIaZsPpXaIhKHR+oV8z+goUbH9e10j6lGRnTzlQ==", + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "license": "MIT" @@ -20697,18 +20706,18 @@ } }, "node_modules/react-virtualized-auto-sizer": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.12.tgz", - "integrity": "sha512-ELTFQieCCGZ7zz8aEkFnAaMUsAlaOMk/thVcyVlEYtWnlloM49iRFLvirrDIPd8rKwuaWD0TaODHRizMsmkdsA==", + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.17.tgz", + "integrity": "sha512-XtojyZHGo/iYmGkOEL8psTQsr5XI4fd+QxCD16ru00mnJhuvXFXcPLHXj5cKJh/xUttxPCglnpUI8d2u6gUgzw==", "peerDependencies": { "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc", "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc" } }, "node_modules/react-window": { - "version": "1.8.8", - "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz", - "integrity": "sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ==", + "version": "1.8.9", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.9.tgz", + "integrity": "sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==", "dependencies": { "@babel/runtime": "^7.0.0", "memoize-one": ">=3.1.1 <6" @@ -20722,9 +20731,9 @@ } }, "node_modules/react-window-infinite-loader": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-1.0.8.tgz", - "integrity": "sha512-907ZLAiZZfBHuZyiY0V7uiSL4P/rI6UQyCF9wES1cDWTeyNLgGLaxu+BZkcUW3R5tSCQcbCcWBl0jVIpYzrKGQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-1.0.9.tgz", + "integrity": "sha512-5Hg89IdU4Vrp0RT8kZYKeTIxWZYhNkVXeI1HbKo01Vm/Z7qztDvXljwx16sMzsa9yapRJQW3ODZfMUw38SOWHw==", "engines": { "node": ">8.0.0" }, @@ -27728,9 +27737,9 @@ } }, "@dcl/schemas": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-6.15.0.tgz", - "integrity": "sha512-JepCFaNcaeTrONVh/TlCsajhogKhSfLjL1pAJfKIbRU/3tcZpfyWP2vka+s53pYVQbufQ1+MJzKIuG4+urC/dA==", + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-6.18.0.tgz", + "integrity": "sha512-RxI6a1m2LpT2c28lBWxIJE/TZg7ynAUtBzyN/5g3hatohfoZmzbwSms0iTzfITXYQZX9EOHz3z6uTLw9mweTBw==", "requires": { "ajv": "^8.11.0", "ajv-errors": "^3.0.0", @@ -40297,6 +40306,12 @@ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" }, + "react-intersection-observer": { + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.4.3.tgz", + "integrity": "sha512-WNRqMQvKpupr6MzecAQI0Pj0+JQong307knLP4g/nBex7kYfIaZsPpXaIhKHR+oV8z+goUbH9e10j6lGRnTzlQ==", + "requires": {} + }, "react-is": { "version": "16.13.1" }, @@ -40541,24 +40556,24 @@ } }, "react-virtualized-auto-sizer": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.12.tgz", - "integrity": "sha512-ELTFQieCCGZ7zz8aEkFnAaMUsAlaOMk/thVcyVlEYtWnlloM49iRFLvirrDIPd8rKwuaWD0TaODHRizMsmkdsA==", + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.17.tgz", + "integrity": "sha512-XtojyZHGo/iYmGkOEL8psTQsr5XI4fd+QxCD16ru00mnJhuvXFXcPLHXj5cKJh/xUttxPCglnpUI8d2u6gUgzw==", "requires": {} }, "react-window": { - "version": "1.8.8", - "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz", - "integrity": "sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ==", + "version": "1.8.9", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.9.tgz", + "integrity": "sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==", "requires": { "@babel/runtime": "^7.0.0", "memoize-one": ">=3.1.1 <6" } }, "react-window-infinite-loader": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-1.0.8.tgz", - "integrity": "sha512-907ZLAiZZfBHuZyiY0V7uiSL4P/rI6UQyCF9wES1cDWTeyNLgGLaxu+BZkcUW3R5tSCQcbCcWBl0jVIpYzrKGQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-1.0.9.tgz", + "integrity": "sha512-5Hg89IdU4Vrp0RT8kZYKeTIxWZYhNkVXeI1HbKo01Vm/Z7qztDvXljwx16sMzsa9yapRJQW3ODZfMUw38SOWHw==", "requires": {} }, "read-pkg": { diff --git a/webapp/package.json b/webapp/package.json index 44787582d..51360f4d6 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -3,7 +3,7 @@ "version": "0.0.0-development", "dependencies": { "@dcl/crypto": "^3.0.0", - "@dcl/schemas": "^6.15.0", + "@dcl/schemas": "^6.18.0", "@dcl/ui-env": "^1.2.0", "@ethersproject/providers": "^5.6.2", "classnames": "^2.3.1", @@ -24,13 +24,14 @@ "react": "^17.0.2", "react-countup": "^6.2.0", "react-dom": "^17.0.2", + "react-intersection-observer": "^9.4.3", "react-lazy-load-image-component": "^1.5.6", "react-redux": "^7.2.4", "react-router-dom": "^5.2.0", "react-scripts": "^4.0.3", - "react-virtualized-auto-sizer": "^1.0.9", - "react-window": "^1.8.8", - "react-window-infinite-loader": "^1.0.8", + "react-virtualized-auto-sizer": "^1.0.17", + "react-window": "^1.8.9", + "react-window-infinite-loader": "^1.0.9", "recharts": "^2.3.2", "redux": "^4.1.1", "redux-logger": "^3.0.6", diff --git a/webapp/src/components/AccountSidebar/AccountSidebar.css b/webapp/src/components/AccountSidebar/AccountSidebar.css index 30b8b9785..8e4b5ee38 100644 --- a/webapp/src/components/AccountSidebar/AccountSidebar.css +++ b/webapp/src/components/AccountSidebar/AccountSidebar.css @@ -9,6 +9,7 @@ background-color: var(--card); border-radius: 10px; padding-bottom: 12px; + margin-right: 0px; } .AccountSidebar ul.Menu > .MenuItem { @@ -97,3 +98,12 @@ .AccountSidebar ul.Menu.other-account-menu { padding-bottom: 0; } + +.AccountSidebar { + position: sticky; + top: 0; + overflow: auto; + height: calc( + 100vh - 197px + ); /* 64px navbar + 65 navigation + 12 margin + 56 footer = 197 */ +} diff --git a/webapp/src/components/AccountSidebar/CurrentAccountSidebar/CurrentAccountSidebar.tsx b/webapp/src/components/AccountSidebar/CurrentAccountSidebar/CurrentAccountSidebar.tsx index d1ad8b6c9..abbbb6b4b 100644 --- a/webapp/src/components/AccountSidebar/CurrentAccountSidebar/CurrentAccountSidebar.tsx +++ b/webapp/src/components/AccountSidebar/CurrentAccountSidebar/CurrentAccountSidebar.tsx @@ -77,6 +77,7 @@ const CurrentAccountSidebar = ({ section, onBrowse }: Props) => ( ( [AssetFilter.BodyShape]: true, [AssetFilter.Network]: true, [AssetFilter.OnSale]: false, - [AssetFilter.More]: false, + [AssetFilter.More]: false }} /> diff --git a/webapp/src/components/AssetBrowse/AssetBrowse.css b/webapp/src/components/AssetBrowse/AssetBrowse.css index c4880f670..9173dd90d 100644 --- a/webapp/src/components/AssetBrowse/AssetBrowse.css +++ b/webapp/src/components/AssetBrowse/AssetBrowse.css @@ -1,3 +1,12 @@ +.AssetBrowse.dcl.page { + margin-top: 12px; +} + +.AssetBrowse .AssetsList .ui.cards { + height: 100%; + width: 100%; +} + .AssetBrowse .Row .right.Column { position: relative; max-width: calc(100% - 256px); @@ -11,13 +20,6 @@ max-width: none; } -.AssetBrowse .ui.cards { - position: relative; - display: grid; - gap: 12px; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); -} - .AssetBrowse .ui.cards { min-height: 150px; margin: 0; @@ -35,7 +37,6 @@ background: var(--background); opacity: 0.6; z-index: 1; - margin-top: -20px; } .AssetBrowse .load-more { @@ -100,7 +101,8 @@ .AssetBrowse .sidebar { margin-right: 20px; - width: var(--sidebar-width); + width: calc(var(--sidebar-width) + 10px); + background-color: var(--background); } .AssetBrowse.fullscreen .NFTFilters { @@ -143,6 +145,19 @@ .AssetBrowse .overlay { margin-top: 0; } + .AssetBrowse .ui.cards { + display: flex; + } + .AssetBrowse .ui.cards > div { + width: 48%; + } + .AssetBrowse .ui.cards .content.catalog { + padding: 10px 10px 18px 10px; + display: flex; + } + .AssetBrowse .ui.cards .CatalogItemInformation { + margin-top: 6px; + } .AssetBrowse .ui.cards, .AssetBrowse .ui.cards > .ui.card { margin-left: 0; diff --git a/webapp/src/components/AssetCard/AssetCard.container.ts b/webapp/src/components/AssetCard/AssetCard.container.ts index 3bd4748da..ebd109962 100644 --- a/webapp/src/components/AssetCard/AssetCard.container.ts +++ b/webapp/src/components/AssetCard/AssetCard.container.ts @@ -10,7 +10,11 @@ import { locations } from '../../modules/routing/locations' import { getOpenRentalId } from '../../modules/rental/utils' import { getRentalById } from '../../modules/rental/selectors' import { getIsFavoritesEnabled } from '../../modules/features/selectors' -import { getPageName } from '../../modules/routing/selectors' +import { + getPageName, + getSortBy, + getWearablesUrlParams +} from '../../modules/routing/selectors' import { PageName } from '../../modules/routing/types' import { MapStateProps, OwnProps, MapDispatchProps } from './AssetCard.types' import AssetCard from './AssetCard' @@ -30,6 +34,8 @@ const mapState = (state: RootState, ownProps: OwnProps): MapStateProps => { ? getRentalById(state, openRentalId) : null + const { minPrice, maxPrice } = getWearablesUrlParams(state) + return { price, showListedTag: @@ -41,7 +47,12 @@ const mapState = (state: RootState, ownProps: OwnProps): MapStateProps => { : false, rental: rentalOfNFT, showRentalChip: rentalOfNFT !== null && pageName === PageName.ACCOUNT, - isFavoritesEnabled: getIsFavoritesEnabled(state) + isFavoritesEnabled: getIsFavoritesEnabled(state), + sortBy: getSortBy(state), + appliedFilters: { + minPrice, + maxPrice + } } } diff --git a/webapp/src/components/AssetCard/AssetCard.css b/webapp/src/components/AssetCard/AssetCard.css index ede07561b..048621d44 100644 --- a/webapp/src/components/AssetCard/AssetCard.css +++ b/webapp/src/components/AssetCard/AssetCard.css @@ -1,5 +1,44 @@ .AssetCard { + height: 345px; + overflow: visible; +} + +.AssetCard.ui.card > .content.catalog { + padding: 14px 14px 18px 14px; +} + +.AssetCard .header .catalogTitle { + flex: 1 2 auto; + white-space: nowrap; + text-overflow: ellipsis; overflow: hidden; + margin-bottom: 0px; + display: flex; + flex-direction: column; + font-size: 14px; + line-height: 24px; +} + +.AssetCard .textOverflow { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.ui.card.AssetCard > .content, +.ui.cards > .card.AssetCard > .content { + height: 185px; + transition: height 0.3s !important; +} + +.ui.card.AssetCard > .content > .header:not(.ui), +.ui.cards > .card.AssetCard > .content > .header:not(.ui) { + flex: unset; +} + +.ui.card.AssetCard .AssetImage, +.ui.card.AssetCard .AssetImage .rarity-background { + border-radius: 10px 10px 0px 0px !important; } .AssetCard .header, @@ -9,22 +48,46 @@ letter-spacing: -0.2px; } +.AssetCard .ui.header.small { + font-size: 14.5px; + font-weight: 400; + margin-top: -1px; +} + +.AssetCard .CatalogItemInformation { + color: white; + display: flex; + flex-direction: column; + font-size: 14px; + transition: inherit; + flex: 1; +} + +.AssetCard .CatalogItemInformation > span > img { + margin-left: 4px; +} + .AssetCard .header .title { flex: 1 2 auto; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; margin-right: 20px; + margin-bottom: 0px; + display: flex; + flex-direction: column; } -.ui.card.AssetCard > .content { - flex: none; +.AssetCard .creator { + font-size: 14px; + font-weight: 400; + color: var(--secondary-text); } .ui.card.AssetCard > .content > .header:not(.ui), .ui.cards > .card.AssetCard > .content > .header:not(.ui) { display: flex; - margin-bottom: 6px; + margin-bottom: 0px; } .AssetCard .dcl.mana.inline { @@ -37,17 +100,8 @@ margin-bottom: 4px; } -.ui.cards > .ui.card.AssetCard.link:hover .meta, -a.ui.card.link:hover .meta { - color: var(--secondary-text); -} - .AssetCard .tags { - margin-top: 12px; -} - -.AssetCard .AssetImage { - overflow: hidden; + margin-top: 10px; } .AssetCard .AssetImage .ens-subdomain { @@ -79,7 +133,7 @@ a.ui.card.link:hover .meta { align-self: flex-end; background-color: #ecebed; border: 1px solid #a09ba8; - color: #43404A; + color: #43404a; font-size: 11px; border-radius: 50px !important; display: flex; @@ -92,6 +146,115 @@ a.ui.card.link:hover .meta { z-index: 2; } +.AssetCard .AssetImage { + height: 223px; + transition: height 0.3s !important; +} + +.AssetCard .NotForSale { + font-weight: bold; + margin-top: 12px; + margin-bottom: 4px; +} + +.AssetCard .AssetImage.catalog { + height: 209px; + transition: height 0.3s !important; +} + +.AssetCard .PriceInMana .ui.header.large { + font-size: 30px; + font-weight: 600; + margin: 0px; +} + +.AssetCard .ens-subdomain { + overflow: hidden; + border-top-right-radius: 10px; + border-top-left-radius: 10px; +} + +.AssetCard .mintIcon { + height: 14px; +} + +.AssetCard .dcl.atlas { + overflow: hidden; + border-top-right-radius: 10px; + border-top-left-radius: 10px; +} + +.AssetCard .wrapBigText { + flex-wrap: wrap; + display: flex; + align-items: center; +} + +@media (min-width: 1199px) { + .AssetCard:hover .AssetImage.catalog { + height: 161px; + transition: height 0.3s !important; + } + + .AssetCard .extraInformation { + display: flex; + visibility: hidden; + height: 0px; + opacity: 0; + transition: height 0.1s, opacity 0.6s; + } + .ui.card.AssetCard.catalog { + height: 360px; + } + .AssetCard:hover .extraInformation { + visibility: visible; + margin-top: 5px; + height: auto; + opacity: 1; + align-items: center; + } + + .ui.cards > .ui.card.AssetCard.link:hover .meta, + a.ui.card.link:hover .meta { + color: var(--secondary-text); + } + + .AssetCard:hover .CatalogItemInformation { + margin-top: 2px; + } + + .ui.cards a.card.AssetCard.catalog:hover, + .ui.link.card.AssetCard.catalog:hover, + .ui.link.cards .card.AssetCard.catalog:hover, + a.ui.card.AssetCard.catalog:hover, + .ui.cards > .ui.card.AssetCard.catalog.link:hover, + .ui.card.AssetCard.catalog.link:hover { + border: none !important; + } + + .ui.card.AssetCard:hover > .content.catalog.expandable, + .ui.cards > .card.AssetCard:hover > .content.catalog.expandable { + height: 222px; + } + + .ui.card.AssetCard:hover > .content.catalog, + .ui.cards > .card.AssetCard:hover > .content.catalog { + position: absolute; + height: 198px; + margin-top: 161px; + width: 100%; + background-color: var(--card); + box-shadow: 0px 4px 34px 0px rgba(255, 255, 255, 0.37) !important; + border-radius: 0px 0px 10px 10px !important; + } + + .ui.card.AssetCard:hover .AssetImage.catalog, + .ui.cards > .card.AssetCard:hover .AssetImage.catalog { + box-shadow: 0px 4px 34px 0px rgba(255, 255, 255, 0.37) !important; + border-radius: 10px !important; + } +} + @media (max-width: 1199px) { .AssetCard .LandBubble { align-self: center; @@ -126,6 +289,66 @@ a.ui.card.link:hover .meta { } @media (max-width: 768px) { + .AssetCard.ui.card > .content.catalog, + .ui.cards > .AssetCard.card > .content.catalog { + display: flex; + } + + .AssetCard .catalog .tags { + margin-top: auto; + } + + .AssetBrowse .ui.cards .ui.card.AssetCard { + transform: translateY(0px); + } + + .AssetCard.ui.card > .content.catalog > .header:not(.ui), + .AssetCard.ui.cards > .card > .content.catalog > .header:not(.ui) .header, + .AssetCard .CatalogItemInformation { + font-size: 15px; + } + + .AssetCard .PriceInMana .ui.header.large { + font-size: 25px; + } + + .AssetCard .wrapBigText { + display: flex; + font-size: 12px; + flex-direction: row; + align-items: center; + } + + .AssetCard .wrapBigText .ui.small.header.dcl.mana.tiniMana { + font-size: 12px; + } + + .AssetCard .PriceInMana { + margin-top: 6px; + } + + .AssetCard .AssetImage.catalog, + .AssetImage.catalog .image-wrapper { + height: 123px; + } + + .AssetCard .extraInformation { + margin-top: 6px; + visibility: visible; + height: unset; + font-size: 12px; + font-weight: normal; + } + + .ui.cards a.card.AssetCard.catalog:hover, + .ui.link.card.AssetCard.catalog:hover, + .ui.link.cards .card.AssetCard.catalog:hover, + a.ui.card.AssetCard.catalog:hover, + .ui.cards > .ui.card.AssetCard.catalog.link:hover, + .ui.card.AssetCard.catalog.link:hover { + transform: translateY(0px); + } + .AssetCard.ui.card > .content, .ui.cards > .AssetCard.card > .content { display: block; @@ -146,4 +369,4 @@ a.ui.card.link:hover .meta { position: fixed; top: 8px; left: 8px; -} \ No newline at end of file +} diff --git a/webapp/src/components/AssetCard/AssetCard.spec.tsx b/webapp/src/components/AssetCard/AssetCard.spec.tsx index fc0441350..15e3da85f 100644 --- a/webapp/src/components/AssetCard/AssetCard.spec.tsx +++ b/webapp/src/components/AssetCard/AssetCard.spec.tsx @@ -1,3 +1,5 @@ +import { mockAllIsIntersecting } from 'react-intersection-observer/test-utils' +import { screen } from '@testing-library/react' import { BodyShape, ChainId, @@ -8,6 +10,7 @@ import { } from '@dcl/schemas' import { Asset } from '../../modules/asset/types' import { INITIAL_STATE } from '../../modules/favorites/reducer' +import { SortBy } from '../../modules/routing/types' import { renderWithProviders } from '../../utils/test' import AssetCard from './AssetCard' import { Props as AssetCardProps } from './AssetCard.types' @@ -23,6 +26,8 @@ function renderAssetCard(props: Partial = {}) { showRentalChip={false} rental={null} isFavoritesEnabled={false} + sortBy={SortBy.RECENTLY_LISTED} + appliedFilters={{ maxPrice: '100', minPrice: '1' }} {...props} />, { @@ -80,6 +85,26 @@ describe('AssetCard', () => { renderAssetCard({ asset }) }) + describe('when its interesected', () => { + it('should render the Asset Card content', () => { + renderAssetCard({ + asset, + isFavoritesEnabled: true + }) + mockAllIsIntersecting(true) + expect(screen.getByTestId('asset-card-content')).toBeInTheDocument() + }) + }) + describe('when its not interesected', () => { + it('should not render the Asset Card content', () => { + renderAssetCard({ + asset, + isFavoritesEnabled: true + }) + expect(screen.queryByTestId('asset-card-content')).not.toBeInTheDocument() + }) + }) + describe('when the favorites feature flag is not enabled', () => { it('should not render the favorites counter', () => { const { queryByTestId } = renderAssetCard({ @@ -111,11 +136,14 @@ describe('AssetCard', () => { }) it('should render the favorites counter', () => { - const { getByTestId } = renderAssetCard({ + renderAssetCard({ asset, isFavoritesEnabled: true }) - expect(getByTestId(FAVORITES_COUNTER_TEST_ID)).toBeInTheDocument() + mockAllIsIntersecting(true) + expect( + screen.getByTestId(FAVORITES_COUNTER_TEST_ID) + ).toBeInTheDocument() }) }) }) diff --git a/webapp/src/components/AssetCard/AssetCard.tsx b/webapp/src/components/AssetCard/AssetCard.tsx index 723c68593..bfbc8498a 100644 --- a/webapp/src/components/AssetCard/AssetCard.tsx +++ b/webapp/src/components/AssetCard/AssetCard.tsx @@ -1,10 +1,15 @@ -import React, { useMemo } from 'react' -import { RentalListing } from '@dcl/schemas' +import React, { useCallback, useMemo } from 'react' +import { Item, RentalListing } from '@dcl/schemas' import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { Profile } from 'decentraland-dapps/dist/containers' import { Link } from 'react-router-dom' -import { Card, Icon } from 'decentraland-ui' -import { formatWeiMANA } from '../../lib/mana' -import { getAssetName, getAssetUrl, isNFT } from '../../modules/asset/utils' +import { Card, Icon, useMobileMediaQuery } from 'decentraland-ui' +import { + getAssetName, + getAssetUrl, + isNFT, + isCatalogItem +} from '../../modules/asset/utils' import { Asset } from '../../modules/asset/types' import { NFT } from '../../modules/nft/types' import { isLand } from '../../modules/nft/utils' @@ -15,6 +20,7 @@ import { isRentalListingExecuted, isRentalListingOpen } from '../../modules/rental/utils' +import { SortBy } from '../../modules/routing/types' import { Mana } from '../Mana' import { AssetImage } from '../AssetImage' import { FavoritesCounter } from '../FavoritesCounter' @@ -23,6 +29,7 @@ import { EstateTags } from './EstateTags' import { WearableTags } from './WearableTags' import { EmoteTags } from './EmoteTags' import { ENSTags } from './ENSTags' +import { formatWeiToAssetCard, getCatalogCardInformation } from './utils' import { Props } from './AssetCard.types' import './AssetCard.css' @@ -35,8 +42,8 @@ const RentalPrice = ({ }) => { return ( <> - - {formatWeiMANA(rentalPricePerDay)} + + {formatWeiToAssetCard(rentalPricePerDay)} /{t('global.day')} @@ -91,9 +98,13 @@ const AssetCard = (props: Props) => { onClick, isClaimingBackLandTransactionPending, rental, - isFavoritesEnabled + isFavoritesEnabled, + sortBy, + appliedFilters } = props + const isMobile = useMobileMediaQuery() + const title = getAssetName(asset) const { parcel, estate, wearable, emote, ens } = asset.data const rentalPricePerDay: string | null = useMemo( @@ -101,9 +112,64 @@ const AssetCard = (props: Props) => { [rental] ) + const catalogItemInformation = useMemo(() => { + if (!isNFT(asset) && isCatalogItem(asset)) { + return getCatalogCardInformation(asset, { + ...appliedFilters, + sortBy: sortBy as SortBy + }) + } + return null + }, [appliedFilters, asset, sortBy]) + + const renderCatalogItemInformation = useCallback(() => { + const isAvailableForMint = + !isNFT(asset) && asset.isOnSale && asset.available > 0 + const notForSale = + !isAvailableForMint && !isNFT(asset) && !asset.minListingPrice + + return catalogItemInformation ? ( +
+ + {catalogItemInformation.action} + {catalogItemInformation.actionIcon && ( + mint + )} + + + {catalogItemInformation.price ? ( +
+ + {catalogItemInformation.price?.includes('-') + ? `${formatWeiToAssetCard( + catalogItemInformation.price.split(' - ')[0] + )} - ${formatWeiToAssetCard( + catalogItemInformation.price.split(' - ')[1] + )}` + : formatWeiToAssetCard(catalogItemInformation.price)} + +
+ ) : ( + `${t('asset_card.owners', { + count: (asset as Item).owners + })}` + )} + {catalogItemInformation.extraInformation && ( + + {catalogItemInformation.extraInformation} + + )} +
+ ) : null + }, [asset, catalogItemInformation]) + return ( { 'tokenId' in asset ? asset.tokenId : asset.itemId }`} > - - {isFavoritesEnabled && !isNFT(asset) ? ( - - ) : null} - {showRentalBubble ? ( - + - ) : null} - - -
{title}
- {price ? ( - - {formatWeiMANA(price)} - - ) : rentalPricePerDay ? ( - - ) : null} -
-
- - {t(`networks.${asset.network.toLowerCase()}`)} - - {rentalPricePerDay && price ? ( -
+ {isFavoritesEnabled && !isNFT(asset) && !isMobile ? ( + + ) : null} + {showRentalBubble ? ( + + ) : null} + + +
+ {title} + {!isNFT(asset) && isCatalogItem(asset) && ( + + + + )} +
+ {!isCatalogItem(asset) && price ? ( + + {formatWeiToAssetCard(price)} + + ) : rentalPricePerDay ? ( -
- ) : null} -
+ ) : null} + +
+ {!isCatalogItem(asset) && ( + + {t(`networks.${asset.network.toLowerCase()}`)} + + )} + + {rentalPricePerDay && price ? ( +
+ +
+ ) : null} +
+ {renderCatalogItemInformation()} - {parcel ? : null} - {estate ? : null} - {wearable ? : null} - {emote ? : null} - {ens ? : null} -
+ {parcel ? : null} + {estate ? : null} + {wearable ? : null} + {emote ? : null} + {ens ? : null} + +
) } diff --git a/webapp/src/components/AssetCard/AssetCard.types.ts b/webapp/src/components/AssetCard/AssetCard.types.ts index 80947f843..8bb161a64 100644 --- a/webapp/src/components/AssetCard/AssetCard.types.ts +++ b/webapp/src/components/AssetCard/AssetCard.types.ts @@ -1,5 +1,6 @@ import { Order, RentalListing } from '@dcl/schemas' import { Asset } from '../../modules/asset/types' +import { BrowseOptions } from '../../modules/routing/types' export type Props = { asset: Asset @@ -12,6 +13,8 @@ export type Props = { showRentalChip: boolean rental: RentalListing | null isFavoritesEnabled: boolean + sortBy: string | undefined + appliedFilters: Pick } export type MapStateProps = Pick< @@ -22,6 +25,8 @@ export type MapStateProps = Pick< | 'rental' | 'isClaimingBackLandTransactionPending' | 'isFavoritesEnabled' + | 'sortBy' + | 'appliedFilters' > export type MapDispatchProps = {} export type OwnProps = Pick diff --git a/webapp/src/components/AssetCard/EmoteTags/EmoteTags.tsx b/webapp/src/components/AssetCard/EmoteTags/EmoteTags.tsx index 6b4a3c23b..77986d31f 100644 --- a/webapp/src/components/AssetCard/EmoteTags/EmoteTags.tsx +++ b/webapp/src/components/AssetCard/EmoteTags/EmoteTags.tsx @@ -1,15 +1,14 @@ import { NFTCategory } from '@dcl/schemas' -import { Popup } from 'decentraland-ui' import classNames from 'classnames' -import { T } from 'decentraland-dapps/dist/modules/translation/utils' import { AssetType } from '../../../modules/asset/types' import RarityBadge from '../../RarityBadge' import { Props } from './EmoteTags.types' import styles from './EmoteTags.module.css' const EmoteTags = (props: Props) => { - const { nft } = props - const { rarity, loop } = nft.data.emote! + const { asset } = props + const { rarity } = asset.data.emote! + return (
{ category={NFTCategory.EMOTE} withTooltip={false} /> - - } - trigger={ -
- -
- } - />
) } diff --git a/webapp/src/components/AssetCard/EmoteTags/EmoteTags.types.ts b/webapp/src/components/AssetCard/EmoteTags/EmoteTags.types.ts index d323c14b1..57392a891 100644 --- a/webapp/src/components/AssetCard/EmoteTags/EmoteTags.types.ts +++ b/webapp/src/components/AssetCard/EmoteTags/EmoteTags.types.ts @@ -1,6 +1,5 @@ -import { NFT } from '../../../modules/nft/types' -import { VendorName } from '../../../modules/vendor/types' +import { Asset } from '../../../modules/asset/types' export type Props = { - nft: NFT + asset: Asset } diff --git a/webapp/src/components/AssetCard/WearableTags/WearableTags.tsx b/webapp/src/components/AssetCard/WearableTags/WearableTags.tsx index fe22f0067..ae57a84e0 100644 --- a/webapp/src/components/AssetCard/WearableTags/WearableTags.tsx +++ b/webapp/src/components/AssetCard/WearableTags/WearableTags.tsx @@ -5,12 +5,14 @@ import { Section } from '../../../modules/vendor/decentraland' import RarityBadge from '../../RarityBadge' import GenderBadge from '../../GenderBadge/GenderBadge' import { AssetType } from '../../../modules/asset/types' +import { isCatalogItem } from '../../../modules/asset/utils' import { Props } from './WearableTags.types' import './WearableTags.css' const WearableTags = (props: Props) => { const { asset } = props const { rarity, category, bodyShapes, isSmart } = asset.data.wearable! + return (
{ category={NFTCategory.EMOTE} withTooltip={false} /> -
- + {!isCatalogItem(asset) && ( +
+ )} + {!isCatalogItem(asset) && ( + + )} {isSmart ? (
diff --git a/webapp/src/components/AssetCard/WearableTags/WearableTags.types.ts b/webapp/src/components/AssetCard/WearableTags/WearableTags.types.ts index 3c5a21c8a..57392a891 100644 --- a/webapp/src/components/AssetCard/WearableTags/WearableTags.types.ts +++ b/webapp/src/components/AssetCard/WearableTags/WearableTags.types.ts @@ -1,6 +1,5 @@ -import { Item } from '@dcl/schemas' -import { NFT } from '../../../modules/nft/types' +import { Asset } from '../../../modules/asset/types' export type Props = { - asset: NFT | Item + asset: Asset } diff --git a/webapp/src/components/AssetCard/utils.spec.tsx b/webapp/src/components/AssetCard/utils.spec.tsx new file mode 100644 index 000000000..a59595b61 --- /dev/null +++ b/webapp/src/components/AssetCard/utils.spec.tsx @@ -0,0 +1,518 @@ +import { BigNumber, ethers } from 'ethers' +import { CatalogSortBy, Item } from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { BrowseOptions, SortBy } from '../../modules/routing/types' +import { + getAlsoAvailableForMintingText, + getAssetListingsRangeInfoText, + getCatalogCardInformation, + getListingsRangePrice +} from './utils' +import mintingIcon from '../../images/minting.png' + +const applyRange = ( + appliedFilters: Pick +) => { + return (appliedFilters = { + ...appliedFilters, + minPrice: '100', + maxPrice: '1000' + }) +} + +describe('AssetCard utils', () => { + describe('getCatalogCardInformation', () => { + let asset: Item + let price: string + let maxListingPrice: string + let minListingPrice: string + let appliedFilters: Pick + + describe('when the asset is not for sale', () => { + beforeEach(() => { + appliedFilters = {} + asset = { + isOnSale: false, + available: 0, + listings: 0 + } as Item + }) + it('should show "Not for sale" title, no icon, no extra information and no price', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: 'Not for sale', + actionIcon: null, + extraInformation: null, + price: null + }) + }) + }) + + describe('when sorting by CHEAPEST', () => { + beforeEach(() => { + appliedFilters = { + sortBy: SortBy.CHEAPEST + } + }) + describe('when the asset has only mint', () => { + beforeEach(() => { + price = ethers.utils.parseUnits('100').toString() + asset = { + isOnSale: true, + available: 1, + listings: 0, + price, + minPrice: price + } as Item + }) + it('should show "Chepeast Option" title, no icon, no extra information and the price', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.cheapest_option'), + actionIcon: null, + extraInformation: null, + price + }) + }) + }) + + describe('when the asset has only listings', () => { + beforeEach(() => { + price = ethers.utils.parseUnits('100').toString() + asset = { + isOnSale: true, + available: 0, + listings: 1, + price, + minPrice: price + } as Item + }) + describe('when the asset has only one listing', () => { + it('should show "Chepeast Option" title, no icon, the price and no extra section', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.cheapest_option'), + actionIcon: null, + extraInformation: null, + price + }) + }) + }) + describe('when the asset has more than one listing', () => { + beforeEach(() => { + minListingPrice = ethers.utils.parseUnits('100').toString() + maxListingPrice = ethers.utils.parseUnits('1000').toString() + asset = { + ...asset, + listings: 2, + minListingPrice, + maxListingPrice + } + }) + it('should show "Chepeast Option" title, no icon, the price and the listings range in the extra section', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.cheapest_option'), + actionIcon: null, + extraInformation: getAssetListingsRangeInfoText(asset), + price + }) + }) + }) + }) + + describe('when the asset has both mint and listings', () => { + beforeEach(() => { + price = ethers.utils.parseUnits('100').toString() + minListingPrice = ethers.utils.parseUnits('100').toString() + maxListingPrice = ethers.utils.parseUnits('1000').toString() + asset = { + isOnSale: true, + available: 1, + listings: 1, + price, + minPrice: minListingPrice, + minListingPrice, + maxListingPrice + } as Item + }) + describe('and there is a range of prices applied', () => { + beforeEach(() => { + appliedFilters = applyRange(appliedFilters) + }) + + describe('and the minting price is less than the range min', () => { + beforeEach(() => { + asset = { + ...asset, + price: BigNumber.from( + ethers.utils.parseUnits(appliedFilters.minPrice as string) + ) + .sub(BigNumber.from(1)) + .toString() + } + }) + it('should show "Chepeast Option" title, no icon, the min price and the mint option in the extra section', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.cheapest_option_range'), + actionIcon: null, + extraInformation: getAlsoAvailableForMintingText(asset), + price: asset.minPrice + }) + }) + }) + describe('and the minting price is greater than the range max', () => { + beforeEach(() => { + asset = { + ...asset, + price: BigNumber.from( + ethers.utils.parseUnits(appliedFilters.maxPrice as string) + ) + .add(BigNumber.from(1)) + .toString() + } + }) + it('should show "Chepeast Option" title, no icon, the min price and no extra section', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.cheapest_option_range'), + actionIcon: null, + extraInformation: null, + price: asset.minPrice + }) + }) + }) + }) + + describe('and there is no range of prices applied', () => { + describe('and the mint price is the cheapest option', () => { + beforeEach(() => { + asset = { + ...asset, + price: asset.minPrice as string + } + }) + it('should show "Chepeast Option" title, no icon, the min price and no extra section', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.cheapest_option'), + actionIcon: null, + extraInformation: null, + price: asset.minPrice + }) + }) + }) + describe('and the mint price is not the cheapest option', () => { + beforeEach(() => { + asset = { + ...asset, + price: BigNumber.from(asset.minPrice as string) + .add(1) + .toString() + } + }) + it('should show "Chepeast Option" title, no icon, the min price and the mint option in the extra section', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.cheapest_option'), + actionIcon: null, + extraInformation: getAlsoAvailableForMintingText(asset), + price: asset.minPrice + }) + }) + }) + }) + }) + }) + + describe('when sorting by MOST_EXPENSIVE', () => { + beforeEach(() => { + price = ethers.utils.parseUnits('5').toString() + minListingPrice = ethers.utils.parseUnits('100').toString() + maxListingPrice = ethers.utils.parseUnits('999').toString() + asset = { + isOnSale: true, + available: 1, + listings: 2, + price, + minPrice: price, + minListingPrice, + maxListingPrice + } as Item + appliedFilters = { + sortBy: SortBy.MOST_EXPENSIVE + } + }) + describe('and there is a range applied', () => { + beforeEach(() => { + appliedFilters = applyRange(appliedFilters) + }) + describe('and the minting is in range', () => { + beforeEach(() => { + asset = { + ...asset, + price: ethers.utils + .parseUnits(appliedFilters.maxPrice as string) + .sub(1) + .toString() + } + }) + describe('and minting is more expensive than the max listing', () => { + beforeEach(() => { + asset = { + ...asset, + maxListingPrice: BigNumber.from(asset.price) + .sub(1) + .toString() + } + }) + it('should show most expensive in range title, no icon, the minting price and the listings range in the extra section', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.most_expensive_range'), + actionIcon: null, + extraInformation: getAssetListingsRangeInfoText(asset), + price: asset.price + }) + }) + }) + describe('and minting is less expensive than the max listing', () => { + beforeEach(() => { + asset = { + ...asset, + maxListingPrice: BigNumber.from(asset.price) + .add(1) + .toString() + } + }) + it('should show "Most Expensive" title, no icon, the max listing price and the listings range in the extra section', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.most_expensive_range'), + actionIcon: null, + extraInformation: getAssetListingsRangeInfoText(asset), + price: asset.maxListingPrice + }) + }) + }) + }) + describe('and the minting is out of range', () => { + beforeEach(() => { + asset = { + ...asset, + price: BigNumber.from( + ethers.utils.parseUnits(appliedFilters.maxPrice as string) + ) + .add(1) + .toString() + } + }) + it('should show "Most Expensive" title, no icon, the max listing price and the listings range in the extra section', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.most_expensive_range'), + actionIcon: null, + extraInformation: getAssetListingsRangeInfoText(asset), + price: asset.maxListingPrice + }) + }) + }) + }) + describe('and there is no range applied', () => { + it('should show most expensive title, no icon, the listing max price and the listings range in the extra section', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.most_expensive'), + actionIcon: null, + extraInformation: getAssetListingsRangeInfoText(asset), + price: asset.maxListingPrice + }) + }) + }) + }) + + describe.each( + Object.values(CatalogSortBy).filter( + sortBy => + sortBy !== CatalogSortBy.CHEAPEST && + sortBy !== CatalogSortBy.MOST_EXPENSIVE + ) + )('when sorting by %s', sort => { + beforeEach(() => { + asset = { + isOnSale: true, + available: 1, + listings: 2, + price: '100', + minPrice: '100', + minListingPrice: '100', + maxListingPrice: '100' + } as Item + appliedFilters = { + sortBy: (sort as unknown) as SortBy + } + }) + describe('and there is only mint available', () => { + beforeEach(() => { + asset = { + ...asset, + listings: 0 + } + }) + it('should show the mint title, mint icon, the mint price and no extra section', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.available_for_mint'), + actionIcon: mintingIcon, + extraInformation: null, + price: asset.price + }) + }) + }) + describe('and there is only listings available', () => { + beforeEach(() => { + asset = { + ...asset, + available: 0, + isOnSale: false, + minPrice: '10', + minListingPrice: '10', + maxListingPrice: '100' + } + }) + describe('and has just 1 listing', () => { + beforeEach(() => { + asset = { + ...asset, + listings: 1 + } + }) + it('should show cheapest listing label, no icon, the min price and no extra section', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.cheapest_listing'), + actionIcon: null, + extraInformation: null, + price: asset.minListingPrice + }) + }) + }) + describe('and has more than 1 listing', () => { + beforeEach(() => { + asset = { + ...asset, + listings: 2 + } + }) + describe('and has a range applied', () => { + beforeEach(() => { + appliedFilters = applyRange(appliedFilters) + }) + it('should show the listings in range label, no icon, the range price and no extra section', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.available_listings_in_range'), + actionIcon: null, + extraInformation: null, + price: getListingsRangePrice(asset) + }) + }) + }) + }) + }) + describe('and there is mint and listings available', () => { + beforeEach(() => { + asset = { + ...asset, + available: 10, + listings: 999, + isOnSale: true, + minPrice: '10', + minListingPrice: '10', + maxListingPrice: '100' + } + }) + describe('and has a range applied', () => { + beforeEach(() => { + appliedFilters = applyRange(appliedFilters) + }) + describe('and minting is in range', () => { + beforeEach(() => { + asset = { + ...asset, + price: BigNumber.from( + ethers.utils.parseUnits(appliedFilters.maxPrice as string) + ) + .sub(1) + .toString() + } + }) + it('should show the available for mint label, mint icon and the listings range in the extra information', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.available_for_mint'), + actionIcon: mintingIcon, + extraInformation: getAssetListingsRangeInfoText(asset), + price: asset.price + }) + }) + }) + describe('and minting is less than the min range price', () => { + beforeEach(() => { + asset = { + ...asset, + price: BigNumber.from( + ethers.utils.parseUnits(appliedFilters.minPrice as string) + ) + .sub(1) + .toString() + } + }) + it('should show the available listings in range label, no icon and the minting price in the extra information', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.available_listings_in_range'), + actionIcon: null, + extraInformation: getAlsoAvailableForMintingText(asset), + price: asset.minListingPrice + }) + }) + }) + describe('and minting is higher than the max range price', () => { + beforeEach(() => { + asset = { + ...asset, + price: BigNumber.from( + ethers.utils.parseUnits(appliedFilters.maxPrice as string) + ) + .add(1) + .toString() + } + }) + it('should show the available listings in range label, no icon and the no the extra information', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.available_listings_in_range'), + actionIcon: null, + extraInformation: null, + price: asset.minListingPrice + }) + }) + }) + }) + describe('and has no range applied', () => { + it('should show the available for mint label, mint icon and the listings range in the extra information', () => { + const result = getCatalogCardInformation(asset, appliedFilters) + expect(result).toEqual({ + action: t('asset_card.available_for_mint'), + actionIcon: mintingIcon, + extraInformation: getAssetListingsRangeInfoText(asset), + price: asset.price + }) + }) + }) + }) + }) + }) +}) diff --git a/webapp/src/components/AssetCard/utils.tsx b/webapp/src/components/AssetCard/utils.tsx new file mode 100644 index 000000000..5c3847e27 --- /dev/null +++ b/webapp/src/components/AssetCard/utils.tsx @@ -0,0 +1,233 @@ +import { BigNumber, ethers } from 'ethers' +import { MAXIMUM_FRACTION_DIGITS } from 'decentraland-dapps/dist/lib/mana' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { Item } from '@dcl/schemas' +import mintingIcon from '../../images/minting.png' +import { getMinimumValueForFractionDigits } from '../../lib/mana' +import { BrowseOptions, SortBy } from '../../modules/routing/types' +import { Mana } from '../Mana' + +const ONE_MILLION = 1000000 +const ONE_BILLION = 1000000000 +const ONE_TRILLION = 1000000000000 + +export function formatWeiToAssetCard(wei: string): string { + const maximumFractionDigits = MAXIMUM_FRACTION_DIGITS + const value = Number(ethers.utils.formatEther(wei)) + + if (value === 0) { + return '0' + } + + const fixedValue = value.toLocaleString(undefined, { + maximumFractionDigits + }) + + if (fixedValue === '0') { + return getMinimumValueForFractionDigits(maximumFractionDigits).toString() + } + + if (value > ONE_TRILLION) { + return `${(+value / ONE_TRILLION).toLocaleString()}T` + } else if (value > ONE_BILLION) { + return `${(+value / ONE_BILLION).toLocaleString()}B` + } else if (value > ONE_MILLION) { + return `${(+value / ONE_MILLION).toLocaleString()}M` + } + + return fixedValue +} + +type CatalogCardInformation = { + action: string + actionIcon: string | null + price: string | null + extraInformation: React.ReactElement | null +} + +export function getAlsoAvailableForMintingText(asset: Item) { + return ( + + {t('asset_card.also_minting')}:  + + {formatWeiToAssetCard(asset.price)} + + + ) +} + +export function getListingsRangePrice(asset: Item) { + return `${asset.minListingPrice} - ${asset.maxListingPrice}` +} + +export function getAssetListingsRangeInfoText(asset: Item) { + return asset.minListingPrice && asset.maxListingPrice ? ( + + {t('asset_card.listings', { count: asset.listings })} + :  + + + {formatWeiToAssetCard(asset.minListingPrice)} + +   + {!!asset.listings && + asset.listings > 1 && + asset.minListingPrice !== asset.maxListingPrice && + `- ${formatWeiToAssetCard(asset.maxListingPrice)}`} + + + ) : null +} + +function getIsMintPriceInRange( + asset: Item, + appliedFilters: Pick +) { + return ( + !appliedFilters.minPrice || + (BigNumber.from(asset.price).gte( + ethers.utils.parseUnits(appliedFilters.minPrice) + ) && + (!appliedFilters.maxPrice || + BigNumber.from(asset.price).lte( + ethers.utils.parseUnits(appliedFilters.maxPrice) + ))) + ) +} + +export function getCatalogCardInformation( + asset: Item, + appliedFilters: Pick +): CatalogCardInformation { + const { sortBy } = appliedFilters + + const isAvailableForMint = asset.isOnSale && asset.available > 0 + const hasListings = asset.listings && asset.listings > 0 + const hasOnlyListings = hasListings && !isAvailableForMint + const hasOnlyMint = isAvailableForMint && !hasListings + const notForSale = !isAvailableForMint && !hasListings + const hasRangeApplied = !!appliedFilters.minPrice || !!appliedFilters.maxPrice + + if (notForSale) { + return { + action: t('asset_card.not_for_sale'), + actionIcon: null, + price: null, + extraInformation: null + } + } + + if (sortBy === SortBy.CHEAPEST) { + const info: CatalogCardInformation = { + action: hasRangeApplied + ? t('asset_card.cheapest_option_range') + : t('asset_card.cheapest_option'), + actionIcon: null, + price: asset.minPrice ?? null, + extraInformation: null + } + + if (hasOnlyMint) { + info.extraInformation = null + } else if (hasOnlyListings && asset.listings && asset.listings > 1) { + info.extraInformation = getAssetListingsRangeInfoText(asset) + } else { + // has both minting and listings + if (hasRangeApplied) { + info.price = asset.minPrice ?? asset.price // TODO check if this is necessary + if (appliedFilters.minPrice) { + const isMintingLessThanMinPriceFilter = BigNumber.from( + asset.price + ).lt(ethers.utils.parseUnits(appliedFilters.minPrice)) + info.extraInformation = isMintingLessThanMinPriceFilter + ? getAlsoAvailableForMintingText(asset) + : null + } + } else { + const mintIsNotCheapestOption = BigNumber.from(asset.price).gt( + BigNumber.from(asset.minPrice) + ) + if (mintIsNotCheapestOption) { + info.extraInformation = getAlsoAvailableForMintingText(asset) + } + } + } + return info + } else if (sortBy === SortBy.MOST_EXPENSIVE) { + const info: CatalogCardInformation = { + action: hasRangeApplied + ? t('asset_card.most_expensive_range') + : t('asset_card.most_expensive'), + actionIcon: null, + price: asset.price, + extraInformation: getAssetListingsRangeInfoText(asset) + } + + const isMintingGreaterThanMaxListingPrice = + !!asset.maxListingPrice && + BigNumber.from(asset.price).gt(BigNumber.from(asset.maxListingPrice)) + + info.price = + getIsMintPriceInRange(asset, appliedFilters) && + isMintingGreaterThanMaxListingPrice + ? asset.price + : asset.maxListingPrice ?? asset.price + + return info + } + + // rest of filter without label logic + const info: CatalogCardInformation = { + action: '', + actionIcon: null, + price: asset.price, + extraInformation: null + } + + if (hasOnlyMint) { + info.action = t('asset_card.available_for_mint') + info.actionIcon = mintingIcon + } else if (hasOnlyListings) { + info.action = hasRangeApplied + ? t('asset_card.available_listings_in_range') + : t('asset_card.cheapest_listing') + info.price = + asset.listings && + asset.listings > 1 && + asset.minListingPrice !== asset.maxListingPrice + ? hasRangeApplied + ? getListingsRangePrice(asset) + : asset.minListingPrice ?? '' + : asset.minPrice ?? '' + } else { + // both mint and listings available + + if (hasRangeApplied) { + const isMintInRange = getIsMintPriceInRange(asset, appliedFilters) + info.action = isMintInRange + ? t('asset_card.available_for_mint') + : t('asset_card.available_listings_in_range') + info.price = isMintInRange ? asset.price : asset.minListingPrice ?? '' + info.actionIcon = isMintInRange ? mintingIcon : null + info.extraInformation = isMintInRange + ? getAssetListingsRangeInfoText(asset) + : null + + if (appliedFilters.minPrice) { + const isMintingLessThanMinPriceFilter = BigNumber.from(asset.price).lt( + ethers.utils.parseUnits(appliedFilters.minPrice) + ) + info.extraInformation = + !isMintInRange && isMintingLessThanMinPriceFilter + ? getAlsoAvailableForMintingText(asset) + : info.extraInformation + } + } else { + // mint is the cheapest, show "available for mint" and the listings range + info.action = t('asset_card.available_for_mint') + info.actionIcon = mintingIcon + info.extraInformation = getAssetListingsRangeInfoText(asset) + } + } + return info +} diff --git a/webapp/src/components/AssetFilters/AssetFilters.container.ts b/webapp/src/components/AssetFilters/AssetFilters.container.ts index 9ca540574..4ab22edf5 100644 --- a/webapp/src/components/AssetFilters/AssetFilters.container.ts +++ b/webapp/src/components/AssetFilters/AssetFilters.container.ts @@ -21,6 +21,7 @@ import { getRentalDays, getRarities, getSection, + getStatus, getWearableGenders } from '../../modules/routing/selectors' import { @@ -31,6 +32,7 @@ import { getIsRentalPeriodFilterEnabled } from '../../modules/features/selectors' import { LANDFilters } from '../Vendor/decentraland/types' +import { AssetStatusFilter } from '../../utils/filters' import { browse } from '../../modules/routing/actions' import { Section } from '../../modules/vendor/routing/types' import { getView } from '../../modules/ui/browse/selectors' @@ -69,6 +71,10 @@ const mapState = (state: RootState, ownProps: OwnProps): MapStateProps => { ? values.maxEstateSize || '' : getMaxEstateSize(state), rarities: 'rarities' in values ? values.rarities || [] : getRarities(state), + status: + 'status' in values + ? values.status + : (getStatus(state) as AssetStatusFilter), network: 'network' in values ? values.network : getNetwork(state), bodyShapes: 'wearableGenders' in values @@ -85,10 +91,20 @@ const mapState = (state: RootState, ownProps: OwnProps): MapStateProps => { landStatus, view: getView(state), section, - rentalDays: 'rentalDays' in values ? values.rentalDays : getRentalDays(state), - minDistanceToPlaza: 'minDistanceToPlaza' in values ? values.minDistanceToPlaza : getMinDistanceToPlaza(state), - maxDistanceToPlaza: 'maxDistanceToPlaza' in values ? values.maxDistanceToPlaza : getMaxDistanceToPlaza(state), - adjacentToRoad: 'adjacentToRoad' in values ? values.adjacentToRoad : getAdjacentToRoad(state), + rentalDays: + 'rentalDays' in values ? values.rentalDays : getRentalDays(state), + minDistanceToPlaza: + 'minDistanceToPlaza' in values + ? values.minDistanceToPlaza + : getMinDistanceToPlaza(state), + maxDistanceToPlaza: + 'maxDistanceToPlaza' in values + ? values.maxDistanceToPlaza + : getMaxDistanceToPlaza(state), + adjacentToRoad: + 'adjacentToRoad' in values + ? values.adjacentToRoad + : getAdjacentToRoad(state), isCreatorFiltersEnabled: getIsCreatorsFilterEnabled(state), isPriceFilterEnabled: getIsPriceFilterEnabled(state), isEstateSizeFilterEnabled: getIsEstateSizeFilterEnabled(state), diff --git a/webapp/src/components/AssetFilters/AssetFilters.css b/webapp/src/components/AssetFilters/AssetFilters.css index 52816dd0e..f04dd8142 100644 --- a/webapp/src/components/AssetFilters/AssetFilters.css +++ b/webapp/src/components/AssetFilters/AssetFilters.css @@ -1,6 +1,7 @@ .filters-sidebar { margin-top: 12px; margin-left: 0; + margin-right: 0; display: grid; grid-template-columns: 1fr; row-gap: 12px; @@ -10,7 +11,7 @@ .dcl.box.filters-sidebar-box { padding: 21px 24px; background: #242129; - max-width: var(--menu-item-width); + max-width: var(--sidebar-width); border: none; } diff --git a/webapp/src/components/AssetFilters/AssetFilters.tsx b/webapp/src/components/AssetFilters/AssetFilters.tsx index f065fe48c..4daa96980 100644 --- a/webapp/src/components/AssetFilters/AssetFilters.tsx +++ b/webapp/src/components/AssetFilters/AssetFilters.tsx @@ -7,10 +7,10 @@ import { WearableGender } from '@dcl/schemas' import { getSectionFromCategory } from '../../modules/routing/search' -import { AssetType } from '../../modules/asset/types' import { isLandSection } from '../../modules/ui/utils' +import { AssetStatusFilter } from '../../utils/filters' import { View } from '../../modules/ui/types' -import { Sections, SortBy } from '../../modules/routing/types' +import { Sections, SortBy, BrowseOptions } from '../../modules/routing/types' import { LANDFilters } from '../Vendor/decentraland/types' import { Menu } from '../Menu' import PriceFilter from './PriceFilter' @@ -31,6 +31,7 @@ import { filtersBySection, trackBarChartComponentChange } from './utils' +import { StatusFilter } from './StatusFilter' import './AssetFilters.css' export const AssetFilters = ({ @@ -41,13 +42,13 @@ export const AssetFilters = ({ collection, creators, rarities, + status, network, category, bodyShapes, isOnlySmart, isOnSale, emotePlayMode, - assetType, section, landStatus, defaultCollapsed, @@ -64,9 +65,13 @@ export const AssetFilters = ({ isCreatorFiltersEnabled, isRentalPeriodFilterEnabled }: Props): JSX.Element | null => { - const isPrimarySell = assetType === AssetType.ITEM const isInLandSection = isLandSection(section) + const handleBrowseParamChange = useCallback( + (options: BrowseOptions) => onBrowse(options), + [onBrowse] + ) + const handleRangeFilterChange = useCallback( ( filterNames: [string, string], @@ -216,6 +221,7 @@ export const AssetFilters = ({ values={values} /> ) : null} + {isEstateSizeFilterEnabled && section !== Sections.decentraland.PARCELS ? ( ) : null} + {shouldRenderFilter(AssetFilter.Status) && view === View.MARKET ? ( + + ) : null} {isPriceFilterEnabled && shouldRenderFilter(AssetFilter.Price) && - isOnSale && + (isOnSale || (!!status && status !== AssetStatusFilter.NOT_FOR_SALE)) && view !== View.ACCOUNT ? ( @@ -305,13 +318,14 @@ export const AssetFilters = ({ defaultCollapsed={!!defaultCollapsed?.[AssetFilter.PlayMode]} /> )} - {shouldRenderFilter(AssetFilter.Network) && !isPrimarySell && ( - - )} + {shouldRenderFilter(AssetFilter.Network) && + status !== AssetStatusFilter.ONLY_MINTING && ( + + )} {shouldRenderFilter(AssetFilter.BodyShape) && (
- + {isOnSale !== undefined ? ( + + ) : null} {isWearableCategory && ( )} diff --git a/webapp/src/components/AssetFilters/RarityFilter/RarityFilter.tsx b/webapp/src/components/AssetFilters/RarityFilter/RarityFilter.tsx index b6d262964..03fe3f13e 100644 --- a/webapp/src/components/AssetFilters/RarityFilter/RarityFilter.tsx +++ b/webapp/src/components/AssetFilters/RarityFilter/RarityFilter.tsx @@ -1,5 +1,5 @@ import { useMemo, useCallback } from 'react' -import { Box, useTabletAndBelowMediaQuery } from 'decentraland-ui' +import { Box, Popup, useTabletAndBelowMediaQuery } from 'decentraland-ui' import { Rarity } from '@dcl/schemas' import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { ArrayFilter } from '../../Vendor/NFTFilters/ArrayFilter' @@ -52,7 +52,15 @@ export const RarityFilter = ({
) : ( - t('nft_filters.rarities.title') + <> + {t('nft_filters.rarities.title')} + } + on="hover" + /> + ), [rarities, isMobileOrTablet, allRaritiesSelected] ) diff --git a/webapp/src/components/AssetFilters/StatusFilter/StatusFilter.css b/webapp/src/components/AssetFilters/StatusFilter/StatusFilter.css new file mode 100644 index 000000000..b41828a5a --- /dev/null +++ b/webapp/src/components/AssetFilters/StatusFilter/StatusFilter.css @@ -0,0 +1,10 @@ +.asset-status-filter .asset-status-options { + display: grid; + grid-template-columns: 1fr; +} + +.asset-status-filter .asset-status-options .ui.radio.checkbox { + min-height: 44px; + display: flex; + align-items: center; +} diff --git a/webapp/src/components/AssetFilters/StatusFilter/StatusFilter.tsx b/webapp/src/components/AssetFilters/StatusFilter/StatusFilter.tsx new file mode 100644 index 000000000..ee3724864 --- /dev/null +++ b/webapp/src/components/AssetFilters/StatusFilter/StatusFilter.tsx @@ -0,0 +1,91 @@ +import { useCallback, useMemo } from 'react' +import { Box, Radio, useTabletAndBelowMediaQuery } from 'decentraland-ui' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { AssetStatusFilter } from '../../../utils/filters' +import { BrowseOptions } from '../../../modules/routing/types' +import { InfoTooltip } from '../../InfoTooltip' +import './StatusFilter.css' + +export type StatusFilterFilterProps = { + status?: AssetStatusFilter + onChange: (value: BrowseOptions) => void + defaultCollapsed?: boolean +} + +export const StatusFilter = ({ + status, + onChange, + defaultCollapsed = false +}: StatusFilterFilterProps) => { + const isMobileOrTablet = useTabletAndBelowMediaQuery() + const statusOptions = useMemo( + () => + Object.keys(AssetStatusFilter).map(opt => ({ + value: opt.toLocaleLowerCase(), + text: t(`nft_filters.status.${opt.toLocaleLowerCase()}`) + })), + [] + ) + + const handleChange = useCallback( + (_evt, { value }) => { + let options: BrowseOptions = { status: value } + if (value === AssetStatusFilter.NOT_FOR_SALE) { + options = { ...options, minPrice: undefined, maxPrice: undefined } + } + return onChange(options) + }, + [onChange] + ) + + const header = useMemo( + () => + isMobileOrTablet ? ( +
+ + {t('nft_filters.status.title')} + + + {status + ? t(`nft_filters.status.${status}`) + : t('nft_filters.status.on_sale')} + +
+ ) : ( + t('nft_filters.status.title') + ), + [isMobileOrTablet, status] + ) + + return ( + +
+ {statusOptions.map(option => ( + + {option.text} + {option.value !== AssetStatusFilter.NOT_FOR_SALE ? ( + + ) : null} + + } + value={option.value} + name="status" + checked={option.value === status} + /> + ))} +
+
+ ) +} diff --git a/webapp/src/components/AssetFilters/StatusFilter/index.ts b/webapp/src/components/AssetFilters/StatusFilter/index.ts new file mode 100644 index 000000000..b9e8657de --- /dev/null +++ b/webapp/src/components/AssetFilters/StatusFilter/index.ts @@ -0,0 +1 @@ +export * from './StatusFilter' diff --git a/webapp/src/components/AssetFilters/utils.ts b/webapp/src/components/AssetFilters/utils.ts index b6ce96bee..2b11b0ca7 100644 --- a/webapp/src/components/AssetFilters/utils.ts +++ b/webapp/src/components/AssetFilters/utils.ts @@ -5,6 +5,7 @@ import * as events from '../../utils/events' export const enum AssetFilter { Rarity, + Status, Price, Collection, Creators, @@ -17,6 +18,7 @@ export const enum AssetFilter { const WearablesFilters = [ AssetFilter.Rarity, + AssetFilter.Status, AssetFilter.Price, AssetFilter.Network, AssetFilter.BodyShape, @@ -28,7 +30,10 @@ const WearablesFilters = [ const EmotesFilters = [ ...WearablesFilters.filter( - filter => filter !== AssetFilter.BodyShape && filter !== AssetFilter.Network + filter => + filter !== AssetFilter.BodyShape && + filter !== AssetFilter.Network && + filter !== AssetFilter.More ), AssetFilter.PlayMode ] diff --git a/webapp/src/components/AssetImage/AssetImage.container.ts b/webapp/src/components/AssetImage/AssetImage.container.ts index dba39190e..eb992c175 100644 --- a/webapp/src/components/AssetImage/AssetImage.container.ts +++ b/webapp/src/components/AssetImage/AssetImage.container.ts @@ -3,6 +3,7 @@ import { Avatar } from '@dcl/schemas' import { connect } from 'react-redux' import { RootState } from '../../modules/reducer' import { getWallet } from '../../modules/wallet/selectors' +import { getItem } from '../../modules/item/utils' import { getIsTryingOn, getIsPlayingEmote, @@ -12,17 +13,27 @@ import { setIsTryingOn, setWearablePreviewController } from '../../modules/ui/preview/actions' +import { getData as getItems } from '../../modules/item/selectors' +import { fetchItemRequest } from '../../modules/item/actions' import { MapStateProps, MapDispatchProps, - MapDispatch + MapDispatch, + OwnProps } from './AssetImage.types' import AssetImage from './AssetImage' -const mapState = (state: RootState): MapStateProps => { +const mapState = (state: RootState, ownProps: OwnProps): MapStateProps => { const profiles = getProfiles(state) const wallet = getWallet(state) let avatar: Avatar | undefined = undefined + const items = getItems(state) + const item = getItem( + ownProps.asset.contractAddress, + ownProps.asset.itemId, + items + ) + if (wallet && !!profiles[wallet.address]) { const profile = profiles[wallet.address] avatar = profile.avatars[0] @@ -31,14 +42,17 @@ const mapState = (state: RootState): MapStateProps => { avatar, wearableController: getWearablePreviewController(state), isTryingOn: getIsTryingOn(state), - isPlayingEmote: getIsPlayingEmote(state) + isPlayingEmote: getIsPlayingEmote(state), + item } } const mapDispatch = (dispatch: MapDispatch): MapDispatchProps => ({ onSetIsTryingOn: value => dispatch(setIsTryingOn(value)), onSetWearablePreviewController: controller => - dispatch(setWearablePreviewController(controller)) + dispatch(setWearablePreviewController(controller)), + onFetchItem: (contractAddress: string, tokenId: string) => + dispatch(fetchItemRequest(contractAddress, tokenId)) }) export default connect(mapState, mapDispatch)(AssetImage) diff --git a/webapp/src/components/AssetImage/AssetImage.css b/webapp/src/components/AssetImage/AssetImage.css index 3626ae837..22e91d291 100644 --- a/webapp/src/components/AssetImage/AssetImage.css +++ b/webapp/src/components/AssetImage/AssetImage.css @@ -73,7 +73,7 @@ text-overflow: ellipsis; white-space: nowrap; text-transform: lowercase; - color: #CFCDD4; + color: #cfcdd4; } .AssetImage .WearablePreview { @@ -204,3 +204,148 @@ .AssetImage .coordinates { margin-right: 8px; } + +.AvailableForMintPopup { + display: flex; + flex-direction: column; + z-index: 1; + background-color: #242129b2; + border-radius: 10px; + align-items: flex-start; + padding: 29px 25px 20px; + gap: 10px; + width: 500px; + bottom: 24px; + position: absolute; + right: 24px; + height: 100px; + transition: height 0.3s !important; + overflow: hidden; +} + +.AvailableForMintPopup .mintIcon { + height: 24.72px; +} + +.AvailableForMintPopup .goToItem { + color: white; +} + +.AvailableForMintPopup .popupPreview { + display: flex; + gap: 10px; + width: 100%; +} + +.ui.button.inverted.goToItemButton { + width: 38px; + height: 32px; + min-width: unset; + padding: 6px 7px 0px 0px; +} + +.AvailableForMintPopup .title { + font-size: 18px; + font-weight: 500; +} + +.AvailableForMintPopup .previewText { + text-align: start; + width: 100%; +} + +.AvailableForMintPopup .popupExtraInformation { + opacity: 0; + transition: opacity 0.3s; + display: flex; + justify-content: space-between; + width: 100%; + margin-top: 15px; +} + +.AvailableForMintPopup:hover { + height: 174px; +} + +.AvailableForMintPopup:hover .popupExtraInformation { + opacity: 1; +} + +.AvailableForMintPopup .informationTooltip { + width: 14px; + height: 14px; +} + +.AvailableForMintPopup .informationTitle { + font-size: 14px; + color: #cfcdd4; + font-weight: 600; + display: flex; + align-items: center; + line-height: 7px; +} + +.AvailableForMintPopup .informationText { + color: white; + font-size: 24px; + line-height: 1.6; + font-weight: 300; +} + +.AvailableForMintPopup .informationBold { + color: white; + font-size: 30px; + font-weight: 600; + margin: unset; +} + +.AvailableForMintPopup .containerRow { + display: flex; + flex-direction: row; + gap: 10px; + align-items: flex-end; +} + +.AvailableForMintPopup .extraInfoContainer { + color: var(--primary-text); + display: flex; + flex-direction: column; + font-weight: 400; + font-size: 15px; + justify-content: space-between; +} + +.AvailableForMintPopup .stockText { + font-weight: 600; + font-size: 18px; + margin-bottom: 6px; +} + +.AssetImage .ui.button + .ui.button { + margin-left: 0px; +} + +@media (max-width: 768px) { + .AssetImage .image { + max-width: 100%; + } + .AvailableForMintPopup { + right: 0; + width: 100%; + bottom: -110px; + padding-top: 20px; + } + .AssetImage .rarity-background, + .AssetImage .WearablePreview { + border-radius: 12px; + } + .AvailableForMintPopup .ui.button.inverted.goToItemButton { + align-self: center; + } + .AssetImage { + height: 400px; + } + .AssetImage.hasMintAvailable { + margin-bottom: 100px; + } +} diff --git a/webapp/src/components/AssetImage/AssetImage.tsx b/webapp/src/components/AssetImage/AssetImage.tsx index 386e49ac5..dc85048a5 100644 --- a/webapp/src/components/AssetImage/AssetImage.tsx +++ b/webapp/src/components/AssetImage/AssetImage.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { LazyLoadImage } from 'react-lazy-load-image-component' import classNames from 'classnames' -import { Env } from '@dcl/ui-env' +// import { Env } from '@dcl/ui-env' import { BodyShape, NFTCategory, PreviewEmote, Rarity } from '@dcl/schemas' import { T, t } from 'decentraland-dapps/dist/modules/translation/utils' import { getAnalytics } from 'decentraland-dapps/dist/modules/analytics/utils' @@ -14,16 +14,16 @@ import { Popup, WearablePreview } from 'decentraland-ui' - -import { getAssetImage, getAssetName } from '../../modules/asset/utils' +import { getAssetImage, getAssetName, isNFT } from '../../modules/asset/utils' import { getSelection, getCenter } from '../../modules/nft/estate/utils' import * as events from '../../utils/events' import { Atlas } from '../Atlas' import ListedBadge from '../ListedBadge' -import { config } from '../../config' +// import { config } from '../../config' import { Coordinate } from '../Coordinate' import { JumpIn } from '../AssetPage/JumpIn' import { ControlOptionAction, Props } from './AssetImage.types' +import AvailableForMintPopup from './AvailableForMintPopup' import './AssetImage.css' // 1x1 transparent pixel @@ -74,7 +74,8 @@ const AssetImage = (props: Props) => { onSetIsTryingOn, onSetWearablePreviewController, children, - hasBadges + hasBadges, + item } = props const { parcel, estate, wearable, emote, ens } = asset.data @@ -144,6 +145,13 @@ const AssetImage = (props: Props) => { const [isTracked, setIsTracked] = useState(false) + const isAvailableForMint = + isNFT(asset) && + (item?.category === NFTCategory.WEARABLE || + item?.category === NFTCategory.EMOTE) && + item.available > 0 && + item.isOnSale + // pick a random emote const previewEmote = useMemo(() => { const poses = [ @@ -266,8 +274,18 @@ const AssetImage = (props: Props) => { emote={isTryingOnEnabled ? previewEmote : undefined} onLoad={handleLoad} onError={handleError} - dev={config.is(Env.DEVELOPMENT)} + // dev={config.is(Env.DEVELOPMENT)} /> + {isAvailableForMint ? ( + + ) : null} {isLoadingWearablePreview ? (
{ wheelStart={100} onLoad={handleLoad} onError={handleError} - dev={config.is(Env.DEVELOPMENT)} + // dev={config.is(Env.DEVELOPMENT)} /> {isLoadingWearablePreview ? (
@@ -509,9 +527,32 @@ const AssetImage = (props: Props) => { // the purpose of this wrapper is to make the div always be square, by using a 1x1 transparent pixel const AssetImageWrapper = (props: Props) => { - const { asset, className, showOrderListedTag, ...rest } = props + const { + asset, + className, + showOrderListedTag, + item, + onFetchItem, + ...rest + } = props - let classes = 'AssetImage' + useEffect(() => { + if (!item && isNFT(asset) && asset.itemId) { + onFetchItem(asset.contractAddress, asset.itemId) + } + }, [asset, item, onFetchItem]) + + const isAvailableForMint = useMemo( + () => + isNFT(asset) && + (item?.category === NFTCategory.WEARABLE || + item?.category === NFTCategory.EMOTE) && + item.available > 0 && + item.isOnSale, + [asset, item] + ) + + let classes = `AssetImage ${isAvailableForMint ? 'hasMintAvailable' : ''}` if (className) { classes += ' ' + className } @@ -543,7 +584,12 @@ const AssetImageWrapper = (props: Props) => { pixel
{showOrderListedTag ? : null} - +
{coordinates ? ( <> diff --git a/webapp/src/components/AssetImage/AssetImage.types.ts b/webapp/src/components/AssetImage/AssetImage.types.ts index 8dd24f21f..57f0434c9 100644 --- a/webapp/src/components/AssetImage/AssetImage.types.ts +++ b/webapp/src/components/AssetImage/AssetImage.types.ts @@ -1,17 +1,20 @@ import React from 'react' import { Dispatch } from 'redux' -import { Avatar, IPreviewController } from '@dcl/schemas' -import { Item } from '@dcl/schemas' -import { NFT } from '../../modules/nft/types' +import { Avatar, IPreviewController, Item, Rarity } from '@dcl/schemas' import { setIsTryingOn, SetIsTryingOnAction, setWearablePreviewController, SetWearablePreviewControllerAction } from '../../modules/ui/preview/actions' +import { Asset } from '../../modules/asset/types' +import { + FetchItemRequestAction, + fetchItemRequest +} from '../../modules/item/actions' export type Props = { - asset: NFT | Item + asset: Asset className?: string isDraggable?: boolean withNavigation?: boolean @@ -26,11 +29,13 @@ export type Props = { showOrderListedTag?: boolean onSetIsTryingOn: typeof setIsTryingOn onSetWearablePreviewController: typeof setWearablePreviewController + onFetchItem: typeof fetchItemRequest children?: React.ReactNode hasBadges?: boolean + item: Item | null } -export type OwnProps = Pick +export type OwnProps = Pick export enum ControlOptionAction { ZOOM_IN, @@ -41,12 +46,23 @@ export enum ControlOptionAction { export type MapStateProps = Pick< Props, - 'avatar' | 'wearableController' | 'isTryingOn' | 'isPlayingEmote' + 'avatar' | 'wearableController' | 'isTryingOn' | 'isPlayingEmote' | 'item' > export type MapDispatchProps = Pick< Props, - 'onSetIsTryingOn' | 'onSetWearablePreviewController' + 'onSetIsTryingOn' | 'onSetWearablePreviewController' | 'onFetchItem' > export type MapDispatch = Dispatch< - SetIsTryingOnAction | SetWearablePreviewControllerAction + | SetIsTryingOnAction + | SetWearablePreviewControllerAction + | FetchItemRequestAction > + +export type AvailableForMintPopupType = { + price: string + stock: number + rarity: Rarity + contractAddress: string + itemId: string + network: string +} diff --git a/webapp/src/components/AssetImage/AvailableForMintPopup.tsx b/webapp/src/components/AssetImage/AvailableForMintPopup.tsx new file mode 100644 index 000000000..5757981fc --- /dev/null +++ b/webapp/src/components/AssetImage/AvailableForMintPopup.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import { Network, Rarity } from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { Button, Icon, Popup } from 'decentraland-ui' +import { locations } from '../../modules/routing/locations' +import mintingIcon from '../../images/minting.png' +import infoIcon from '../../images/infoIcon.png' +import Mana from '../Mana/Mana' +import { formatWeiToAssetCard } from '../AssetCard/utils' +import { ManaToFiat } from '../ManaToFiat' +import { AvailableForMintPopupType } from './AssetImage.types' +import './AssetImage.css' + +const AvailableForMintPopup = ({ + price, + stock, + rarity, + contractAddress, + itemId, + network +}: AvailableForMintPopupType) => { + return ( +
+
+ mint + + + {t('asset_page.available_for_mint_popup.available_for_mint')} + +
+ {t('asset_page.available_for_mint_popup.buy_directly')} +
+ +
+
+
+ + {t('best_buying_option.minting.price').toUpperCase()}  + + } + on="hover" + /> + +
+
+ + {formatWeiToAssetCard(price)} + +
+ {+price > 0 && ( +
+ {'('} + + {')'} +
+ )} +
+
+
+ + {t('best_buying_option.minting.stock').toUpperCase()} + + + {stock.toLocaleString()}/{' '} + {Rarity.getMaxSupply(rarity).toLocaleString()} + +
+
+
+ ) +} + +export default React.memo(AvailableForMintPopup) diff --git a/webapp/src/components/AssetList/AssetList.container.ts b/webapp/src/components/AssetList/AssetList.container.ts index 650e5cff8..c36d02bb0 100644 --- a/webapp/src/components/AssetList/AssetList.container.ts +++ b/webapp/src/components/AssetList/AssetList.container.ts @@ -1,6 +1,5 @@ import { connect } from 'react-redux' import { isLoadingType } from 'decentraland-dapps/dist/modules/loading/selectors' - import { RootState } from '../../modules/reducer' import { FETCH_NFTS_REQUEST } from '../../modules/nft/actions' import { browse, clearFilters } from '../../modules/routing/actions' diff --git a/webapp/src/components/AssetList/AssetList.css b/webapp/src/components/AssetList/AssetList.css index a85ad1d78..b33d32f99 100644 --- a/webapp/src/components/AssetList/AssetList.css +++ b/webapp/src/components/AssetList/AssetList.css @@ -1,17 +1,44 @@ +/* .AssetsList { + height: calc( + 100vh - 287px + ); +} */ +/* 64px navbar + 65 navigation + 24 margin + 90 search bar + 20 margin + 56px footer = 319 */ +/* overflow-y: scroll; */ + +/* 64px navbar + 65 navigation + 12 margin + 134 con pills + 56 = 331 */ +/* ideal 343 */ +/* ideal sin filtros 287 */ + +.AssetsList { + position: relative; +} + .AssetsList .empty-actions { font-weight: bold; background: none; border: none; - color: var(--summer-red); + color: white; padding: 0; padding-top: 6px; cursor: pointer; + text-decoration: underline; + text-underline-offset: 4px; +} + +.empty-state { + color: white; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + line-height: 24px; } -.AssetsList .watermelon { - background-image: url('../../images/watermelon.svg'); - width: 56px; - height: 56px; +.watermelon { + background-image: url('../../images/noResults.svg'); + width: 66px; + height: 66px; display: flex; margin: auto; margin-bottom: 16px; diff --git a/webapp/src/components/AssetList/AssetList.tsx b/webapp/src/components/AssetList/AssetList.tsx index 699047c2e..c17826acf 100644 --- a/webapp/src/components/AssetList/AssetList.tsx +++ b/webapp/src/components/AssetList/AssetList.tsx @@ -1,24 +1,32 @@ -import React, { useCallback, useMemo, useEffect } from 'react' +import React, { useCallback, useMemo, useEffect, useRef, useState } from 'react' import { Link } from 'react-router-dom' -import { Button, Card, Loader } from 'decentraland-ui' +import InfiniteLoader from 'react-window-infinite-loader' +import AutoSizer from 'react-virtualized-auto-sizer' +import { + FixedSizeGrid, + FixedSizeGrid as Grid, + GridChildComponentProps, + GridOnItemsRenderedProps +} from 'react-window' +import { Button, Card, Loader, useMobileMediaQuery } from 'decentraland-ui' import { NFTCategory } from '@dcl/schemas' import { t, T } from 'decentraland-dapps/dist/modules/translation/utils' import { getAnalytics } from 'decentraland-dapps/dist/modules/analytics/utils' import { getCategoryFromSection } from '../../modules/routing/search' -import { getMaxQuerySize, MAX_PAGE } from '../../modules/vendor/api' -import { AssetType } from '../../modules/asset/types' import { Section } from '../../modules/vendor/decentraland' import { locations } from '../../modules/routing/locations' import * as events from '../../utils/events' -import { InfiniteScroll } from '../InfiniteScroll' import { AssetCard } from '../AssetCard' import { getLastVisitedElementId } from './utils' import { Props } from './AssetList.types' import './AssetList.css' +const GUTTER_SIZE = 6 +const CARD_MIN_WIDTH = 260 +const CARD_HEIGHT = 360 + const AssetList = (props: Props) => { const { - vendor, section, assetType, assets, @@ -33,20 +41,30 @@ const AssetList = (props: Props) => { onClearFilters } = props - useEffect(() => { - if (visitedLocations.length > 1) { - const [currentLocation, previousLocation] = visitedLocations - const elementId = getLastVisitedElementId( - currentLocation?.pathname, - previousLocation?.pathname - ) - if (elementId) { - document.getElementById(elementId)?.scrollIntoView() + const isMobile = useMobileMediaQuery() + + const handleScroll = useCallback( + (gridRef: FixedSizeGrid | null, cardsPerRow: number) => { + if (visitedLocations.length > 1) { + const [currentLocation, previousLocation] = visitedLocations + const elementId = getLastVisitedElementId( + currentLocation?.pathname, + previousLocation?.pathname + ) + if (elementId && cardsPerRow && gridRef) { + const elementIndex = assets.findIndex(asset => asset.id === elementId) + const elementRow = Math.floor(elementIndex / cardsPerRow) + const elementColumn = elementIndex % cardsPerRow + gridRef.scrollToItem({ + align: 'center', + columnIndex: elementColumn, + rowIndex: elementRow + }) + } } - } - // only run effect on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, + [assets, visitedLocations] + ) const handleLoadMore = useCallback( newPage => { @@ -55,17 +73,13 @@ const AssetList = (props: Props) => { }, [onBrowse] ) - const maxQuerySize = getMaxQuerySize(vendor) - - const hasMorePages = - (assets.length !== count || count === maxQuerySize) && page <= MAX_PAGE const emptyStateTranslationString = useMemo(() => { if (assets.length > 0) { return '' } else if (section) { if (isManager) { - return 'nft_list.simple_empty' + return 'nft_list.empty' } const isEmoteOrWearableSection = [ @@ -77,7 +91,7 @@ const AssetList = (props: Props) => { return search ? 'nft_list.empty_search' : 'nft_list.empty' } } - return 'nft_list.simple_empty' + return 'nft_list.empty' }, [assets.length, search, section, isManager]) const renderEmptyState = useCallback(() => { @@ -94,21 +108,21 @@ const AssetList = (props: Props) => { ) } - const currentSection = - assetType === AssetType.ITEM - ? t('browse_page.primary_market_title').toLocaleLowerCase() - : t('browse_page.secondary_market_title').toLocaleLowerCase() - const alternativeSection = - assetType === AssetType.ITEM - ? t('browse_page.secondary_market_title').toLocaleLowerCase() - : t('browse_page.primary_market_title').toLocaleLowerCase() + // const currentSection = + // assetType === AssetType.ITEM + // ? t('browse_page.primary_market_title').toLocaleLowerCase() + // : t('browse_page.secondary_market_title').toLocaleLowerCase() + // const alternativeSection = + // assetType === AssetType.ITEM + // ? t('browse_page.secondary_market_title').toLocaleLowerCase() + // : t('browse_page.primary_market_title').toLocaleLowerCase() return (
{t(`${emptyStateTranslationString}.title`, { - search, - currentSection + search + // currentSection })} @@ -116,23 +130,8 @@ const AssetList = (props: Props) => { id={`${emptyStateTranslationString}.action`} values={{ search, - currentSection, - section: alternativeSection, - searchStore: (chunks: string) => ( - - ), + // currentSection, + // section: alternativeSection, 'if-filters': (chunks: string) => hasFiltersEnabled ? chunks : '', clearFilters: (chunks: string) => ( @@ -146,10 +145,8 @@ const AssetList = (props: Props) => {
) }, [ - assetType, emptyStateTranslationString, hasFiltersEnabled, - onBrowse, onClearFilters, search, section @@ -160,20 +157,199 @@ const AssetList = (props: Props) => { [assets.length, isLoading] ) + const EXTRA_SPACE_FOR_SHADOW = 26 + const EXTRA_SPACE_TOP_FOR_SHADOW = 24 + + const VirtualizedCard = useCallback( + ({ + columnIndex, + rowIndex, + style, + cardsPerRow + }: GridChildComponentProps & { cardsPerRow: number }) => { + const asset = assets[rowIndex * cardsPerRow + columnIndex] + return asset ? ( +
+ +
+ ) : null + }, + [assetType, assets, isManager, isMobile] + ) + + const promiseOfLoadingMore = useRef<(() => void) | null>() + + /** The loadMoreItems fn needs to return a promise that is resolved when the load more finishes + to do so, we store a promise that resolves when the `isLoading` turns from true to false */ + useEffect(() => { + if (!isLoading && promiseOfLoadingMore.current) { + promiseOfLoadingMore.current() + promiseOfLoadingMore.current = null // resets the ref + } + }, [isLoading]) + + const loadMoreItems = useCallback(() => { + handleLoadMore(page + 1) + return new Promise(res => { + promiseOfLoadingMore.current = res + }) + }, [handleLoadMore, page]) + + const getCardsDimensions = useCallback( + (width: number) => { + const CARDS_PER_ROW_MOBILE = 2 + const CARD_HEIGHT_MOBILE = 345 + if (isMobile) { + console.log('width * 0.5: ', width * 0.5) + return { + cardsPerRow: CARDS_PER_ROW_MOBILE, + cardWidth: width * 0.5, + cardHeight: CARD_HEIGHT_MOBILE + } + } + const quotient = Math.floor(width / CARD_MIN_WIDTH) + const reminder = width % CARD_MIN_WIDTH + const dimensions = { + cardsPerRow: quotient, + cardWidth: CARD_MIN_WIDTH + reminder / quotient, + cardHeight: CARD_HEIGHT + } + return dimensions + }, + [isMobile] + ) + + const isItemLoaded = useCallback( + (index: number) => { + const hasNextPage = count && assets.length < count + return !hasNextPage || index < assets.length + }, + [assets.length, count] + ) + const renderAssetCards = useCallback( - () => - assets.map((assets, index) => ( - - )), - [assetType, assets, isManager] + () => ( + + {({ height, width }) => { + if (!height || !width) return null + const { cardsPerRow, cardWidth } = getCardsDimensions(width) + const rowCount = Math.ceil(assets.length / cardsPerRow) + const threshold = cardsPerRow // 1 row more + + return ( + !!height && + !!width && ( + + {({ onItemsRendered, ref }) => { + const newOnItemsRendered = ( + props: GridOnItemsRenderedProps + ) => { + const { + overscanRowStartIndex, + overscanRowStopIndex, + visibleRowStopIndex, + visibleRowStartIndex + } = props + const params = { + overscanStartIndex: Math.ceil( + overscanRowStartIndex * cardsPerRow + ), + overscanStopIndex: Math.ceil( + (overscanRowStopIndex + 1) * cardsPerRow + ), + visibleStartIndex: Math.ceil( + visibleRowStartIndex * cardsPerRow + ), + visibleStopIndex: Math.ceil( + (visibleRowStopIndex + 1) * cardsPerRow + ) + } + onItemsRendered(params) + } + return ( + { + ref(elemRef) + handleScroll(elemRef, cardsPerRow) + }} + height={height} + width={width + EXTRA_SPACE_FOR_SHADOW * 2} + columnCount={cardsPerRow} + columnWidth={cardWidth} + rowCount={rowCount} + rowHeight={CARD_HEIGHT + GUTTER_SIZE * 2} + onItemsRendered={newOnItemsRendered} + style={{ + position: 'absolute', + left: -EXTRA_SPACE_FOR_SHADOW + }} + > + {props => ( + + )} + + ) + }} + + ) + ) + }} + + ), + [ + VirtualizedCard, + assets.length, + count, + getCardsDimensions, + handleScroll, + isItemLoaded, + loadMoreItems + ] ) + const DEFAULT_FOOTER_SIZE = 56 + const footerHeight = + document.querySelector('ui.container.dcl.footer')?.getBoundingClientRect() + .height || DEFAULT_FOOTER_SIZE + + const [assetListTopOffset, setAssetListTopOffset] = useState(0) + return ( -
+
{ + const rect = ref?.getBoundingClientRect() + rect && setAssetListTopOffset(rect.top) + !isMobile && + window.addEventListener('scroll', () => { + const rect = ref?.getBoundingClientRect() + rect && setAssetListTopOffset(rect.top) + }) + }} + className="AssetsList" + style={{ + height: `calc(100vh - ${assetListTopOffset + + (isMobile ? 0 : footerHeight)}px)` + }} + > {isLoading ? ( <>
@@ -182,18 +358,11 @@ const AssetList = (props: Props) => {
) : null} - {assets.length > 0 ? ( + {assets.length > 0 && assetListTopOffset ? ( {renderAssetCards()} + ) : shouldRenderEmptyState ? ( + renderEmptyState() ) : null} - - {shouldRenderEmptyState ? renderEmptyState() : null} -
) } diff --git a/webapp/src/components/AssetPage/AssetPage.container.tsx b/webapp/src/components/AssetPage/AssetPage.container.tsx deleted file mode 100644 index 7f8514150..000000000 --- a/webapp/src/components/AssetPage/AssetPage.container.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { connect } from 'react-redux' -import { Dispatch } from 'redux' -import { goBack } from '../../modules/routing/actions' -import AssetPage from './AssetPage' -import { MapDispatchProps } from './AssetPage.types' - -const mapState = () => ({}) - -const mapDispatch = (dispatch: Dispatch): MapDispatchProps => ({ - onBack: (location?: string) => dispatch(goBack(location)) -}) - -export default connect(mapState, mapDispatch)(AssetPage) diff --git a/webapp/src/components/AssetPage/AssetPage.css b/webapp/src/components/AssetPage/AssetPage.css index b804970ea..454f2762e 100644 --- a/webapp/src/components/AssetPage/AssetPage.css +++ b/webapp/src/components/AssetPage/AssetPage.css @@ -9,3 +9,14 @@ .AssetPage .asset-container { width: 100%; } + +.AssetPage .backText { + margin-left: 45px; + margin-top: 10px; +} + +@media (max-width: 768px) { + .AssetPage .backText { + margin-top: -20px; + } +} diff --git a/webapp/src/components/AssetPage/AssetPage.tsx b/webapp/src/components/AssetPage/AssetPage.tsx index 97484a39e..bab0b8421 100644 --- a/webapp/src/components/AssetPage/AssetPage.tsx +++ b/webapp/src/components/AssetPage/AssetPage.tsx @@ -1,6 +1,7 @@ -import React from 'react' -import { Page, Section, Column } from 'decentraland-ui' +import React, { useCallback } from 'react' +import { Item } from '@dcl/schemas' import { mapAsset } from '../../modules/asset/utils' +import { Page, Section, Column } from 'decentraland-ui' import { AssetProviderPage } from '../AssetProviderPage' import { Navbar } from '../Navbar' import { Navigation } from '../Navigation' @@ -16,6 +17,10 @@ import { EmoteDetail } from './EmoteDetail' import './AssetPage.css' const AssetPage = ({ type }: Props) => { + const renderItemDetail = useCallback( + (item: Item) => , + [] + ) return ( <> @@ -30,8 +35,8 @@ const AssetPage = ({ type }: Props) => { {mapAsset( asset, { - wearable: item => , - emote: item => + wearable: renderItemDetail, + emote: renderItemDetail }, { ens: nft => , diff --git a/webapp/src/components/AssetPage/AssetPage.types.ts b/webapp/src/components/AssetPage/AssetPage.types.ts index f4643794a..8d0424d51 100644 --- a/webapp/src/components/AssetPage/AssetPage.types.ts +++ b/webapp/src/components/AssetPage/AssetPage.types.ts @@ -2,7 +2,4 @@ import { AssetType } from '../../modules/asset/types' export type Props = { type: AssetType - onBack: (location?: string) => void } - -export type MapDispatchProps = Pick diff --git a/webapp/src/components/AssetPage/BaseDetail/BaseDetail.css b/webapp/src/components/AssetPage/BaseDetail/BaseDetail.css index f983bf1c8..303aa61b3 100644 --- a/webapp/src/components/AssetPage/BaseDetail/BaseDetail.css +++ b/webapp/src/components/AssetPage/BaseDetail/BaseDetail.css @@ -1,18 +1,3 @@ -.BaseDetail .top-header { - display: flex; - align-items: center; -} - -.BaseDetail .top-header .back { - display: unset; - align-self: flex-start; -} - -.BaseDetail .top-header .favorites { - flex-grow: 1; - justify-content: flex-end; -} - .BaseDetail .ui.container > .info { width: 100%; display: flex; @@ -93,10 +78,6 @@ padding: 25px; } -.BaseDetail .ui.container > div { - margin-bottom: 48px; -} - /* Override ui stats */ .BaseDetail .dcl.stats + .dcl.stats { diff --git a/webapp/src/components/AssetPage/BaseDetail/BaseDetail.tsx b/webapp/src/components/AssetPage/BaseDetail/BaseDetail.tsx index 948a42108..70503a2f0 100644 --- a/webapp/src/components/AssetPage/BaseDetail/BaseDetail.tsx +++ b/webapp/src/components/AssetPage/BaseDetail/BaseDetail.tsx @@ -1,16 +1,11 @@ import React from 'react' import classNames from 'classnames' -import { Back, Container } from 'decentraland-ui' -import { useMobileMediaQuery } from 'decentraland-ui/dist/components/Media' -import { isNFT, mapAsset } from '../../../modules/asset/utils' -import { AssetType } from '../../../modules/asset/types' -import { locations } from '../../../modules/routing/locations' -import { Sections } from '../../../modules/routing/types' +import { Container } from 'decentraland-ui' import { Box } from '../../AssetBrowse/Box' // TODO: make it importable from the root directory as AssetDetails or AssetDetailsBox import { DetailsBox } from '../../DetailsBox' -import { FavoritesCounter } from '../../FavoritesCounter' import { PageHeader } from '../../PageHeader' +import OnBack from '../OnBack' import Title from '../Title' import { Props } from './BaseDetail.types' import './BaseDetail.css' @@ -25,74 +20,11 @@ const BaseDetail = ({ below, className, actions, - showDetails, - isFavoritesEnabled, - onBack + showDetails }: Props) => { - const isMobile = useMobileMediaQuery() - return (
-
- - onBack( - mapAsset( - asset, - { - wearable: () => - locations.browse({ - assetType: AssetType.ITEM, - section: Sections.decentraland.WEARABLES - }), - emote: () => - locations.browse({ - assetType: AssetType.ITEM, - section: Sections.decentraland.EMOTES - }) - }, - { - ens: () => - locations.browse({ - assetType: AssetType.NFT, - section: Sections.decentraland.ENS - }), - estate: () => - locations.lands({ - assetType: AssetType.NFT, - section: Sections.decentraland.ESTATES, - isMap: false, - isFullscreen: false - }), - parcel: () => - locations.lands({ - assetType: AssetType.NFT, - section: Sections.decentraland.PARCELS, - isMap: false, - isFullscreen: false - }), - wearable: () => - locations.browse({ - assetType: AssetType.NFT, - section: Sections.decentraland.WEARABLES - }), - emote: () => - locations.browse({ - assetType: AssetType.NFT, - section: Sections.decentraland.EMOTES - }) - }, - () => undefined - ) - ) - } - /> - {isFavoritesEnabled && isMobile && !isNFT(asset) ? ( - - ) : null} -
+ {assetImage}
diff --git a/webapp/src/components/AssetPage/BaseDetail/BaseDetail.types.ts b/webapp/src/components/AssetPage/BaseDetail/BaseDetail.types.ts index 5186ef4c3..485cfa9c6 100644 --- a/webapp/src/components/AssetPage/BaseDetail/BaseDetail.types.ts +++ b/webapp/src/components/AssetPage/BaseDetail/BaseDetail.types.ts @@ -14,9 +14,4 @@ export type Props = { className?: string actions?: ReactNode showDetails?: boolean - isFavoritesEnabled: boolean - onBack: (location?: string) => void } - -export type MapStateProps = Pick -export type MapDispatchProps = Pick diff --git a/webapp/src/components/AssetPage/BaseDetail/index.ts b/webapp/src/components/AssetPage/BaseDetail/index.ts index 6eab541cd..196edfa19 100644 --- a/webapp/src/components/AssetPage/BaseDetail/index.ts +++ b/webapp/src/components/AssetPage/BaseDetail/index.ts @@ -1,3 +1,3 @@ -import BaseDetail from './BaseDetail.container' +import BaseDetail from './BaseDetail' export default BaseDetail diff --git a/webapp/src/components/AssetPage/BestBuyingOption/BestBuyingOption.module.css b/webapp/src/components/AssetPage/BestBuyingOption/BestBuyingOption.module.css new file mode 100644 index 000000000..9316eaf6e --- /dev/null +++ b/webapp/src/components/AssetPage/BestBuyingOption/BestBuyingOption.module.css @@ -0,0 +1,223 @@ +.BestBuyingOption { + border: 2px solid var(--secondary); + border-radius: 10px; + display: flex; + padding: 24px; + width: 100%; + background-color: var(--secondary); +} + +.AlignEnd { + margin-top: auto; +} + +.BestBuyingOption .cardTitle { + font-weight: 400; + font-size: 14px; + align-items: center; + display: flex; +} + +.BestBuyingOption .noOffer { + margin-bottom: 8px; + font-weight: 400; + font-size: 14px; + align-items: center; + display: flex; +} + +.BestBuyingOption .emptyCardTitle { + font-weight: 600; + font-size: 18px; +} + +.BestBuyingOption .containerColumn { + color: var(--primary-text); + display: flex; + flex-direction: column; + font-weight: 400; + font-size: 14px; + gap: 10px; +} + +.BestBuyingOption .nolistingsImage { + height: 40px; + align-self: center; +} + +.BestBuyingOption .checkTheOwners { + padding: 0px 5px; + text-decoration: underline; + cursor: pointer; +} + +.BestBuyingOption .mintingStockContainer { + color: var(--primary-text); + display: flex; + flex-direction: column; + font-weight: 400; + font-size: 15px; + justify-content: space-between; +} + +.BestBuyingOption .fullWidth { + width: 100%; +} + +.BestBuyingOption .containerRow { + display: flex; + flex-direction: row; + gap: 10px; + align-items: flex-end; +} + +.BestBuyingOption .emptyCardContainer { + display: flex; + flex-direction: row; + gap: 10px; + padding: 9px; + justify-content: center; + width: 100%; +} + +.BestBuyingOption .informationContainer { + display: flex; + margin: 10px 0px 0px 0px; + justify-content: space-between; + flex-grow: 1; + width: 100%; +} + +.BestBuyingOption .mintingContainer { + display: flex; + justify-content: space-between; + flex-grow: 1; + width: 100%; +} + +.BestBuyingOption .informationBold { + color: white; + font-size: 30px; + font-weight: 600; + margin: unset; +} + +.BestBuyingOption .stockText { + color: white; + font-size: 18px; + font-weight: 600; + display: flex; + align-items: flex-end; + margin-bottom: 7px; +} + +.BestBuyingOption .informationBold :global(.ui.header.large) { + font-size: 30px; + font-weight: 600; +} + +.BestBuyingOption .informationBold :global(.dcl.mana .matic) { + margin-bottom: 5px; + width: 22px; + height: 22px; +} + +.BestBuyingOption .informationText { + color: white; + font-size: 24px; + line-height: 1.7; +} + +.BestBuyingOption .informationListingText { + color: white; + font-size: 14px; + line-height: 1.5; +} + +.BestBuyingOption .centerItems { + align-items: center; + gap: 0px; +} + +.BestBuyingOption .informationTitle { + font-size: 14px; + color: var(--secondary-text); + font-weight: 600; + display: flex; + align-items: center; + line-height: 7px; +} + +.BestBuyingOption .informationTooltip { + width: 14px; + height: 14px; +} + +.BestBuyingOption .mintingIcon { + height: 19px; +} + +.BestBuyingOption :global(.ui.header.medium) { + font-size: 17px; +} + +.BestBuyingOption :global(.ui.button + .ui.button) { + margin-left: unset; +} + +.BestBuyingOption :global(.ui.loader.active) { + display: flex; + position: unset; + margin-top: 20px; +} + +.BestBuyingOption .emptyContainer { + display: flex; + width: 100%; + justify-content: center; + align-items: center; + height: 150px; +} +.BestBuyingOption .buyWithCardClassName { + background-color: white !important; + color: black !important; +} + +.BestBuyingOption .primaryButton { + background-color: var(--primary) !important; +} + +.BestBuyingOption .outlinedButton { + border: 1px solid var(--secondary-text) !important; +} + +.BestBuyingOption .expiresAt { + display: flex; + align-items: center; + font-size: 14px; +} + +.BestBuyingOption .listingMana :global(.ui.header.small) { + font-size: 14px; + display: flex; + align-items: center; +} + +.BestBuyingOption .columnListing { + color: var(--primary-text); + display: flex; + flex-direction: column; + font-weight: 400; + font-size: 14px; + justify-content: space-between; +} + +@media (max-width: 768px) { + .BestBuyingOption { + padding: 24px 15px; + } + + .BestBuyingOption .informationContainer { + gap: 10px; + } +} diff --git a/webapp/src/components/AssetPage/BestBuyingOption/BestBuyingOption.spec.tsx b/webapp/src/components/AssetPage/BestBuyingOption/BestBuyingOption.spec.tsx new file mode 100644 index 000000000..c9af09071 --- /dev/null +++ b/webapp/src/components/AssetPage/BestBuyingOption/BestBuyingOption.spec.tsx @@ -0,0 +1,206 @@ +import { + Bid, + ChainId, + Item, + ListingStatus, + Network, + NFTCategory, + Order, + Rarity +} from '@dcl/schemas' +import { waitFor } from '@testing-library/react' +import React, { RefObject } from 'react' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' + +import * as bidAPI from '../../../modules/vendor/decentraland/bid/api' +import * as orderAPI from '../../../modules/vendor/decentraland/order/api' +import { renderWithProviders } from '../../../utils/tests' +import BestBuyingOption from './BestBuyingOption' +import { formatWeiMANA } from '../../../lib/mana' + +jest.mock('../../../modules/vendor/decentraland/nft/api') +jest.mock('../../../modules/vendor/decentraland/order/api') +jest.mock('../../../modules/vendor/decentraland/bid/api') +jest.mock('decentraland-dapps/dist/containers', () => { + const module = jest.requireActual('decentraland-dapps/dist/containers') + return { + ...module, + Profile: () =>
+ } +}) + +describe('Best Buying Option', () => { + let asset: Item = { + contractAddress: '0xaddress', + itemId: '1', + id: '1', + name: 'asset name', + thumbnail: '', + url: '', + category: NFTCategory.WEARABLE, + rarity: Rarity.UNIQUE, + price: '10', + available: 2, + isOnSale: true, + creator: '0xcreator', + beneficiary: null, + createdAt: 1671033414000, + updatedAt: 1671033414000, + reviewedAt: 1671033414000, + soldAt: 1671033414000, + data: { + parcel: undefined, + estate: undefined, + wearable: undefined, + ens: undefined, + emote: undefined + }, + network: Network.MATIC, + chainId: ChainId.ETHEREUM_GOERLI, + firstListedAt: null + } + + let orderResponse: Order = { + id: '1', + marketplaceAddress: '0xmarketplace', + contractAddress: '0xaddress', + tokenId: '1', + owner: '0x92712b730b9a474f99a47bb8b1750190d5959a2b', + buyer: null, + price: '100000000000000000000', + status: ListingStatus.OPEN, + expiresAt: 1671033414000, + createdAt: 1671033414000, + updatedAt: 0, + network: Network.MATIC, + chainId: ChainId.ETHEREUM_GOERLI, + issuedId: '1' + } + + let bid: Bid = { + id: '1', + bidAddress: '0xbid', + bidder: 'bidder', + seller: 'seller', + price: '2', + fingerprint: '', + status: ListingStatus.OPEN, + blockchainId: '1', + blockNumber: '', + expiresAt: 1671033414000, + createdAt: 1671033414000, + updatedAt: 0, + contractAddress: '0xaddress', + tokenId: '', + network: Network.MATIC, + chainId: ChainId.ETHEREUM_GOERLI + } + + afterEach(() => { + jest.resetAllMocks() + }) + + describe('Mint option', () => { + it('should render the mint option', async () => { + const reference: RefObject = React.createRef() + const { getByText } = renderWithProviders( + + ) + + expect( + getByText(t('best_buying_option.minting.title')) + ).toBeInTheDocument() + }) + + it('should render the mint price', async () => { + const reference: RefObject = React.createRef() + const { getByText } = renderWithProviders( + + ) + + const price = formatWeiMANA(asset.price) + + expect(getByText(price)).toBeInTheDocument() + }) + }) + + describe('Listing option', () => { + beforeEach(() => { + asset.available = 0 + ;(orderAPI.orderAPI.fetchOrders as jest.Mock).mockResolvedValueOnce({ + data: [orderResponse], + total: 1 + }) + ;(bidAPI.bidAPI.fetchByNFT as jest.Mock).mockResolvedValueOnce({ + data: [bid], + total: 1 + }) + }) + + it('should render the listing option', async () => { + const reference: RefObject = React.createRef() + const { getByText, findByTestId } = renderWithProviders( + + ) + + const loader = await findByTestId('loader') + + await waitFor(() => { + expect(loader).not.toBeInTheDocument() + }) + + expect( + getByText(t('best_buying_option.buy_listing.title'), { exact: false }) + ).toBeInTheDocument() + }) + + it('should render the listing price and de highest offer for that NFT', async () => { + const reference: RefObject = React.createRef() + const { getByText, findByTestId } = renderWithProviders( + + ) + + const loader = await findByTestId('loader') + + await waitFor(() => { + expect(loader).not.toBeInTheDocument() + }) + + const price = formatWeiMANA(orderResponse.price) + + const highestOffer = formatWeiMANA(bid.price) + + expect(getByText(price)).toBeInTheDocument() + expect(getByText(highestOffer)).toBeInTheDocument() + }) + }) + + describe('No available options', () => { + beforeEach(() => { + asset.available = 0 + ;(orderAPI.orderAPI.fetchOrders as jest.Mock).mockResolvedValueOnce({ + data: [], + total: 0 + }) + ;(bidAPI.bidAPI.fetchByNFT as jest.Mock).mockResolvedValueOnce({ + data: [bid], + total: 0 + }) + }) + + it('should render no options available', async () => { + const reference: RefObject = React.createRef() + const { getByText, findByTestId } = renderWithProviders( + + ) + + const loader = await findByTestId('loader') + + await waitFor(() => { + expect(loader).not.toBeInTheDocument() + }) + + expect(getByText(t('best_buying_option.empty.title'))).toBeInTheDocument() + }) + }) +}) diff --git a/webapp/src/components/AssetPage/BestBuyingOption/BestBuyingOption.tsx b/webapp/src/components/AssetPage/BestBuyingOption/BestBuyingOption.tsx new file mode 100644 index 000000000..372bd5384 --- /dev/null +++ b/webapp/src/components/AssetPage/BestBuyingOption/BestBuyingOption.tsx @@ -0,0 +1,316 @@ +import React, { useEffect, useState } from 'react' +import { Link, useHistory, useLocation } from 'react-router-dom' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { + Bid, + BidSortBy, + Item, + ListingStatus, + Network, + Order, + OrderFilters, + OrderSortBy, + Rarity +} from '@dcl/schemas' +import { Button, Loader, Popup } from 'decentraland-ui' +import { formatDistanceToNow } from '../../../lib/date' +import { locations } from '../../../modules/routing/locations' +import { isNFT } from '../../../modules/asset/utils' +import { bidAPI, orderAPI } from '../../../modules/vendor/decentraland' +import mintingIcon from '../../../images/minting.png' +import infoIcon from '../../../images/infoIcon.png' +import clock from '../../../images/clock.png' +import noListings from '../../../images/noListings.png' +import Mana from '../../Mana/Mana' +import { ManaToFiat } from '../../ManaToFiat' +import { formatWeiToAssetCard } from '../../AssetCard/utils' +import { BuyNFTButtons } from '../SaleActionBox/BuyNFTButtons' +import { ItemSaleActions } from '../SaleActionBox/ItemSaleActions' +import { BuyOptions, Props } from './BestBuyingOption.types' +import styles from './BestBuyingOption.module.css' + +const BestBuyingOption = ({ asset, tableRef }: Props) => { + const [buyOption, setBuyOption] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [listing, setListing] = useState<{ + order: Order + total: number + } | null>(null) + const [mostExpensiveBid, setMostExpensiveBid] = useState(null) + const history = useHistory() + + const location = useLocation() + + const handleViewOffers = () => { + history.replace({ + pathname: location.pathname, + search: `selectedTableTab=owners` + }) + tableRef && + tableRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' }) + } + + useEffect(() => { + if (asset && !isNFT(asset)) { + if (asset.available > 0 && asset.isOnSale) { + setBuyOption(BuyOptions.MINT) + } else { + setIsLoading(true) + + let params: OrderFilters = { + contractAddress: asset.contractAddress, + first: 1, + skip: 0, + status: ListingStatus.OPEN + } + const sortBy = OrderSortBy.CHEAPEST + + if (asset.network === Network.MATIC) { + params.itemId = asset.itemId + } else if (asset.network === Network.ETHEREUM) { + params.nftName = asset.name + } + + orderAPI + .fetchOrders(params, sortBy) + .then(response => { + if (response.data.length > 0) { + setBuyOption(BuyOptions.BUY_LISTING) + setListing({ order: response.data[0], total: response.total }) + bidAPI + .fetchByNFT( + asset.contractAddress, + response.data[0].tokenId, + ListingStatus.OPEN, + BidSortBy.MOST_EXPENSIVE, + '1' + ) + .then(response => { + setMostExpensiveBid(response.data[0]) + }) + .finally(() => setIsLoading(false)) + .catch(error => { + console.error(error) + }) + } + }) + .finally(() => setIsLoading(false)) + .catch(error => { + console.error(error) + }) + } + } + }, [asset]) + + const customClasses = { + primaryButton: styles.primaryButton, + secondaryButton: styles.buyWithCardClassName, + outlinedButton: styles.outlinedButton, + buyWithCardClassName: styles.buyWithCardClassName + } + + return ( +
+ {isLoading ? ( +
+ +
+ ) : buyOption === BuyOptions.MINT && asset && !isNFT(asset) ? ( +
+ + {t('best_buying_option.minting.title')}  + mint +   + + } + on="hover" + /> + +
+
+ + {t('best_buying_option.minting.price').toUpperCase()}  + + } + on="hover" + /> + +
+
+ + {formatWeiToAssetCard(asset.price)} + +
+ {+asset.price > 0 && ( +
+ {'('} + + {')'} +
+ )} +
+
+
+ + {t('best_buying_option.minting.stock').toUpperCase()} + + + {asset.available.toLocaleString()}/{' '} + {Rarity.getMaxSupply(asset.rarity).toLocaleString()} + +
+
+ +
+ ) : buyOption === BuyOptions.BUY_LISTING && asset && listing ? ( +
+ + {t('best_buying_option.buy_listing.title')}:   + {t('best_buying_option.buy_listing.issue_number')}  # + {listing.order.issuedId} + +
+
+ + {t('best_buying_option.minting.price').toUpperCase()} + +
+
+ + {formatWeiToAssetCard(listing.order.price)} + +
+ {+listing.order.price > 0 && ( +
+ {'('} + + {')'} +
+ )} +
+
+ +
+ + {t( + 'best_buying_option.buy_listing.highest_offer' + ).toUpperCase()} + +
+ {mostExpensiveBid ? ( + <> +
+ + {formatWeiToAssetCard(mostExpensiveBid.price)} + +
+ +
+ {'('} + + {')'} +
+ + ) : ( + + {t('best_buying_option.buy_listing.no_offer')} + + )} +
+
+
+ + + + clock +   + {t('best_buying_option.buy_listing.expires')}  + {formatDistanceToNow(listing.order.expiresAt, { + addSuffix: true + })} + . + +
+ ) : ( +
+ {t('best_buying_option.empty.title')} +
+ + {t('best_buying_option.empty.title')} + + + {t('best_buying_option.empty.you_can')} + + {t('best_buying_option.empty.check_the_current_owners')} + + + {t('best_buying_option.empty.and_make_an_offer')} + +
+
+ )} +
+ ) +} + +export default React.memo(BestBuyingOption) diff --git a/webapp/src/components/AssetPage/BestBuyingOption/BestBuyingOption.types.ts b/webapp/src/components/AssetPage/BestBuyingOption/BestBuyingOption.types.ts new file mode 100644 index 000000000..1e62f7e9f --- /dev/null +++ b/webapp/src/components/AssetPage/BestBuyingOption/BestBuyingOption.types.ts @@ -0,0 +1,12 @@ +import { RefObject } from 'react' +import { Asset } from '../../../modules/asset/types' + +export type Props = { + asset: Asset | null + tableRef?: RefObject | null +} + +export enum BuyOptions { + MINT = 'MINT', + BUY_LISTING = 'BUY_LISTING' +} diff --git a/webapp/src/components/AssetPage/BestBuyingOption/index.ts b/webapp/src/components/AssetPage/BestBuyingOption/index.ts new file mode 100644 index 000000000..efe97114b --- /dev/null +++ b/webapp/src/components/AssetPage/BestBuyingOption/index.ts @@ -0,0 +1,3 @@ +import BestBuyingOption from './BestBuyingOption' + +export { BestBuyingOption } diff --git a/webapp/src/components/AssetPage/BidsTable/BidsTable.container.ts b/webapp/src/components/AssetPage/BidsTable/BidsTable.container.ts new file mode 100644 index 000000000..f53cd4ec7 --- /dev/null +++ b/webapp/src/components/AssetPage/BidsTable/BidsTable.container.ts @@ -0,0 +1,21 @@ +import { isLoadingType } from 'decentraland-dapps/dist/modules/loading/selectors' +import { connect } from 'react-redux' +import { RootState } from '../../../modules/reducer' +import { getAddress, getLoading } from '../../../modules/wallet/selectors' +import { + ACCEPT_BID_REQUEST, + acceptBidRequest +} from '../../../modules/bid/actions' +import { MapDispatch, MapDispatchProps, MapStateProps } from './BidsTable.types' +import BidsTable from './BidsTable' + +const mapState = (state: RootState): MapStateProps => ({ + address: getAddress(state), + isAcceptingBid: isLoadingType(getLoading(state), ACCEPT_BID_REQUEST) +}) + +const mapDispatch = (dispatch: MapDispatch): MapDispatchProps => ({ + onAccept: bid => dispatch(acceptBidRequest(bid)) +}) + +export default connect(mapState, mapDispatch)(BidsTable) diff --git a/webapp/src/components/AssetPage/BidsTable/BidsTable.module.css b/webapp/src/components/AssetPage/BidsTable/BidsTable.module.css new file mode 100644 index 000000000..a9edce053 --- /dev/null +++ b/webapp/src/components/AssetPage/BidsTable/BidsTable.module.css @@ -0,0 +1,82 @@ +.BidsTable { + position: relative; +} + +.linkedProfileRow { + color: var(--primary) !important; +} + +.BidsTable .headerMargin { + padding: 20px !important; +} + +.issuedIdContainer { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + font-size: 14px; +} + +.BidsTable .issuedId { + font-size: 15px; + font-weight: 700; +} + +.row { + display: flex; + flex-direction: row; + gap: 10px; + justify-content: space-between; + align-items: center; +} + +.badgeContainer :global(.dcl.badge) { + color: white; + background-color: transparent !important; +} + +.badgeContainer { + display: flex; + flex-direction: row; + gap: 10px; +} + +.BidsTable .goToNFT { + color: white; + padding-right: 25px; +} + +.emptyTable { + width: 100%; + height: 350px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + font-size: 20px; +} + +.manaField { + display: flex; + align-items: center; + font-size: 14px; +} + +.manaField :global(.ui.header:last-child) { + font-size: 14px; +} + +.viewListingContainer { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-right: 10px; +} + +@media (max-width: 768px) { + .linkedProfileRow { + margin-left: unset; + } +} diff --git a/webapp/src/components/AssetPage/BidsTable/BidsTable.tsx b/webapp/src/components/AssetPage/BidsTable/BidsTable.tsx new file mode 100644 index 000000000..960c1d4df --- /dev/null +++ b/webapp/src/components/AssetPage/BidsTable/BidsTable.tsx @@ -0,0 +1,164 @@ +import { ethers } from 'ethers' +import React, { useEffect, useState } from 'react' +import { Bid, BidSortBy } from '@dcl/schemas' +import { Mana, useTabletAndBelowMediaQuery } from 'decentraland-ui' +import { T, t } from 'decentraland-dapps/dist/modules/translation/utils' +import { bidAPI } from '../../../modules/vendor/decentraland' +import { formatWeiMANA } from '../../../lib/mana' +import { TableContent } from '../../Table/TableContent' +import { DataTableType } from '../../Table/TableContent/TableContent.types' +import TableContainer from '../../Table/TableContainer' +import { AssetType } from '../../../modules/asset/types' +import { getAssetName } from '../../../modules/asset/utils' +import { AssetProvider } from '../../AssetProvider' +import { ConfirmInputValueModal } from '../../ConfirmInputValueModal' +import { formatDataToTable } from './utils' +import { Props } from './BidsTable.types' + +export const ROWS_PER_PAGE = 6 +const INITIAL_PAGE = 1 + +const BidsTable = (props: Props) => { + const { nft, address, isAcceptingBid, onAccept } = props + const isMobileOrTablet = useTabletAndBelowMediaQuery() + + const tabList = [ + { + value: 'offers_table', + displayValue: t('offers_table.offers') + } + ] + + const sortByList = [ + { + text: t('offers_table.most_expensive'), + value: BidSortBy.MOST_EXPENSIVE + }, + { + text: t('offers_table.recenty_offered'), + value: BidSortBy.RECENTLY_OFFERED + }, + { + text: t('offers_table.recently_updated'), + value: BidSortBy.RECENTLY_UPDATED + } + ] + + const [bids, setBids] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(INITIAL_PAGE) + const [totalPages, setTotalPages] = useState(0) + const [isLoading, setIsLoading] = useState(false) + const [sortBy, setSortBy] = useState(BidSortBy.MOST_EXPENSIVE) + const [showConfirmationModal, setShowConfirmationModal] = useState<{ + display: boolean + bid: Bid | null + }>({ + display: false, + bid: null + }) + + // We're doing this outside of redux to avoid having to store all orders when we only care about the first ROWS_PER_PAGE + useEffect(() => { + if (nft) { + setIsLoading(true) + bidAPI + .fetchByNFT( + nft.contractAddress, + nft.tokenId, + null, + sortBy, + ROWS_PER_PAGE.toString(), + ((page - 1) * ROWS_PER_PAGE).toString() + ) + .then(response => { + setTotal(response.total) + setBids( + formatDataToTable( + response.data.filter(bid => bid.bidder !== address), + bid => setShowConfirmationModal({ display: true, bid }), + address, + isMobileOrTablet + ) + ) + setTotalPages(Math.ceil(response.total / ROWS_PER_PAGE) | 0) + }) + .finally(() => setIsLoading(false)) + .catch(error => { + console.error(error) + }) + } + }, [nft, setIsLoading, setBids, page, sortBy, address, isMobileOrTablet]) + + return bids.length > 0 ? ( + <> + null} + total={total} + hasHeaders + /> + } + tabsList={tabList} + sortbyList={sortByList} + handleSortByChange={(value: string) => setSortBy(value as BidSortBy)} + sortBy={sortBy} + /> + {showConfirmationModal.bid && showConfirmationModal.display ? ( + + {nft => + nft && + showConfirmationModal.bid && ( + + {getAssetName(nft)}, + amount: ( + + {formatWeiMANA(showConfirmationModal.bid.price)} + + ) + }} + /> +
+ + + } + onConfirm={() => { + showConfirmationModal.bid && + onAccept(showConfirmationModal.bid) + }} + valueToConfirm={ethers.utils.formatEther( + showConfirmationModal.bid.price + )} + network={nft.network} + onCancel={() => + setShowConfirmationModal({ display: false, bid: null }) + } + loading={isAcceptingBid} + disabled={isAcceptingBid} + /> + ) + } +
+ ) : null} + + ) : null +} + +export default React.memo(BidsTable) diff --git a/webapp/src/components/AssetPage/BidsTable/BidsTable.types.ts b/webapp/src/components/AssetPage/BidsTable/BidsTable.types.ts new file mode 100644 index 000000000..c228b9a48 --- /dev/null +++ b/webapp/src/components/AssetPage/BidsTable/BidsTable.types.ts @@ -0,0 +1,17 @@ +import { Dispatch } from 'redux' +import { Bid } from '@dcl/schemas' +import { VendorName } from '../../../modules/vendor' +import { NFT } from '../../../modules/nft/types' +import { AcceptBidRequestAction } from '../../../modules/bid/actions' + +export type Props = { + nft: NFT | null + address?: string + onAccept: (bid: Bid) => void + isAcceptingBid: boolean +} + +export type MapStateProps = Pick + +export type MapDispatchProps = Pick +export type MapDispatch = Dispatch diff --git a/webapp/src/components/AssetPage/BidsTable/index.ts b/webapp/src/components/AssetPage/BidsTable/index.ts new file mode 100644 index 000000000..461d186de --- /dev/null +++ b/webapp/src/components/AssetPage/BidsTable/index.ts @@ -0,0 +1,2 @@ +import BidsTable from './BidsTable.container' +export { BidsTable } diff --git a/webapp/src/components/AssetPage/BidsTable/utils.tsx b/webapp/src/components/AssetPage/BidsTable/utils.tsx new file mode 100644 index 000000000..1753c957c --- /dev/null +++ b/webapp/src/components/AssetPage/BidsTable/utils.tsx @@ -0,0 +1,61 @@ +import { Bid } from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { Button, Mana } from 'decentraland-ui' +import { formatDistanceToNow, getDateAndMonthName } from '../../../lib/date' +import { formatWeiMANA } from '../../../lib/mana' +import { LinkedProfile } from '../../LinkedProfile' +import { ManaToFiat } from '../../ManaToFiat' +import { DataTableType } from '../../Table/TableContent/TableContent.types' +import styles from './BidsTable.module.css' + +export const formatDataToTable = ( + bids: Bid[], + setShowConfirmationModal: (bid: Bid) => void, + address?: string | null, + isMobile = false +): DataTableType[] => { + return bids.reduce((accumulator: DataTableType[], bid: Bid) => { + const value: DataTableType = { + [t('offers_table.from')]: ( + + ), + ...(!isMobile && { + [t('offers_table.published_date')]: getDateAndMonthName(bid.createdAt) + }), + ...(!isMobile && { + [t('offers_table.expiration_date')]: formatDistanceToNow( + +bid.expiresAt, + { + addSuffix: true + } + ) + }), + [t('listings_table.offer')]: ( +
+
+ + {formatWeiMANA(bid.price)} + {' '} +   + {'('} + + {')'} +
+ {address === bid.seller ? ( + + ) : null} +
+ ) + } + return [...accumulator, value] + }, []) +} diff --git a/webapp/src/components/AssetPage/BuyNFTBox/BuyNFTBox.container.ts b/webapp/src/components/AssetPage/BuyNFTBox/BuyNFTBox.container.ts new file mode 100644 index 000000000..245f2fccf --- /dev/null +++ b/webapp/src/components/AssetPage/BuyNFTBox/BuyNFTBox.container.ts @@ -0,0 +1,11 @@ +import { connect } from 'react-redux' +import { RootState } from '../../../modules/reducer' +import { getAddress } from '../../../modules/wallet/selectors' +import { MapStateProps } from './BuyNFTBox.types' +import YourOffer from './BuyNFTBox' + +const mapState = (state: RootState): MapStateProps => ({ + address: getAddress(state) +}) + +export default connect(mapState)(YourOffer) diff --git a/webapp/src/components/AssetPage/BuyNFTBox/BuyNFTBox.module.css b/webapp/src/components/AssetPage/BuyNFTBox/BuyNFTBox.module.css new file mode 100644 index 000000000..30b5bbda1 --- /dev/null +++ b/webapp/src/components/AssetPage/BuyNFTBox/BuyNFTBox.module.css @@ -0,0 +1,185 @@ +.BuyNFTBox { + border: 2px solid var(--secondary); + border-radius: 10px; + display: flex; + padding: 24px; + width: 100%; + background-color: var(--secondary); +} + +.AlignEnd { + margin-top: auto; +} + +.BuyNFTBox .containerColumn { + color: var(--primary-text); + display: flex; + flex-direction: column; + font-weight: 400; + font-size: 14px; + gap: 10px; +} + +.BuyNFTBox .fullWidth { + width: 100%; +} + +.BuyNFTBox .containerRow { + display: flex; + flex-direction: row; + gap: 10px; + align-items: flex-end; +} + +.BuyNFTBox .informationContainer { + display: flex; + margin: 10px 0px 0px 0px; + justify-content: space-between; + flex-grow: 1; + width: 100%; +} + +.BuyNFTBox .mintingContainer { + display: flex; + justify-content: space-between; + flex-grow: 1; + width: 100%; +} + +.BuyNFTBox .informationBold { + color: white; + font-size: 30px; + font-weight: 600; + margin: unset; +} + +.BuyNFTBox .stockText { + color: white; + font-size: 18px; + font-weight: 600; + display: flex; + align-items: flex-end; + margin-bottom: 7px; +} + +.BuyNFTBox .informationBold :global(.ui.header.large) { + font-size: 30px; + font-weight: 600; +} + +.BuyNFTBox .informationBold :global(.dcl.mana .matic) { + margin-bottom: 5px; + width: 22px; + height: 22px; +} + +.BuyNFTBox .informationText { + color: white; + font-size: 24px; + line-height: 1.7; +} + +.BuyNFTBox .informationListingText { + color: white; + font-size: 14px; + line-height: 1.5; +} + +.BuyNFTBox .centerItems { + align-items: center; + gap: 0px; +} + +.BuyNFTBox .issueNumber { + font-size: 18px; + font-weight: 600; + margin-top: 15px; +} + +.BuyNFTBox .informationTitle { + font-size: 14px; + color: var(--secondary-text); + font-weight: 600; + display: flex; + align-items: center; + line-height: 7px; +} + +.BuyNFTBox .informationTooltip { + width: 14px; + height: 14px; +} + +.BuyNFTBox .mintingIcon { + height: 19px; +} + +.BuyNFTBox :global(.ui.header.medium) { + font-size: 17px; +} + +.BuyNFTBox :global(.ui.button + .ui.button) { + margin-left: unset; +} + +.BuyNFTBox :global(.ui.loader.active) { + display: flex; + position: unset; + margin-top: 20px; +} + +.BuyNFTBox .emptyContainer { + display: flex; + width: 100%; + justify-content: center; + align-items: center; + height: 150px; +} +.BuyNFTBox .buyWithCardClassName { + background-color: white !important; + color: black !important; +} + +.BuyNFTBox .primaryButton { + background-color: var(--primary) !important; +} + +.BuyNFTBox .outlinedButton { + border: 1px solid var(--secondary-text) !important; +} + +.BuyNFTBox .expiresAt { + display: flex; + align-items: center; + font-size: 14px; +} + +.BuyNFTBox .listingMana :global(.ui.header.small) { + font-size: 14px; + display: flex; + align-items: center; +} + +.BuyNFTBox .columnListing { + color: var(--primary-text); + display: flex; + flex-direction: column; + font-weight: 400; + font-size: 14px; +} + +.BuyNFTBox .makeOfferButton { + align-items: center; + display: flex; + justify-content: center; +} + +@media (max-width: 768px) { + .BuyNFTBox { + padding: 24px 15px; + } + + .BuyNFTBox .informationContainer { + gap: 10px; + } +} diff --git a/webapp/src/components/AssetPage/BuyNFTBox/BuyNFTBox.tsx b/webapp/src/components/AssetPage/BuyNFTBox/BuyNFTBox.tsx new file mode 100644 index 000000000..d48212c4f --- /dev/null +++ b/webapp/src/components/AssetPage/BuyNFTBox/BuyNFTBox.tsx @@ -0,0 +1,188 @@ +import React, { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { Order, OrderFilters, OrderSortBy } from '@dcl/schemas' +import { Button, Loader } from 'decentraland-ui' +import Mana from '../../Mana/Mana' +import { formatDistanceToNow } from '../../../lib/date' +import clock from '../../../images/clock.png' +import makeOffer from '../../../images/makeOffer.png' +import { locations } from '../../../modules/routing/locations' +import { bidAPI, orderAPI } from '../../../modules/vendor/decentraland' +import { ManaToFiat } from '../../ManaToFiat' +import { formatWeiToAssetCard } from '../../AssetCard/utils' +import { BuyNFTButtons } from '../SaleActionBox/BuyNFTButtons' +import { Props } from './BuyNFTBox.types' +import styles from './BuyNFTBox.module.css' + +const FIRST = '1' + +const BuyNFTBox = ({ nft, address }: Props) => { + const [isLoading, setIsLoading] = useState(false) + const [listing, setListing] = useState<{ + order: Order + total: number + } | null>(null) + + const [canBid, setCanBid] = useState(false) + + useEffect(() => { + if (nft && nft?.owner !== address) { + bidAPI + .fetchByNFT( + nft.contractAddress, + nft.tokenId, + null, + undefined, + FIRST, + undefined, + address + ) + .then(response => { + if (response.total === 0) setCanBid(true) + }) + .catch(error => { + console.error(error) + }) + } + }, [nft, address]) + + useEffect(() => { + if (nft) { + setIsLoading(true) + + let params: OrderFilters = { + contractAddress: nft.contractAddress, + first: 1, + skip: 0, + tokenId: nft.tokenId + } + + orderAPI + .fetchOrders(params, OrderSortBy.CHEAPEST) + .then(response => { + if (response.data.length > 0) { + setListing({ order: response.data[0], total: response.total }) + } + }) + .finally(() => setIsLoading(false)) + .catch(error => { + console.error(error) + }) + } + }, [nft]) + + return ( +
+ {isLoading ? ( +
+ +
+ ) : nft && listing ? ( +
+
+
+ + {t('best_buying_option.minting.price').toUpperCase()} + +
+
+ + {formatWeiToAssetCard(listing.order.price)} + +
+ {+listing.order.price > 0 && ( +
+ {'('} + + {')'} +
+ )} +
+
+ +
+ + {t('best_buying_option.buy_listing.issue_number').toUpperCase()} + +
+ #{listing.order.issuedId} +
+
+
+ + {canBid && ( + + )} + + clock +   + {t('best_buying_option.buy_listing.expires')}  + {formatDistanceToNow(listing.order.expiresAt, { + addSuffix: true + })} + . + +
+ ) : ( + nft && ( +
+
+
+ + {t('best_buying_option.minting.price').toUpperCase()} + +
+ {t('best_buying_option.buy_listing.no_offer')} +
+
+
+ + {t('best_buying_option.buy_listing.make_offer').toUpperCase()} + +
+ #{nft.issuedId} +
+
+
+ +
+ ) + )} +
+ ) +} + +export default React.memo(BuyNFTBox) diff --git a/webapp/src/components/AssetPage/BuyNFTBox/BuyNFTBox.types.ts b/webapp/src/components/AssetPage/BuyNFTBox/BuyNFTBox.types.ts new file mode 100644 index 000000000..9f4eae589 --- /dev/null +++ b/webapp/src/components/AssetPage/BuyNFTBox/BuyNFTBox.types.ts @@ -0,0 +1,9 @@ +import { VendorName } from '../../../modules/vendor' +import { NFT } from '../../../modules/nft/types' + +export type Props = { + nft: NFT | null + address?: string +} + +export type MapStateProps = Pick diff --git a/webapp/src/components/AssetPage/BuyNFTBox/index.tsx b/webapp/src/components/AssetPage/BuyNFTBox/index.tsx new file mode 100644 index 000000000..309e4a898 --- /dev/null +++ b/webapp/src/components/AssetPage/BuyNFTBox/index.tsx @@ -0,0 +1,3 @@ +import BuyNFTBox from './BuyNFTBox.container' + +export { BuyNFTBox } diff --git a/webapp/src/components/AssetPage/Collection/Collection.module.css b/webapp/src/components/AssetPage/Collection/Collection.module.css index 5c233f166..b444f0b44 100644 --- a/webapp/src/components/AssetPage/Collection/Collection.module.css +++ b/webapp/src/components/AssetPage/Collection/Collection.module.css @@ -5,9 +5,9 @@ .name { margin-left: 16px; - font-size: 21px; + font-size: 14px; font-style: normal; - font-weight: 600; + font-weight: 500; line-height: 30px; letter-spacing: 0.3149999976158142px; text-align: left; @@ -17,6 +17,6 @@ .image { width: 48px; height: 48px; - border-radius: 5px; + border-radius: 30px; overflow: hidden; } diff --git a/webapp/src/components/AssetPage/Description/Description.css b/webapp/src/components/AssetPage/Description/Description.css deleted file mode 100644 index af5f92363..000000000 --- a/webapp/src/components/AssetPage/Description/Description.css +++ /dev/null @@ -1,5 +0,0 @@ -.Description .description-text { - font-size: 17px; - line-height: 26px; - letter-spacing: 0.2px; -} diff --git a/webapp/src/components/AssetPage/Description/Description.module.css b/webapp/src/components/AssetPage/Description/Description.module.css new file mode 100644 index 000000000..5a8dd6e7a --- /dev/null +++ b/webapp/src/components/AssetPage/Description/Description.module.css @@ -0,0 +1,21 @@ +.Description .descriptionText { + font-size: 14px; + line-height: 26px; + letter-spacing: 0.2px; +} + +.Description .descriptionContained { + font-size: 14px; + line-height: 26px; + letter-spacing: 0.2px; +} + +.Description .readMore { + padding: 0px !important; + color: var(--primary); + cursor: pointer; +} + +.Description :global(.ui.sub.header) { + font-weight: 600; +} diff --git a/webapp/src/components/AssetPage/Description/Description.tsx b/webapp/src/components/AssetPage/Description/Description.tsx index 8602a544c..ca7549ea4 100644 --- a/webapp/src/components/AssetPage/Description/Description.tsx +++ b/webapp/src/components/AssetPage/Description/Description.tsx @@ -1,16 +1,39 @@ -import React from 'react' +import React, { useState } from 'react' import { Header } from 'decentraland-ui' import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { Props } from './Description.types' -import './Description.css' +import styles from './Description.module.css' + +const MAX_LENGTH = 81 const Description = (props: Props) => { - return props.text ? ( -
+ const { text } = props + const hasMoreLines = text?.length && text.length > MAX_LENGTH + const [showMore, setShowMore] = useState(false) + + return ( +
{t('asset_page.description')}
-
{props.text}
+
+ {text ? text : t('asset_page.no_description')} +
+ {hasMoreLines ? ( + setShowMore(prevState => !prevState)} + className={styles.readMore} + > + {t('asset_page.read_more').toLocaleUpperCase()} + + ) : null}
- ) : null + ) } export default React.memo(Description) diff --git a/webapp/src/components/AssetPage/EmoteDetail/EmoteDetail.module.css b/webapp/src/components/AssetPage/EmoteDetail/EmoteDetail.module.css index 732797692..54fd9de77 100644 --- a/webapp/src/components/AssetPage/EmoteDetail/EmoteDetail.module.css +++ b/webapp/src/components/AssetPage/EmoteDetail/EmoteDetail.module.css @@ -1,3 +1,43 @@ +.EmoteDetail { + display: flex; + flex-direction: column; +} + +.EmoteDetail .actionsContainer { + width: 521px; +} + +.EmoteDetail .assetImageContainer { + height: 602px; +} + +.EmoteDetail .assetImageContainer :global(.AssetImage) { + border-radius: 12px; + overflow: hidden; +} + +.EmoteDetail .badges { + margin-top: 8px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.EmoteDetail .wearableInformation { + display: flex; + flex-direction: column; + flex: 1; + justify-content: space-between; +} + +.EmoteDetail .wearableInformationContainer { + display: flex; + flex-direction: row; + gap: 30px; + width: 100%; + margin-top: 25px; +} + .issued { color: var(--secondary-text); } diff --git a/webapp/src/components/AssetPage/EmoteDetail/EmoteDetail.tsx b/webapp/src/components/AssetPage/EmoteDetail/EmoteDetail.tsx index 0e475f6f3..b901c65bb 100644 --- a/webapp/src/components/AssetPage/EmoteDetail/EmoteDetail.tsx +++ b/webapp/src/components/AssetPage/EmoteDetail/EmoteDetail.tsx @@ -1,25 +1,61 @@ -import React, { useMemo } from 'react' +import React, { useMemo, useState } from 'react' import { t } from 'decentraland-dapps/dist/modules/translation/utils' -import { EmotePlayMode, NFTCategory } from '@dcl/schemas' +import { EmotePlayMode, NFTCategory, OrderSortBy } from '@dcl/schemas' import { AssetType } from '../../../modules/asset/types' import { Section } from '../../../modules/vendor/decentraland' import { locations } from '../../../modules/routing/locations' import { AssetImage } from '../../AssetImage' import CampaignBadge from '../../Campaign/CampaignBadge' +import TableContainer from '../../Table/TableContainer' import RarityBadge from '../../RarityBadge' -import BaseDetail from '../BaseDetail' -import { BidList } from '../BidList' +import { BidsTable } from '../BidsTable' +import { YourOffer } from '../YourOffer' import Collection from '../Collection' import { Description } from '../Description' import IconBadge from '../IconBadge' import { Owner } from '../Owner' -import { SaleActionBox } from '../SaleActionBox' +import { ListingsTable } from '../ListingsTable' import { TransactionHistory } from '../TransactionHistory' +import OnBack from '../OnBack' +import Title from '../Title' +import { BuyNFTBox } from '../BuyNFTBox' import { Props } from './EmoteDetail.types' +import styles from './EmoteDetail.module.css' const EmoteDetail = ({ nft }: Props) => { const emote = nft.data.emote! const loop = nft.data.emote!.loop + const [sortBy, setSortBy] = useState(OrderSortBy.CHEAPEST) + + const tabList = [ + { + value: 'other_available_listings', + displayValue: t('listings_table.other_available_listings') + } + ] + + const listingSortByOptions = [ + { + text: t('listings_table.cheapest'), + value: OrderSortBy.CHEAPEST + }, + { + text: t('listings_table.newest'), + value: OrderSortBy.RECENTLY_LISTED + }, + { + text: t('listings_table.oldest'), + value: OrderSortBy.OLDEST + }, + { + text: t('listings_table.issue_number_asc'), + value: OrderSortBy.ISSUED_ID_ASC + }, + { + text: t('listings_table.issue_number_desc'), + value: OrderSortBy.ISSUED_ID_DESC + } + ] const emoteBadgeHref = useMemo( () => @@ -32,44 +68,50 @@ const EmoteDetail = ({ nft }: Props) => { ) return ( - } - isOnSale={!!nft.activeOrderId} - badges={ - <> - - - - - } - left={ - <> +
+ +
+ +
+
+
+
+ + <div className={styles.badges}> + <RarityBadge + rarity={emote.rarity} + assetType={AssetType.NFT} + category={NFTCategory.EMOTE} + /> + <IconBadge + icon={loop ? 'play-loop' : 'play-once'} + text={t(`emote.play_mode.${loop ? 'loop' : 'simple'}`)} + href={emoteBadgeHref} + /> + <CampaignBadge contract={nft.contractAddress} /> + </div> + </div> <Description text={emote.description} /> - <div className="BaseDetail row"> + <div> <Owner asset={nft} /> <Collection asset={nft} /> </div> - </> - } - box={null} - showDetails - actions={<SaleActionBox asset={nft} />} - below={ - <> - <BidList nft={nft} /> - <TransactionHistory asset={nft} /> - </> - } - /> + </div> + <div className={styles.actionsContainer}> + <BuyNFTBox nft={nft} /> + </div> + </div> + <YourOffer nft={nft} /> + <BidsTable nft={nft} /> + <TransactionHistory asset={nft} /> + <TableContainer + tabsList={tabList} + handleSortByChange={(value: string) => setSortBy(value as OrderSortBy)} + sortbyList={listingSortByOptions} + sortBy={sortBy} + children={<ListingsTable asset={nft} sortBy={sortBy as OrderSortBy} />} + /> + </div> ) } diff --git a/webapp/src/components/AssetPage/IconBadge/IconBadge.css b/webapp/src/components/AssetPage/IconBadge/IconBadge.css index 1e193ef6f..bb7cbf4e0 100644 --- a/webapp/src/components/AssetPage/IconBadge/IconBadge.css +++ b/webapp/src/components/AssetPage/IconBadge/IconBadge.css @@ -2,19 +2,19 @@ display: inline-flex; align-items: center; text-transform: uppercase; - padding: 4px 12px; + padding: 2px 8px; border-radius: 5px; cursor: pointer; background-color: #37333d; } .IconBadge .text { - font-size: 15px; + font-size: 13px; } .IconBadge .icon { - width: 15px; - height: 15px; + width: 13px; + height: 13px; margin-right: 10px; background-position: center; background-repeat: no-repeat; diff --git a/webapp/src/components/AssetPage/ItemDetail/ItemDetail.module.css b/webapp/src/components/AssetPage/ItemDetail/ItemDetail.module.css index c2d6c0c65..2d531b7a6 100644 --- a/webapp/src/components/AssetPage/ItemDetail/ItemDetail.module.css +++ b/webapp/src/components/AssetPage/ItemDetail/ItemDetail.module.css @@ -1,9 +1,107 @@ -.supply { - color: var(--secondary-text); +.ItemDetail { + display: flex; + flex-direction: column; } -.ownerButtons { +.ItemDetail .assetImageContainer :global(.AssetImage) { + border-radius: 12px; + overflow: hidden; +} + +.ItemDetail .assetImageContainer { + width: 52.5%; + height: 602px; +} + +.ItemDetail .badges { + margin-top: 8px; display: flex; - flex-direction: column; gap: 8px; + flex-wrap: wrap; +} + +.ItemDetail .itemDetailBottomContainer { + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; +} + +.ItemDetail .spaceInMint { + margin-top: 29px; +} + +.ItemDetail .information { + display: flex; + flex-direction: column; + width: 45%; + gap: 15px; +} + +.ItemDetail .informationContainer { + display: flex; + flex-direction: row; + gap: 30px; +} + +.ItemDetail .basicRow { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; +} + +.ItemDetail :global(.ui.button + .ui.button) { + margin-left: 0px; +} + +@media (max-width: 800px) { + .ItemDetail .information { + width: 100%; + } + + .ItemDetail .informationContainer { + flex-wrap: wrap; + } + .ItemDetail .assetImageContainer { + width: 100%; + } + .ItemDetail .basicRow { + flex-direction: column; + } + + .ItemDetail :global(.dcl.stats + .dcl.stats) { + width: 100%; + } +} + +@media (max-width: 768px) { + .ItemDetail .assetImageContainer { + height: 400px; + } + + .ItemDetail :global(.dcl.tab.active) { + border-left: none; + border-bottom: 2px solid var(--primary); + margin-bottom: 0; + } + + .ItemDetail :global(.dcl.tab) { + margin-bottom: 0; + } + + .ItemDetail .basicRow :global(.dcl.stats) { + width: 100%; + } + + .ItemDetail :global(.dcl.tabs) { + justify-content: space-evenly; + } + .ItemDetail :global(.dcl.tabs.fullscreen) { + border-bottom: 1px solid var(--divider); + margin-bottom: 0; + } + .ItemDetail .spaceInMint { + gap: 0px; + } } diff --git a/webapp/src/components/AssetPage/ItemDetail/ItemDetail.tsx b/webapp/src/components/AssetPage/ItemDetail/ItemDetail.tsx index 9edf7d89a..158d8a53e 100644 --- a/webapp/src/components/AssetPage/ItemDetail/ItemDetail.tsx +++ b/webapp/src/components/AssetPage/ItemDetail/ItemDetail.tsx @@ -1,6 +1,6 @@ -import React, { useMemo } from 'react' -import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import React, { useMemo, useRef } from 'react' import { BodyShape, EmotePlayMode, NFTCategory } from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { locations } from '../../../modules/routing/locations' import { Section } from '../../../modules/vendor/decentraland' import RarityBadge from '../../RarityBadge' @@ -13,11 +13,14 @@ import SmartBadge from '../SmartBadge' import { Description } from '../Description' import { Owner } from '../Owner' import Collection from '../Collection' -import BaseDetail from '../BaseDetail' import IconBadge from '../IconBadge' import { TransactionHistory } from '../TransactionHistory' -import { SaleActionBox } from '../SaleActionBox' +import ListingsTableContainer from '../ListingsTableContainer/ListingsTableContainer' +import { BestBuyingOption } from '../BestBuyingOption' +import Title from '../Title' +import OnBack from '../OnBack' import { Props } from './ItemDetail.types' +import styles from './ItemDetail.module.css' const ItemDetail = ({ item }: Props) => { let description = '' @@ -25,6 +28,8 @@ const ItemDetail = ({ item }: Props) => { let category let loop = false + const tableRef = useRef<HTMLDivElement>(null) + switch (item.category) { case NFTCategory.WEARABLE: description = item.data.wearable!.description @@ -50,67 +55,79 @@ const ItemDetail = ({ item }: Props) => { ) return ( - <BaseDetail - asset={item} - assetImage={<AssetImage asset={item} isDraggable />} - isOnSale={item.isOnSale} - badges={ - <> - <RarityBadge - rarity={item.rarity} - assetType={AssetType.ITEM} - category={NFTCategory.WEARABLE} - /> - {category && ( - <CategoryBadge - category={ - item.data.emote - ? item.data.emote.category - : item.data.wearable!.category - } - assetType={AssetType.ITEM} - /> - )} - {item.category === NFTCategory.EMOTE && ( - <IconBadge - icon={loop ? 'play-loop' : 'play-once'} - text={t(`emote.play_mode.${loop ? 'loop' : 'simple'}`)} - href={emoteBadgeHref} - /> - )} - {bodyShapes.length > 0 && !item.data.emote && ( - <GenderBadge - bodyShapes={bodyShapes} - assetType={AssetType.ITEM} - section={ - item.category === NFTCategory.WEARABLE - ? Section.WEARABLES - : Section.EMOTES - } - /> - )} - {item.category === NFTCategory.WEARABLE && - item.data.wearable!.isSmart && ( - <SmartBadge assetType={AssetType.ITEM} /> - )} + <div className={styles.ItemDetail}> + <OnBack asset={item} /> + <div className={styles.informationContainer}> + <div className={styles.assetImageContainer}> + <AssetImage asset={item} isDraggable /> + </div> + <div className={styles.information}> + <div> + <Title asset={item} /> + <div className={styles.badges}> + <RarityBadge + rarity={item.rarity} + assetType={AssetType.ITEM} + category={NFTCategory.WEARABLE} + size="small" + /> + {category && ( + <CategoryBadge + category={ + item.data.emote + ? item.data.emote.category + : item.data.wearable!.category + } + assetType={AssetType.ITEM} + /> + )} + {item.category === NFTCategory.EMOTE && ( + <IconBadge + icon={loop ? 'play-loop' : 'play-once'} + text={t(`emote.play_mode.${loop ? 'loop' : 'simple'}`)} + href={emoteBadgeHref} + /> + )} + {bodyShapes.length > 0 && !item.data.emote && ( + <GenderBadge + bodyShapes={bodyShapes} + assetType={AssetType.ITEM} + section={ + item.category === NFTCategory.WEARABLE + ? Section.WEARABLES + : Section.EMOTES + } + /> + )} + {item.category === NFTCategory.WEARABLE && + item.data.wearable!.isSmart && ( + <SmartBadge assetType={AssetType.ITEM} /> + )} + + <CampaignBadge contract={item.contractAddress} /> + </div> + </div> - <CampaignBadge contract={item.contractAddress} /> - </> - } - left={ - <> <Description text={description} /> - <div className="BaseDetail row"> - <Owner asset={item} /> - <Collection asset={item} /> + <div + className={ + item.available > 0 && item.isOnSale + ? `${styles.itemDetailBottomContainer} ${styles.spaceInMint}` + : styles.itemDetailBottomContainer + } + > + <div className={styles.basicRow}> + <Owner asset={item} /> + <Collection asset={item} /> + </div> + <BestBuyingOption asset={item} tableRef={tableRef} /> </div> - </> - } - box={null} - showDetails - actions={<SaleActionBox asset={item} />} - below={<TransactionHistory asset={item} />} - /> + </div> + </div> + + <ListingsTableContainer item={item} ref={tableRef} /> + <TransactionHistory asset={item} /> + </div> ) } diff --git a/webapp/src/components/AssetPage/ListingsTable/ListingsTable.module.css b/webapp/src/components/AssetPage/ListingsTable/ListingsTable.module.css new file mode 100644 index 000000000..bdaaba6c5 --- /dev/null +++ b/webapp/src/components/AssetPage/ListingsTable/ListingsTable.module.css @@ -0,0 +1,101 @@ +.ListingsTable { + position: relative; +} + +.linkedProfileRow { + color: var(--primary) !important; +} + +.ListingsTable .headerMargin { + padding: 20px !important; +} + +.issuedIdContainer { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + font-size: 14px; +} + +.ListingsTable .issuedId { + font-size: 15px; + font-weight: 700; +} + +.row { + display: flex; + flex-direction: row; + gap: 10px; + justify-content: space-between; + align-items: center; +} + +.badgeContainer :global(.dcl.badge) { + color: white; + background-color: transparent !important; +} + +.badgeContainer { + display: flex; + flex-direction: row; + gap: 10px; +} + +.ListingsTable .goToNFT { + color: white; + padding-right: 25px; +} + +.emptyTable { + width: 100%; + height: 350px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + font-size: 20px; +} + +.manaField { + display: flex; + align-items: center; + font-size: 14px; +} + +.manaField :global(.ui.header:last-child) { + font-size: 14px; +} + +.viewListingContainer { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-right: 10px; +} + +@media (max-width: 768px) { + .linkedProfileRow { + margin-left: unset; + } + :global(.ui.very.basic.table tr th), + :global(.ui.very.basic.table tr td) { + width: 50% !important; /* 2 columns, needs to be overriden with the !important since the library uses it too */ + } + :global(.ui.very.basic.table tr) { + border: 1px solid var(--divider); + } + :global(.ui.very.basic.table tr):first-of-type { + border: none; + } + :global(.ui.very.basic.table td) { + border-right: 1px solid var(--divider) !important; /* needs to be overriden with the !important since the library uses it too */ + } + .viewListingContainer { + margin-right: 0; + } + .manaField { + flex-wrap: wrap; + } +} diff --git a/webapp/src/components/AssetPage/ListingsTable/ListingsTable.spec.tsx b/webapp/src/components/AssetPage/ListingsTable/ListingsTable.spec.tsx new file mode 100644 index 000000000..4764a4f17 --- /dev/null +++ b/webapp/src/components/AssetPage/ListingsTable/ListingsTable.spec.tsx @@ -0,0 +1,168 @@ +import { waitFor } from '@testing-library/react' +import { + ChainId, + Item, + ListingStatus, + Network, + NFTCategory, + Order, + Rarity +} from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { formatDistanceToNow, getDateAndMonthName } from '../../../lib/date' +import { formatWeiMANA } from '../../../lib/mana' +import { OwnersResponse } from '../../../modules/vendor/decentraland' +import * as nftAPI from '../../../modules/vendor/decentraland/nft/api' +import * as orderAPI from '../../../modules/vendor/decentraland/order/api' +import { renderWithProviders } from '../../../utils/tests' +import ListingsTable from './ListingsTable' + +jest.mock('../../../modules/vendor/decentraland/nft/api') +jest.mock('../../../modules/vendor/decentraland/order/api') +jest.mock('decentraland-dapps/dist/containers', () => { + const module = jest.requireActual('decentraland-dapps/dist/containers') + return { + ...module, + Profile: () => <div></div> + } +}) + +describe('Listings Table', () => { + let asset: Item = { + contractAddress: '0xaddress', + itemId: '1', + id: '1', + name: 'asset name', + thumbnail: '', + url: '', + category: NFTCategory.WEARABLE, + rarity: Rarity.UNIQUE, + price: '10', + available: 2, + isOnSale: false, + creator: '0xcreator', + beneficiary: null, + createdAt: 1671033414000, + updatedAt: 1671033414000, + reviewedAt: 1671033414000, + soldAt: 1671033414000, + data: { + parcel: undefined, + estate: undefined, + wearable: undefined, + ens: undefined, + emote: undefined + }, + network: Network.MATIC, + chainId: ChainId.ETHEREUM_GOERLI, + firstListedAt: null + } + + let ownersResponse: OwnersResponse = { + issuedId: 1, + ownerId: '0x92712b730b9a474f99a47bb8b1750190d5959a2b', + orderStatus: 'open', + orderExpiresAt: '1671033414000', + tokenId: '1' + } + + let orderResponse: Order = { + id: '1', + marketplaceAddress: '0xmarketplace', + contractAddress: '0xaddress', + tokenId: '1', + owner: '0x92712b730b9a474f99a47bb8b1750190d5959a2b', + buyer: null, + price: '10', + status: ListingStatus.OPEN, + expiresAt: 1671033414000, + createdAt: 1671033414000, + updatedAt: 0, + network: Network.MATIC, + chainId: ChainId.ETHEREUM_GOERLI, + issuedId: '1' + } + + afterEach(() => { + jest.resetAllMocks() + }) + + describe('Empty table', () => { + beforeEach(() => { + ;(nftAPI.nftAPI.getOwners as jest.Mock).mockResolvedValueOnce({ + data: [], + total: 0 + }) + ;(orderAPI.orderAPI.fetchOrders as jest.Mock).mockResolvedValueOnce({ + data: [], + total: 0 + }) + }) + + it('should render the empty table message', async () => { + const { getByText, findByTestId } = renderWithProviders( + <ListingsTable asset={asset} /> + ) + + const loader = await findByTestId('loader') + + await waitFor(() => { + expect(loader).not.toBeInTheDocument() + }) + + expect( + getByText(t('listings_table.there_are_no_listings')) + ).toBeInTheDocument() + }) + }) + + describe('Should render the table correctly', () => { + beforeEach(() => { + ;(nftAPI.nftAPI.getOwners as jest.Mock).mockResolvedValueOnce({ + data: [ownersResponse], + total: 1 + }) + ;(orderAPI.orderAPI.fetchOrders as jest.Mock).mockResolvedValueOnce({ + data: [orderResponse], + total: 1 + }) + }) + + it('should render the table', async () => { + const screen = renderWithProviders(<ListingsTable asset={asset} />) + + const { findByTestId, getByTestId } = screen + + const loader = await findByTestId('loader') + + await waitFor(() => { + expect(loader).not.toBeInTheDocument() + }) + + expect(getByTestId('table-content')).not.toBe(null) + }) + + it('should render the table data correctly', async () => { + const screen = renderWithProviders(<ListingsTable asset={asset} />) + + const { findByTestId, getByText } = screen + + const loader = await findByTestId('loader') + + await waitFor(() => { + expect(loader).not.toBeInTheDocument() + }) + + const created = getDateAndMonthName(orderResponse.createdAt) + const expires = formatDistanceToNow(+orderResponse.expiresAt, { + addSuffix: true + }) + const price = formatWeiMANA(orderResponse.price) + + expect(getByText(orderResponse.issuedId)).not.toBe(null) + expect(getByText(created)).not.toBe(null) + expect(getByText(expires)).not.toBe(null) + expect(getByText(price)).not.toBe(null) + }) + }) +}) diff --git a/webapp/src/components/AssetPage/ListingsTable/ListingsTable.tsx b/webapp/src/components/AssetPage/ListingsTable/ListingsTable.tsx new file mode 100644 index 000000000..60a00e0be --- /dev/null +++ b/webapp/src/components/AssetPage/ListingsTable/ListingsTable.tsx @@ -0,0 +1,87 @@ +import React, { useState, useEffect } from 'react' +import { useTabletAndBelowMediaQuery } from 'decentraland-ui' +import { ListingStatus, Network } from '@dcl/schemas' +import { OrderFilters, OrderSortBy } from '@dcl/schemas/dist/dapps/order' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { isNFT } from '../../../modules/asset/utils' +import { orderAPI } from '../../../modules/vendor/decentraland' +import noListings from '../../../images/noListings.png' +import { TableContent } from '../../Table/TableContent' +import { DataTableType } from '../../Table/TableContent/TableContent.types' +import { formatDataToTable } from './utils' +import { Props } from './ListingsTable.types' +import styles from './ListingsTable.module.css' + +export const ROWS_PER_PAGE = 6 +const INITIAL_PAGE = 1 + +const ListingsTable = (props: Props) => { + const { asset, sortBy = OrderSortBy.CHEAPEST } = props + const isMobileOrTablet = useTabletAndBelowMediaQuery() + + const [orders, setOrders] = useState<DataTableType[]>([]) + const [total, setTotal] = useState<number | null>(null) + const [page, setPage] = useState(INITIAL_PAGE) + const [totalPages, setTotalPages] = useState<number | null>(null) + const [isLoading, setIsLoading] = useState(false) + + // We're doing this outside of redux to avoid having to store all orders when we only care about the first ROWS_PER_PAGE + useEffect(() => { + if (asset) { + setIsLoading(true) + + let params: OrderFilters = { + contractAddress: asset.contractAddress, + first: ROWS_PER_PAGE, + skip: (page - 1) * ROWS_PER_PAGE, + status: ListingStatus.OPEN + } + + if (asset.network === Network.MATIC && asset.itemId) { + params.itemId = asset.itemId + } else if (asset.network === Network.ETHEREUM) { + params.nftName = asset.name + } + + orderAPI + .fetchOrders(params, sortBy) + .then(response => { + setTotalPages(Math.ceil(response.total / ROWS_PER_PAGE) || 0) + setOrders( + formatDataToTable( + isNFT(asset) + ? response.data.filter(order => order.tokenId !== asset.tokenId) + : response.data, + isMobileOrTablet + ) + ) + setTotal(response.total) + }) + .finally(() => setIsLoading(false)) + .catch(error => { + console.error(error) + }) + } + }, [asset, setIsLoading, setOrders, sortBy, page, isMobileOrTablet]) + + return ( + <TableContent + data={orders} + isLoading={isLoading} + setPage={setPage} + totalPages={totalPages} + empty={() => ( + <div className={styles.emptyTable}> + <img src={noListings} alt="empty" /> + <span>{t('listings_table.there_are_no_listings')}</span> + </div> + )} + total={total} + rowsPerPage={ROWS_PER_PAGE} + activePage={page} + hasHeaders + /> + ) +} + +export default React.memo(ListingsTable) diff --git a/webapp/src/components/AssetPage/ListingsTable/ListingsTable.types.ts b/webapp/src/components/AssetPage/ListingsTable/ListingsTable.types.ts new file mode 100644 index 000000000..cefd430ed --- /dev/null +++ b/webapp/src/components/AssetPage/ListingsTable/ListingsTable.types.ts @@ -0,0 +1,7 @@ +import { OrderSortBy } from '@dcl/schemas' +import { Asset } from '../../../modules/asset/types' + +export type Props = { + asset: Asset | null + sortBy?: OrderSortBy +} diff --git a/webapp/src/components/AssetPage/ListingsTable/index.ts b/webapp/src/components/AssetPage/ListingsTable/index.ts new file mode 100644 index 000000000..b8ef50ad2 --- /dev/null +++ b/webapp/src/components/AssetPage/ListingsTable/index.ts @@ -0,0 +1,3 @@ +import ListingsTable from './ListingsTable' + +export { ListingsTable } diff --git a/webapp/src/components/AssetPage/ListingsTable/utils.tsx b/webapp/src/components/AssetPage/ListingsTable/utils.tsx new file mode 100644 index 000000000..c3c32b3fe --- /dev/null +++ b/webapp/src/components/AssetPage/ListingsTable/utils.tsx @@ -0,0 +1,89 @@ +import { Link } from 'react-router-dom' +import { ListingStatus, Order } from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { Button, Icon, Mana } from 'decentraland-ui' +import { formatDistanceToNow, getDateAndMonthName } from '../../../lib/date' +import { formatWeiMANA } from '../../../lib/mana' +import { locations } from '../../../modules/routing/locations' +import { LinkedProfile } from '../../LinkedProfile' +import ListedBadge from '../../ListedBadge' +import { ManaToFiat } from '../../ManaToFiat' +import { DataTableType } from '../../Table/TableContent/TableContent.types' +import styles from './ListingsTable.module.css' + +export const formatDataToTable = ( + orders: Order[], + isMobile = false +): DataTableType[] => { + return orders.reduce((accumulator: DataTableType[], order: Order) => { + const value: DataTableType = { + [t('listings_table.owner')]: ( + <LinkedProfile + className={styles.linkedProfileRow} + address={order.owner} + /> + ), + ...(!isMobile && { + [t('listings_table.published_date')]: getDateAndMonthName( + order.createdAt + ) + }), + ...(!isMobile && { + [t('listings_table.expiration_date')]: formatDistanceToNow( + +order.expiresAt, + { + addSuffix: true + } + ) + }), + ...(!isMobile && { + [t('listings_table.issue_number')]: ( + <div className={styles.issuedIdContainer}> + <div className={styles.badgeContainer}> + {order.status === ListingStatus.OPEN && + order.expiresAt && + Number(order.expiresAt) >= Date.now() ? ( + <ListedBadge className={styles.badge} /> + ) : null} + <div className={styles.row}> + <span> + #<span className={styles.issuedId}>{order.issuedId}</span> + </span> + </div> + </div> + </div> + ) + }), + [t('listings_table.price')]: ( + <div className={styles.viewListingContainer}> + <div className={styles.manaField}> + <Mana className="manaField" network={order.network}> + {formatWeiMANA(order.price)} + </Mana>{' '} +   + {'('} + <ManaToFiat mana={order.price} /> + {')'} + </div> + {order && ( + <div> + {isMobile ? ( + <Icon name="chevron right" className={styles.gotToNFT} /> + ) : ( + <Button + inverted + as={Link} + to={locations.nft(order.contractAddress, order.tokenId)} + size="small" + > + {t('listings_table.view_listing')} + </Button> + )} + </div> + )} + </div> + ) + } + return [...accumulator, value] + }, []) +} diff --git a/webapp/src/components/AssetPage/ListingsTableContainer/ListingsTableContainer.module.css b/webapp/src/components/AssetPage/ListingsTableContainer/ListingsTableContainer.module.css new file mode 100644 index 000000000..0f9115b1a --- /dev/null +++ b/webapp/src/components/AssetPage/ListingsTableContainer/ListingsTableContainer.module.css @@ -0,0 +1,69 @@ +.supply { + color: var(--secondary-text); +} + +.ownerButtons { + display: flex; + flex-direction: column; + gap: 8px; +} + +.filtertabsContainer { + display: flex; + border-top-right-radius: 10px; + border-top-left-radius: 10px; + align-items: center; + border-bottom: 1px solid var(--secondary); + padding-left: 20px; +} + +.tableContainer { + display: flex; + flex-direction: column; + border: 2px solid var(--secondary); + border-radius: 12px; + margin-top: 48px; +} + +.sortByDropdown { + border-radius: 12px; + margin-right: 20px; + justify-content: space-between; +} + +.filtertabsContainer :global(.ui.dropdown) { + display: flex; + align-items: center; + font-size: 13px; + position: absolute; + right: 20px; +} + +.ui.dropdown > .icon { + color: white !important; + height: 100%; + display: flex; + align-items: center; +} + +@media (max-width: 768px) { + :global(.ui.button.basic) { + text-align: left; + } + + .filtertabsContainer { + flex-direction: column; + padding: unset; + } + + .tabStyle { + padding-left: unset; + } + + .filtertabsContainer :global(.ui.dropdown) { + position: relative; + left: 20px; + align-self: flex-start; + margin-bottom: 10px; + } +} diff --git a/webapp/src/components/AssetPage/ListingsTableContainer/ListingsTableContainer.tsx b/webapp/src/components/AssetPage/ListingsTableContainer/ListingsTableContainer.tsx new file mode 100644 index 000000000..c353ee2ba --- /dev/null +++ b/webapp/src/components/AssetPage/ListingsTableContainer/ListingsTableContainer.tsx @@ -0,0 +1,110 @@ +import React, { forwardRef, useCallback, useEffect, useState } from 'react' +import { useLocation } from 'react-router-dom' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { OrderSortBy } from '@dcl/schemas' +import { OrderDirection } from '../OwnersTable/OwnersTable.types' +import { OwnersTable } from '../OwnersTable' +import { ListingsTable } from '../ListingsTable' +import TableContainer from '../../Table/TableContainer' +import { Props, SortByType } from './ListingsTableContainer.types' + +const ListingsTableContainer = forwardRef<HTMLDivElement, Props>( + (props, ref) => { + const { item } = props + + const BelowTabs = { + LISTINGS: { + value: 'listings', + displayValue: t('listings_table.listings') + }, + OWNERS: { + value: 'owners', + displayValue: t('owners_table.owners') + } + } + + const locations = useLocation() + const [belowTab, setBelowTab] = useState(BelowTabs.LISTINGS.value) + const [sortBy, setSortBy] = useState<SortByType>(OrderSortBy.CHEAPEST) + + const ownerSortByOptions = [ + { + text: t('owners_table.issue_number_asc'), + value: OrderDirection.ASC + }, + { + text: t('owners_table.issue_number_desc'), + value: OrderDirection.DESC + } + ] + + const listingSortByOptions = [ + { + text: t('listings_table.cheapest'), + value: OrderSortBy.CHEAPEST + }, + { + text: t('listings_table.newest'), + value: OrderSortBy.RECENTLY_LISTED + }, + { + text: t('listings_table.oldest'), + value: OrderSortBy.OLDEST + }, + { + text: t('listings_table.issue_number_asc'), + value: OrderSortBy.ISSUED_ID_ASC + }, + { + text: t('listings_table.issue_number_desc'), + value: OrderSortBy.ISSUED_ID_DESC + } + ] + + const handleTabChange = useCallback( + (tab: string) => { + const sortByTab = + tab === BelowTabs.LISTINGS.value + ? OrderSortBy.CHEAPEST + : OrderDirection.ASC + setBelowTab(tab) + setSortBy(sortByTab) + }, + [BelowTabs.LISTINGS] + ) + + useEffect(() => { + const params = new URLSearchParams(locations.search) + if (params.get('selectedTableTab') === BelowTabs.OWNERS.value) + handleTabChange(BelowTabs.OWNERS.value) + }, [BelowTabs.OWNERS, handleTabChange, locations.search]) + + return ( + <TableContainer + children={ + belowTab === BelowTabs.LISTINGS.value ? ( + <ListingsTable asset={item} sortBy={sortBy as OrderSortBy} /> + ) : ( + <OwnersTable + asset={item} + orderDirection={sortBy as OrderDirection} + /> + ) + } + ref={ref} + tabsList={[BelowTabs.LISTINGS, BelowTabs.OWNERS]} + activeTab={belowTab} + handleTabChange={(tab: string) => handleTabChange(tab)} + sortbyList={ + belowTab === BelowTabs.LISTINGS.value + ? listingSortByOptions + : ownerSortByOptions + } + handleSortByChange={(value: string) => setSortBy(value as SortByType)} + sortBy={sortBy} + /> + ) + } +) + +export default React.memo(ListingsTableContainer) diff --git a/webapp/src/components/AssetPage/ListingsTableContainer/ListingsTableContainer.types.ts b/webapp/src/components/AssetPage/ListingsTableContainer/ListingsTableContainer.types.ts new file mode 100644 index 000000000..58075ad25 --- /dev/null +++ b/webapp/src/components/AssetPage/ListingsTableContainer/ListingsTableContainer.types.ts @@ -0,0 +1,10 @@ +import { RefObject } from 'react' +import { Item, OrderSortBy } from '@dcl/schemas' +import { OrderDirection } from '../OwnersTable/OwnersTable.types' + +export type Props = { + item: Item + ref: RefObject<HTMLDivElement> +} + +export type SortByType = OrderSortBy | OrderDirection diff --git a/webapp/src/components/AssetPage/BaseDetail/BaseDetail.container.ts b/webapp/src/components/AssetPage/OnBack/OnBack.container.ts similarity index 75% rename from webapp/src/components/AssetPage/BaseDetail/BaseDetail.container.ts rename to webapp/src/components/AssetPage/OnBack/OnBack.container.ts index 53ecc09b5..12dcb3e01 100644 --- a/webapp/src/components/AssetPage/BaseDetail/BaseDetail.container.ts +++ b/webapp/src/components/AssetPage/OnBack/OnBack.container.ts @@ -3,8 +3,8 @@ import { Dispatch } from 'redux' import { getIsFavoritesEnabled } from '../../../modules/features/selectors' import { RootState } from '../../../modules/reducer' import { goBack } from '../../../modules/routing/actions' -import { MapStateProps, MapDispatchProps } from './BaseDetail.types' -import BaseDetail from './BaseDetail' +import { MapStateProps, MapDispatchProps } from './OnBack.types' +import OnBack from './OnBack' const mapState = (state: RootState): MapStateProps => ({ isFavoritesEnabled: getIsFavoritesEnabled(state) @@ -14,4 +14,4 @@ const mapDispatch = (dispatch: Dispatch): MapDispatchProps => ({ onBack: (location?: string) => dispatch(goBack(location)) }) -export default connect(mapState, mapDispatch)(BaseDetail) +export default connect(mapState, mapDispatch)(OnBack) diff --git a/webapp/src/components/AssetPage/OnBack/OnBack.css b/webapp/src/components/AssetPage/OnBack/OnBack.css new file mode 100644 index 000000000..73ef8a103 --- /dev/null +++ b/webapp/src/components/AssetPage/OnBack/OnBack.css @@ -0,0 +1,21 @@ +.top-header .favorites { + flex-grow: 1; + justify-content: flex-end; +} + +.top-header { + display: flex; + align-items: center; + margin-bottom: 10px; +} + +.top-header .dcl.back.absolute { + position: inherit; +} + +.top-header .ui.button.basic { + color: white !important; + align-items: center; + display: flex; + gap: 10px; +} diff --git a/webapp/src/components/AssetPage/BaseDetail/BaseDetail.spec.tsx b/webapp/src/components/AssetPage/OnBack/OnBack.spec.tsx similarity index 80% rename from webapp/src/components/AssetPage/BaseDetail/BaseDetail.spec.tsx rename to webapp/src/components/AssetPage/OnBack/OnBack.spec.tsx index d8281a9cd..73362f2d6 100644 --- a/webapp/src/components/AssetPage/BaseDetail/BaseDetail.spec.tsx +++ b/webapp/src/components/AssetPage/OnBack/OnBack.spec.tsx @@ -2,24 +2,19 @@ import { useMobileMediaQuery } from 'decentraland-ui/dist/components/Media' import { Asset } from '../../../modules/asset/types' import { INITIAL_STATE } from '../../../modules/favorites/reducer' import { renderWithProviders } from '../../../utils/test' -import BaseDetail from './BaseDetail' -import { Props as BaseDetailProps } from './BaseDetail.types' +import { Props as OnBackProps } from './OnBack.types' +import OnBack from './OnBack' jest.mock('decentraland-ui/dist/components/Media') const FAVORITES_COUNTER_TEST_ID = 'favorites-counter' -function renderBaseDetail(props: Partial<BaseDetailProps> = {}) { +function renderOnBack(props: Partial<OnBackProps> = {}) { return renderWithProviders( - <BaseDetail - asset={{} as Asset} - assetImage={undefined} - isFavoritesEnabled={false} - badges={undefined} - left={undefined} - box={undefined} - isOnSale - {...props} + <OnBack + asset={props.asset || ({} as Asset)} + isFavoritesEnabled={props.isFavoritesEnabled || false} + onBack={jest.fn()} />, { preloadedState: { @@ -37,7 +32,7 @@ function renderBaseDetail(props: Partial<BaseDetailProps> = {}) { ) } -describe('BaseDetail', () => { +describe('OnBack', () => { let asset: Asset let useMobileMediaQueryMock: jest.MockedFunction<typeof useMobileMediaQuery> @@ -54,7 +49,7 @@ describe('BaseDetail', () => { }) it('should not render the favorites counter', () => { - const { queryByTestId } = renderBaseDetail({ + const { queryByTestId } = renderOnBack({ asset, isFavoritesEnabled: false }) @@ -69,7 +64,7 @@ describe('BaseDetail', () => { describe('and the favorites feature flag is not enabled', () => { it('should not render the favorites counter', () => { - const { queryByTestId } = renderBaseDetail({ + const { queryByTestId } = renderOnBack({ asset, isFavoritesEnabled: false }) @@ -84,7 +79,7 @@ describe('BaseDetail', () => { }) it('should not render the favorites counter', () => { - const { queryByTestId } = renderBaseDetail({ + const { queryByTestId } = renderOnBack({ asset, isFavoritesEnabled: true }) @@ -95,10 +90,11 @@ describe('BaseDetail', () => { describe('and the asset is an item', () => { beforeEach(() => { asset = { ...asset, itemId: 'itemId' } as Asset + useMobileMediaQueryMock.mockReturnValue(true) }) it('should render the favorites counter', () => { - const { getByTestId } = renderBaseDetail({ + const { getByTestId } = renderOnBack({ asset, isFavoritesEnabled: true }) diff --git a/webapp/src/components/AssetPage/OnBack/OnBack.tsx b/webapp/src/components/AssetPage/OnBack/OnBack.tsx new file mode 100644 index 000000000..c8cb4c09d --- /dev/null +++ b/webapp/src/components/AssetPage/OnBack/OnBack.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import { Button } from 'decentraland-ui' +import { useMobileMediaQuery } from 'decentraland-ui/dist/components/Media' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import onBackIcon from '../../../images/onBack.png' +import { isNFT, mapAsset } from '../../../modules/asset/utils' +import { AssetType } from '../../../modules/asset/types' +import { locations } from '../../../modules/routing/locations' +import { Sections } from '../../../modules/routing/types' +import { FavoritesCounter } from '../../FavoritesCounter' +import { Props } from './OnBack.types' +import './OnBack.css' + +const OnBack = ({ asset, isFavoritesEnabled, onBack }: Props) => { + const isMobile = useMobileMediaQuery() + + return ( + <div className="top-header"> + <Button + className="back" + onClick={() => + onBack( + mapAsset( + asset, + { + wearable: () => + locations.browse({ + assetType: AssetType.ITEM, + section: Sections.decentraland.WEARABLES + }), + emote: () => + locations.browse({ + assetType: AssetType.ITEM, + section: Sections.decentraland.EMOTES + }) + }, + { + ens: () => + locations.browse({ + assetType: AssetType.NFT, + section: Sections.decentraland.ENS + }), + estate: () => + locations.lands({ + assetType: AssetType.NFT, + section: Sections.decentraland.ESTATES, + isMap: false, + isFullscreen: false + }), + parcel: () => + locations.lands({ + assetType: AssetType.NFT, + section: Sections.decentraland.PARCELS, + isMap: false, + isFullscreen: false + }), + wearable: () => + locations.browse({ + assetType: AssetType.NFT, + section: Sections.decentraland.WEARABLES + }), + emote: () => + locations.browse({ + assetType: AssetType.NFT, + section: Sections.decentraland.EMOTES + }) + }, + () => undefined + ) + ) + } + basic + > + <img src={onBackIcon} alt={t('global.back')} /> + {t('global.back')} + </Button> + {isFavoritesEnabled && isMobile && !isNFT(asset) ? ( + <FavoritesCounter isCollapsed className="favorites" item={asset} /> + ) : null} + </div> + ) +} + +export default React.memo(OnBack) diff --git a/webapp/src/components/AssetPage/OnBack/OnBack.types.tsx b/webapp/src/components/AssetPage/OnBack/OnBack.types.tsx new file mode 100644 index 000000000..0e934025c --- /dev/null +++ b/webapp/src/components/AssetPage/OnBack/OnBack.types.tsx @@ -0,0 +1,10 @@ +import { Asset } from '../../../modules/asset/types' + +export type Props = { + asset: Asset + isFavoritesEnabled: boolean + onBack: (location?: string) => void +} + +export type MapStateProps = Pick<Props, 'isFavoritesEnabled'> +export type MapDispatchProps = Pick<Props, 'onBack'> diff --git a/webapp/src/components/AssetPage/OnBack/index.tsx b/webapp/src/components/AssetPage/OnBack/index.tsx new file mode 100644 index 000000000..ab036f9b9 --- /dev/null +++ b/webapp/src/components/AssetPage/OnBack/index.tsx @@ -0,0 +1,3 @@ +import OnBack from './OnBack.container' + +export default OnBack diff --git a/webapp/src/components/AssetPage/Owner/Owner.css b/webapp/src/components/AssetPage/Owner/Owner.css index 36841e7ff..be846e347 100644 --- a/webapp/src/components/AssetPage/Owner/Owner.css +++ b/webapp/src/components/AssetPage/Owner/Owner.css @@ -5,11 +5,15 @@ .Owner .Profile .name { margin-left: 10px; - font-size: 21px; + font-size: 14px; font-style: normal; - font-weight: 600; + font-weight: 500; line-height: 30px; letter-spacing: 0.3149999976158142px; text-align: left; color: var(--text); } + +.Owner .Profile.inline :global(.dcl.blockie) { + border-radius: 30px; +} diff --git a/webapp/src/components/AssetPage/OwnersTable/OwnersTable.module.css b/webapp/src/components/AssetPage/OwnersTable/OwnersTable.module.css new file mode 100644 index 000000000..4a6bf5b13 --- /dev/null +++ b/webapp/src/components/AssetPage/OwnersTable/OwnersTable.module.css @@ -0,0 +1,58 @@ +.headerMargin { + padding: 20px !important; +} + +.linkedProfileRow { + color: var(--primary) !important; +} + +.issuedIdContainer { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + font-size: 14px; +} + +.row { + display: flex; + flex-direction: row; + gap: 10px; + align-items: center; +} + +.badge { + color: white; + background-color: transparent !important; +} + +.gotToNFT { + color: white; + padding-right: 25px; +} + +.empty { + height: 86px; +} + +.emptyTable { + width: 100%; + height: 350px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + font-size: 20px; +} + +.OwnersTable .emptyTableActionButton :global(.ui.button.basic) { + text-decoration: underline; + color: white !important; +} + +@media (max-width: 768px) { + .linkedProfileRow { + margin-left: unset; + } +} diff --git a/webapp/src/components/AssetPage/OwnersTable/OwnersTable.spec.tsx b/webapp/src/components/AssetPage/OwnersTable/OwnersTable.spec.tsx new file mode 100644 index 000000000..adf1dcdec --- /dev/null +++ b/webapp/src/components/AssetPage/OwnersTable/OwnersTable.spec.tsx @@ -0,0 +1,124 @@ +import { ChainId, Item, Network, NFTCategory, Rarity } from '@dcl/schemas' +import { waitFor } from '@testing-library/react' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { OwnersResponse } from '../../../modules/vendor/decentraland' +import * as nftAPI from '../../../modules/vendor/decentraland/nft/api' +import { renderWithProviders } from '../../../utils/tests' +import OwnersTable from './OwnersTable' + +const ownerIdMock = '0x92712b730b9a474f99a47bb8b1750190d5959a2b' + +jest.mock('../../../modules/vendor/decentraland/nft/api') +jest.mock('decentraland-dapps/dist/containers', () => { + const module = jest.requireActual('decentraland-dapps/dist/containers') + return { + ...module, + Profile: () => <div>{ownerIdMock}</div> + } +}) + +describe('Owners Table', () => { + let asset: Item = { + contractAddress: '0xaddress', + itemId: '1', + id: '1', + name: 'asset name', + thumbnail: '', + url: '', + category: NFTCategory.WEARABLE, + rarity: Rarity.UNIQUE, + price: '10', + available: 2, + isOnSale: false, + creator: '0xcreator', + beneficiary: null, + createdAt: 1671033414000, + updatedAt: 1671033414000, + reviewedAt: 1671033414000, + soldAt: 1671033414000, + data: { + parcel: undefined, + estate: undefined, + wearable: undefined, + ens: undefined, + emote: undefined + }, + network: Network.MATIC, + chainId: ChainId.ETHEREUM_GOERLI, + firstListedAt: null + } + let ownersResponse: OwnersResponse = { + issuedId: 1, + ownerId: ownerIdMock, + orderStatus: 'open', + orderExpiresAt: '1671033414000', + tokenId: '1' + } + + afterEach(() => { + jest.resetAllMocks() + }) + + describe('Empty table', () => { + beforeEach(() => { + ;(nftAPI.nftAPI.getOwners as jest.Mock).mockResolvedValueOnce({ + data: [], + total: 0 + }) + }) + + it('should render the empty table message', async () => { + const { getByText, findByTestId } = renderWithProviders( + <OwnersTable asset={asset} /> + ) + + const loader = await findByTestId('loader') + + await waitFor(() => { + expect(loader).not.toBeInTheDocument() + }) + + expect( + getByText(t('owners_table.there_are_no_owners')) + ).toBeInTheDocument() + }) + }) + + describe('Should render the table correctly', () => { + beforeEach(() => { + ;(nftAPI.nftAPI.getOwners as jest.Mock).mockResolvedValueOnce({ + data: [ownersResponse], + total: 1 + }) + }) + + it('should render the table', async () => { + const screen = renderWithProviders(<OwnersTable asset={asset} />) + + const { findByTestId, getByTestId } = screen + + const loader = await findByTestId('loader') + + await waitFor(() => { + expect(loader).not.toBeInTheDocument() + }) + + expect(getByTestId('table-content')).not.toBe(null) + }) + + it('should render the table data correctly', async () => { + const screen = renderWithProviders(<OwnersTable asset={asset} />) + + const { findByTestId, getByText } = screen + + const loader = await findByTestId('loader') + + await waitFor(() => { + expect(loader).not.toBeInTheDocument() + }) + + expect(getByText(ownersResponse.ownerId)).not.toBe(null) + expect(getByText(ownersResponse.issuedId)).not.toBe(null) + }) + }) +}) diff --git a/webapp/src/components/AssetPage/OwnersTable/OwnersTable.tsx b/webapp/src/components/AssetPage/OwnersTable/OwnersTable.tsx new file mode 100644 index 000000000..1046dafe1 --- /dev/null +++ b/webapp/src/components/AssetPage/OwnersTable/OwnersTable.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useState } from 'react' +import { Button } from 'decentraland-ui' +import { + nftAPI, + OwnersFilters, + OwnersSortBy +} from '../../../modules/vendor/decentraland' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import emptyOwners from '../../../images/emptyOwners.png' +import { TableContent } from '../../Table/TableContent' +import { DataTableType } from '../../Table/TableContent/TableContent.types' +import { formatDataToTable } from './utils' +import { OrderDirection, Props } from './OwnersTable.types' +import styles from './OwnersTable.module.css' + +export const ROWS_PER_PAGE = 6 +const INITIAL_PAGE = 1 + +const OwnersTable = (props: Props) => { + const { asset, orderDirection = OrderDirection.ASC } = props + + const [owners, setOwners] = useState<DataTableType[]>([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(INITIAL_PAGE) + const [totalPages, setTotalPages] = useState<number>(0) + const [isLoading, setIsLoading] = useState(false) + + // We're doing this outside of redux to avoid having to store all orders when we only care about the first ROWS_PER_PAGE + useEffect(() => { + if (asset && asset.itemId) { + setIsLoading(true) + let params: OwnersFilters = { + contractAddress: asset.contractAddress, + itemId: asset.itemId, + first: ROWS_PER_PAGE, + skip: (page - 1) * ROWS_PER_PAGE, + sortBy: OwnersSortBy.ISSUED_ID, + orderDirection + } + nftAPI + .getOwners(params) + .then(response => { + setTotal(response.total) + setOwners(formatDataToTable(response.data, asset)) + setTotalPages(Math.ceil(response.total / ROWS_PER_PAGE) | 0) + }) + .finally(() => setIsLoading(false)) + .catch(error => { + console.error(error) + }) + } + }, [asset, setIsLoading, setOwners, page, orderDirection]) + + return ( + <TableContent + data={owners} + activePage={page} + isLoading={isLoading} + setPage={setPage} + totalPages={totalPages} + empty={() => ( + <div className={styles.emptyTable}> + <img src={emptyOwners} alt="empty" className={styles.empty} /> + <span> + {t('owners_table.there_are_no_owners')} + <Button + basic + onClick={() => { + window.scrollTo({ top: 0, behavior: 'smooth' }) + }} + className={styles.emptyTableActionButton} + > + {t('owners_table.become_the_first_one')} + </Button> + </span> + </div> + )} + total={total} + hasHeaders + /> + ) +} + +export default React.memo(OwnersTable) diff --git a/webapp/src/components/AssetPage/OwnersTable/OwnersTable.types.ts b/webapp/src/components/AssetPage/OwnersTable/OwnersTable.types.ts new file mode 100644 index 000000000..5ebe70036 --- /dev/null +++ b/webapp/src/components/AssetPage/OwnersTable/OwnersTable.types.ts @@ -0,0 +1,14 @@ +import { Asset } from '../../../modules/asset/types' + +export type Props = { + asset: Asset | null + orderDirection?: OrderDirection +} + +export type MapStateProps = {} +export type MapDispatchProps = {} + +export enum OrderDirection { + ASC = 'asc', + DESC = 'desc' +} diff --git a/webapp/src/components/AssetPage/OwnersTable/index.ts b/webapp/src/components/AssetPage/OwnersTable/index.ts new file mode 100644 index 000000000..9e2b76f61 --- /dev/null +++ b/webapp/src/components/AssetPage/OwnersTable/index.ts @@ -0,0 +1,2 @@ +import OwnersTable from './OwnersTable' +export { OwnersTable } diff --git a/webapp/src/components/AssetPage/OwnersTable/utils.tsx b/webapp/src/components/AssetPage/OwnersTable/utils.tsx new file mode 100644 index 000000000..d15697c63 --- /dev/null +++ b/webapp/src/components/AssetPage/OwnersTable/utils.tsx @@ -0,0 +1,49 @@ +import { ListingStatus } from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { Icon } from 'decentraland-ui' +import { Link } from 'react-router-dom' +import { Asset } from '../../../modules/asset/types' +import { locations } from '../../../modules/routing/locations' +import { OwnersResponse } from '../../../modules/vendor/decentraland' +import { LinkedProfile } from '../../LinkedProfile' +import ListedBadge from '../../ListedBadge' +import { DataTableType } from '../../Table/TableContent/TableContent.types' +import styles from './OwnersTable.module.css' + +export const formatDataToTable = ( + owners: OwnersResponse[], + asset: Asset +): DataTableType[] => { + return owners?.map((owner: OwnersResponse) => { + const value: DataTableType = { + [t('owners_table.owner')]: ( + <LinkedProfile + className={styles.linkedProfileRow} + address={owner.ownerId} + /> + ), + [t('owners_table.issue_number')]: ( + <div className={styles.issuedIdContainer}> + <div className={styles.row}> + {owner.orderStatus === ListingStatus.OPEN && + owner.orderExpiresAt && + Number(owner.orderExpiresAt) >= Date.now() ? ( + <ListedBadge className={styles.badge} /> + ) : null} + #<span className={styles.issuedId}>{owner.issuedId}</span> + </div> + {!!owner && ( + <div> + {asset?.contractAddress && owner.tokenId && ( + <Link to={locations.nft(asset.contractAddress, owner.tokenId)}> + <Icon name="chevron right" className={styles.gotToNFT} /> + </Link> + )} + </div> + )} + </div> + ) + } + return value + }) +} diff --git a/webapp/src/components/AssetPage/RentalHistory/RentalHistory.tsx b/webapp/src/components/AssetPage/RentalHistory/RentalHistory.tsx index 9b50062ee..4a99f633e 100644 --- a/webapp/src/components/AssetPage/RentalHistory/RentalHistory.tsx +++ b/webapp/src/components/AssetPage/RentalHistory/RentalHistory.tsx @@ -1,49 +1,28 @@ import React, { useState, useEffect } from 'react' -import { RentalListing, RentalStatus } from '@dcl/schemas' -import { - Header, - Table, - Mobile, - NotMobile, - Pagination, - Loader, - Row -} from 'decentraland-ui' -import { T, t } from 'decentraland-dapps/dist/modules/translation/utils' -import dateFnsFormat from 'date-fns/format' - -import { capitalize } from '../../../lib/text' +import { RentalStatus } from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { rentalsAPI } from '../../../modules/vendor/decentraland/rentals/api' -import { formatDistanceToNow } from '../../../lib/date' -import { formatWeiMANA } from '../../../lib/mana' -import { getRentalChosenPeriod } from '../../../modules/rental/utils' -import { Mana } from '../../Mana' -import { LinkedProfile } from '../../LinkedProfile' +import { DataTableType } from '../../Table/TableContent/TableContent.types' + +import TableContainer from '../../Table/TableContainer' +import { TableContent } from '../../Table/TableContent' +import { formatDataToTable } from './utils' import { Props } from './RentalHistory.types' -import styles from './RentalHistory.module.css' -const INPUT_FORMAT = 'PPP' -const WEEK_IN_MILLISECONDS = 7 * 24 * 60 * 60 * 1000 const ROWS_PER_PAGE = 12 -const formatEventDate = (updatedAt: number) => { - const newUpdatedAt = new Date(updatedAt) - return Date.now() - newUpdatedAt.getTime() > WEEK_IN_MILLISECONDS - ? dateFnsFormat(newUpdatedAt, INPUT_FORMAT) - : formatDistanceToNow(newUpdatedAt, { addSuffix: true }) -} - -const formatDateTitle = (updatedAt: number) => { - return new Date(updatedAt).toLocaleString() -} - const RentalHistory = (props: Props) => { const { asset } = props - const [rentals, setRentals] = useState([] as Array<RentalListing>) + const tabList = [ + { value: 'rental_history', displayValue: t('rental_history.title') } + ] + + const [rentals, setRentals] = useState<DataTableType[]>([]) const [page, setPage] = useState(1) const [totalPages, setTotalPages] = useState(1) const [isLoading, setIsLoading] = useState(false) + const [total, setTotal] = useState(0) // We're doing this outside of redux to avoid having to store all orders when we only care about the last open one useEffect(() => { @@ -57,7 +36,8 @@ const RentalHistory = (props: Props) => { page: (page - 1) * ROWS_PER_PAGE }) .then(response => { - setRentals(response.results) + setTotal(response.total) + setRentals(formatDataToTable(response.results)) setTotalPages(Math.ceil(response.total / ROWS_PER_PAGE) | 0) }) .finally(() => setIsLoading(false)) @@ -67,105 +47,23 @@ const RentalHistory = (props: Props) => { } }, [asset, setIsLoading, setRentals, page]) - const network = asset ? asset.network : undefined - - return ( - <div className={styles.main}> - {isLoading && rentals.length === 0 ? null : rentals.length > 0 ? ( - <> - <Header sub>{t('rental_history.title')}</Header> - <NotMobile> - <Table basic="very"> - <Table.Header> - <Table.Row> - <Table.HeaderCell> - {t('rental_history.lessor')} - </Table.HeaderCell> - <Table.HeaderCell> - {t('rental_history.tenant')} - </Table.HeaderCell> - <Table.HeaderCell> - {t('rental_history.started_at')} - </Table.HeaderCell> - <Table.HeaderCell> - {capitalize(t('global.days'))} - </Table.HeaderCell> - <Table.HeaderCell> - {t('rental_history.price_per_day')} - </Table.HeaderCell> - </Table.Row> - </Table.Header> - <Table.Body className={isLoading ? 'is-loading' : ''}> - {rentals.map(rental => ( - <Table.Row key={rental.id}> - <Table.Cell> - <LinkedProfile address={rental.lessor!} /> - </Table.Cell> - <Table.Cell> - <LinkedProfile address={rental.tenant!} /> - </Table.Cell> - <Table.Cell title={formatDateTitle(rental.startedAt!)}> - {formatEventDate(rental.startedAt ?? 0)} - </Table.Cell> - <Table.Cell> - {t('rental_history.selected_days', { - days: rental.rentedDays ?? 0 - })} - </Table.Cell> - <Table.Cell> - <Mana showTooltip network={network} inline> - {formatWeiMANA( - getRentalChosenPeriod(rental).pricePerDay - )} - </Mana> - </Table.Cell> - </Table.Row> - ))} - {isLoading ? <Loader active /> : null} - </Table.Body> - </Table> - </NotMobile> - <Mobile> - <div className={styles.mobileRentalsHistory}> - {rentals.map(rental => ( - <div className={styles.mobileRentalsHistoryRow} key={rental.id}> - <div> - <T - id="rental_history.mobile_price" - values={{ - days: rental.rentedDays, - pricePerDay: ( - <Mana showTooltip network={network} inline> - {formatWeiMANA( - getRentalChosenPeriod(rental).pricePerDay - )} - </Mana> - ) - }} - ></T> - </div> - <div className={styles.mobileRentalsHistoryRowStartedAt}> - {formatEventDate(rental.startedAt!)} - </div> - </div> - ))} - </div> - </Mobile> - {totalPages > 1 ? ( - <Row center> - <Pagination - activePage={page} - totalPages={totalPages} - onPageChange={(_event, props) => setPage(+props.activePage!)} - firstItem={null} - lastItem={null} - /> - </Row> - ) : null} - </> - ) : null} - </div> - ) + return rentals.length > 0 ? ( + <TableContainer + tabsList={tabList} + children={ + <TableContent + data={rentals} + activePage={page} + isLoading={isLoading} + setPage={setPage} + totalPages={totalPages} + empty={() => null} + total={total} + rowsPerPage={ROWS_PER_PAGE} + /> + } + /> + ) : null } export default React.memo(RentalHistory) diff --git a/webapp/src/components/AssetPage/RentalHistory/utils.tsx b/webapp/src/components/AssetPage/RentalHistory/utils.tsx new file mode 100644 index 000000000..06c99ff5b --- /dev/null +++ b/webapp/src/components/AssetPage/RentalHistory/utils.tsx @@ -0,0 +1,41 @@ +import { capitalize } from 'lodash' +import dateFnsFormat from 'date-fns/format' +import { RentalListing } from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { Mana } from 'decentraland-ui' +import { getRentalChosenPeriod } from '../../../modules/rental/utils' +import { formatDistanceToNow } from '../../../lib/date' +import { formatWeiMANA } from '../../../lib/mana' +import { LinkedProfile } from '../../LinkedProfile' +import { DataTableType } from '../../Table/TableContent/TableContent.types' + +const INPUT_FORMAT = 'PPP' +const WEEK_IN_MILLISECONDS = 7 * 24 * 60 * 60 * 1000 + +const formatEventDate = (updatedAt: number) => { + const newUpdatedAt = new Date(updatedAt) + return Date.now() - newUpdatedAt.getTime() > WEEK_IN_MILLISECONDS + ? dateFnsFormat(newUpdatedAt, INPUT_FORMAT) + : formatDistanceToNow(newUpdatedAt, { addSuffix: true }) +} + +export const formatDataToTable = ( + rentals: RentalListing[] +): DataTableType[] => { + return rentals?.map((rental: RentalListing) => { + const value: DataTableType = { + [t('rental_history.lessor')]: <LinkedProfile address={rental.lessor!} />, + [t('rental_history.tenant')]: <LinkedProfile address={rental.tenant!} />, + [t('rental_history.started_at')]: formatEventDate(rental.startedAt ?? 0), + [capitalize(t('global.days'))]: t('rental_history.selected_days', { + days: rental.rentedDays ?? 0 + }), + [t('rental_history.price_per_day')]: ( + <Mana network={rental.network} inline> + {formatWeiMANA(getRentalChosenPeriod(rental).pricePerDay)} + </Mana> + ) + } + return value + }) +} diff --git a/webapp/src/components/AssetPage/SaleActionBox/BuyNFTButtons/BuyNFTButtons.tsx b/webapp/src/components/AssetPage/SaleActionBox/BuyNFTButtons/BuyNFTButtons.tsx index 237034d1f..eb7cb34bf 100644 --- a/webapp/src/components/AssetPage/SaleActionBox/BuyNFTButtons/BuyNFTButtons.tsx +++ b/webapp/src/components/AssetPage/SaleActionBox/BuyNFTButtons/BuyNFTButtons.tsx @@ -11,7 +11,7 @@ import * as events from '../../../../utils/events' import styles from './BuyNFTButtons.module.css' import { Props } from './BuyNFTButtons.types' -const BuyNFTButtons = ({ asset }: Props) => { +const BuyNFTButtons = ({ asset, buyWithCardClassName }: Props) => { const { contractAddress, network } = asset const assetType = isNFT(asset) ? AssetType.NFT : AssetType.ITEM const assetId = isNFT(asset) ? asset.tokenId : asset.itemId @@ -36,7 +36,7 @@ const BuyNFTButtons = ({ asset }: Props) => { <Button as={Link} - className={styles.buy_with_card} + className={`${styles.buy_with_card} ${buyWithCardClassName}`} to={locations.buyWithCard(assetType, contractAddress, assetId)} onClick={handleBuyWithCard} fluid diff --git a/webapp/src/components/AssetPage/SaleActionBox/BuyNFTButtons/BuyNFTButtons.types.ts b/webapp/src/components/AssetPage/SaleActionBox/BuyNFTButtons/BuyNFTButtons.types.ts index aaa61624c..51cd37247 100644 --- a/webapp/src/components/AssetPage/SaleActionBox/BuyNFTButtons/BuyNFTButtons.types.ts +++ b/webapp/src/components/AssetPage/SaleActionBox/BuyNFTButtons/BuyNFTButtons.types.ts @@ -2,6 +2,7 @@ import { Asset } from '../../../../modules/asset/types' export type Props = { asset: Asset + buyWithCardClassName?: string } export type OwnProps = Pick<Props, 'asset'> diff --git a/webapp/src/components/AssetPage/SaleActionBox/ItemSaleActions/ItemSaleActions.container.ts b/webapp/src/components/AssetPage/SaleActionBox/ItemSaleActions/ItemSaleActions.container.ts index 3bb87fb3e..48f1e5134 100644 --- a/webapp/src/components/AssetPage/SaleActionBox/ItemSaleActions/ItemSaleActions.container.ts +++ b/webapp/src/components/AssetPage/SaleActionBox/ItemSaleActions/ItemSaleActions.container.ts @@ -2,10 +2,14 @@ import { connect } from 'react-redux' import { RootState } from '../../../../modules/reducer' import { getWallet } from '../../../../modules/wallet/selectors' import { MapStateProps } from './ItemSaleActions.types' -import SaleRentActionBox from './ItemSaleActions' +import ItemSaleActions from './ItemSaleActions' -const mapState = (state: RootState): MapStateProps => ({ - wallet: getWallet(state) -}) +const mapState = (state: RootState): MapStateProps => { + const wallet = getWallet(state) -export default connect(mapState)(SaleRentActionBox) + return { + wallet + } +} + +export default connect(mapState)(ItemSaleActions) diff --git a/webapp/src/components/AssetPage/SaleActionBox/ItemSaleActions/ItemSaleActions.tsx b/webapp/src/components/AssetPage/SaleActionBox/ItemSaleActions/ItemSaleActions.tsx index 8c50255ea..11d1c1126 100644 --- a/webapp/src/components/AssetPage/SaleActionBox/ItemSaleActions/ItemSaleActions.tsx +++ b/webapp/src/components/AssetPage/SaleActionBox/ItemSaleActions/ItemSaleActions.tsx @@ -1,13 +1,13 @@ import { memo } from 'react' import { Button } from 'decentraland-ui' import { t } from 'decentraland-dapps/dist/modules/translation/utils' - import { getBuilderCollectionDetailUrl } from '../../../../modules/collection/utils' +import { BuyNFTButtons } from '../BuyNFTButtons' + import styles from './ItemSaleActions.module.css' import { Props } from './ItemSaleActions.types' -import { BuyNFTButtons } from '../BuyNFTButtons' -const NFTSaleActions = ({ item, wallet }: Props) => { +const ItemSaleActions = ({ item, wallet, customClassnames }: Props) => { const isOwner = wallet?.address === item.creator const canBuy = !isOwner && item.isOnSale && item.available > 0 const builderCollectionUrl = getBuilderCollectionDetailUrl( @@ -18,21 +18,41 @@ const NFTSaleActions = ({ item, wallet }: Props) => { <> {isOwner ? ( <div className={styles.ownerButtons}> - <Button as="a" href={builderCollectionUrl} fluid> + <Button + as="a" + href={builderCollectionUrl} + fluid + className={customClassnames?.primaryButton} + > {t('asset_page.actions.edit_price')} </Button> - <Button as="a" href={builderCollectionUrl} fluid> + <Button + as="a" + href={builderCollectionUrl} + fluid + className={customClassnames?.secondaryButton} + > {t('asset_page.actions.change_beneficiary')} </Button> - <Button as="a" href={builderCollectionUrl} fluid> + <Button + as="a" + href={builderCollectionUrl} + fluid + className={customClassnames?.outlinedButton} + > {t('asset_page.actions.mint_item')} </Button> </div> ) : ( - canBuy && <BuyNFTButtons asset={item} /> + canBuy && ( + <BuyNFTButtons + asset={item} + buyWithCardClassName={customClassnames?.buyWithCardClassName} + /> + ) )} </> ) } -export default memo(NFTSaleActions) +export default memo(ItemSaleActions) diff --git a/webapp/src/components/AssetPage/SaleActionBox/ItemSaleActions/ItemSaleActions.types.ts b/webapp/src/components/AssetPage/SaleActionBox/ItemSaleActions/ItemSaleActions.types.ts index b36ba01c8..01b0abdd7 100644 --- a/webapp/src/components/AssetPage/SaleActionBox/ItemSaleActions/ItemSaleActions.types.ts +++ b/webapp/src/components/AssetPage/SaleActionBox/ItemSaleActions/ItemSaleActions.types.ts @@ -3,8 +3,9 @@ import { Wallet } from 'decentraland-dapps/dist/modules/wallet/types' export type Props = { item: Item - wallet: Wallet | null + wallet?: Wallet | null + customClassnames?: { [key: string]: string } | undefined } -export type OwnProps = Pick<Props, 'item'> +export type OwnProps = Pick<Props, 'item' | 'customClassnames'> export type MapStateProps = Pick<Props, 'wallet'> diff --git a/webapp/src/components/AssetPage/Title/Title.module.css b/webapp/src/components/AssetPage/Title/Title.module.css index 0c31b49e4..c1d02534f 100644 --- a/webapp/src/components/AssetPage/Title/Title.module.css +++ b/webapp/src/components/AssetPage/Title/Title.module.css @@ -1,5 +1,5 @@ .title { - font-size: 34px; + font-size: 28px; font-style: normal; font-weight: 600; line-height: 42px; diff --git a/webapp/src/components/AssetPage/Title/Title.tsx b/webapp/src/components/AssetPage/Title/Title.tsx index 835b58daf..21c77d335 100644 --- a/webapp/src/components/AssetPage/Title/Title.tsx +++ b/webapp/src/components/AssetPage/Title/Title.tsx @@ -10,7 +10,9 @@ const Title = ({ asset, isFavoritesEnabled }: Props) => { return ( <div className={styles.title}> - <span className={styles.text}>{getAssetName(asset)}</span> + <span className={styles.text}> + {getAssetName(asset)} {isNFT(asset) ? `#${asset.issuedId}` : ''}{' '} + </span> {/* TODO (lists): this may be moved after the new detail page for unified markets */} {isFavoritesEnabled && !isMobile && !isNFT(asset) ? ( <FavoritesCounter diff --git a/webapp/src/components/AssetPage/TransactionHistory/TransactionHistory.css b/webapp/src/components/AssetPage/TransactionHistory/TransactionHistory.css index ebd9ab5fc..997be3578 100644 --- a/webapp/src/components/AssetPage/TransactionHistory/TransactionHistory.css +++ b/webapp/src/components/AssetPage/TransactionHistory/TransactionHistory.css @@ -22,3 +22,35 @@ .TransactionHistory tbody.is-loading tr { opacity: 0.5; } + +.TransactionHistory .titleContainer { + display: flex; + padding: 20 0px; + border-top-right-radius: 10px; + border-top-left-radius: 10px; + align-items: center; + border-bottom: 1px solid var(--secondary); + padding-left: 20px; +} + +.TransactionHistory .tableContainer { + display: flex; + flex-direction: column; + border: 2px solid var(--secondary); + border-radius: 12px; + margin-top: 48px; +} + +.TransactionHistory .firstTabMargin { + padding-left: 20px !important; +} + +@media (max-width: 768px) { + .TransactionHistory .titleContainer { + padding: 20px; + } +} + +.linkedProfile { + color: var(--primary) !important; +} diff --git a/webapp/src/components/AssetPage/TransactionHistory/TransactionHistory.tsx b/webapp/src/components/AssetPage/TransactionHistory/TransactionHistory.tsx index 042a7d9a2..329a6ccd7 100644 --- a/webapp/src/components/AssetPage/TransactionHistory/TransactionHistory.tsx +++ b/webapp/src/components/AssetPage/TransactionHistory/TransactionHistory.tsx @@ -1,47 +1,33 @@ import React, { useState, useEffect } from 'react' -import { Item, Sale } from '@dcl/schemas' -import { - Header, - Table, - Mobile, - NotMobile, - Pagination, - Loader, - Row -} from 'decentraland-ui' +import { useTabletAndBelowMediaQuery } from 'decentraland-ui' +import { Item } from '@dcl/schemas' import { t } from 'decentraland-dapps/dist/modules/translation/utils' -import dateFnsFormat from 'date-fns/format' - -import { Mana } from '../../Mana' import { saleAPI } from '../../../modules/vendor/decentraland' -import { formatDistanceToNow } from '../../../lib/date' -import { formatWeiMANA } from '../../../lib/mana' import { isNFT } from '../../../modules/asset/utils' import { NFT } from '../../../modules/nft/types' -import { LinkedProfile } from '../../LinkedProfile' +import TableContainer from '../../Table/TableContainer' +import { TableContent } from '../../Table/TableContent' +import { DataTableType } from '../../Table/TableContent/TableContent.types' +import { formatDataToTable } from './utils' import { Props } from './TransactionHistory.types' import './TransactionHistory.css' -const INPUT_FORMAT = 'PPP' -const WEEK_IN_MILLISECONDS = 7 * 24 * 60 * 60 * 1000 const ROWS_PER_PAGE = 12 -const formatEventDate = (updatedAt: number) => { - const newUpdatedAt = new Date(updatedAt) - return Date.now() - newUpdatedAt.getTime() > WEEK_IN_MILLISECONDS - ? dateFnsFormat(newUpdatedAt, INPUT_FORMAT) - : formatDistanceToNow(newUpdatedAt, { addSuffix: true }) -} - -const formatDateTitle = (updatedAt: number) => { - return new Date(updatedAt).toLocaleString() -} - const TransactionHistory = (props: Props) => { const { asset } = props + const isMobileOrTablet = useTabletAndBelowMediaQuery() - const [sales, setSales] = useState([] as Sale[]) + const tabList = [ + { + value: 'transaction_history', + displayValue: t('transaction_history.title') + } + ] + + const [sales, setSales] = useState<DataTableType[]>([]) const [page, setPage] = useState(1) + const [total, setTotal] = useState(0) const [totalPages, setTotalPages] = useState(1) const [isLoading, setIsLoading] = useState(false) const isAssetNull = asset === null @@ -67,7 +53,8 @@ const TransactionHistory = (props: Props) => { saleAPI .fetch(params) .then(response => { - setSales(response.data) + setTotal(response.total) + setSales(formatDataToTable(response.data, isMobileOrTablet)) setTotalPages(Math.ceil(response.total / ROWS_PER_PAGE) | 0) }) .finally(() => setIsLoading(false)) @@ -83,91 +70,28 @@ const TransactionHistory = (props: Props) => { setSales, page, isAssetNull, - isAssetNFT + isAssetNFT, + isMobileOrTablet ]) - const network = asset ? asset.network : undefined - - return ( - <div className="TransactionHistory"> - {isLoading && sales.length === 0 ? null : sales.length > 0 ? ( - <> - <Header sub>{t('transaction_history.title')}</Header> - <NotMobile> - <Table basic="very"> - <Table.Header> - <Table.Row> - <Table.HeaderCell> - {t('transaction_history.from')} - </Table.HeaderCell> - <Table.HeaderCell> - {t('transaction_history.to')} - </Table.HeaderCell> - <Table.HeaderCell> - {t('transaction_history.type')} - </Table.HeaderCell> - <Table.HeaderCell> - {t('transaction_history.when')} - </Table.HeaderCell> - <Table.HeaderCell> - {t('transaction_history.price')} - </Table.HeaderCell> - </Table.Row> - </Table.Header> - - <Table.Body className={isLoading ? 'is-loading' : ''}> - {sales.map(sale => ( - <Table.Row key={sale.id}> - <Table.Cell> - <LinkedProfile address={sale.seller} /> - </Table.Cell> - <Table.Cell> - <LinkedProfile address={sale.buyer} /> - </Table.Cell> - <Table.Cell>{t(`global.${sale.type}`)}</Table.Cell> - <Table.Cell title={formatDateTitle(sale.timestamp)}> - {formatEventDate(sale.timestamp)} - </Table.Cell> - <Table.Cell> - <Mana showTooltip network={network} inline> - {formatWeiMANA(sale.price)} - </Mana> - </Table.Cell> - </Table.Row> - ))} - {isLoading ? <Loader active /> : null} - </Table.Body> - </Table> - </NotMobile> - <Mobile> - <div className="mobile-tx-history"> - {sales.map(sale => ( - <div className="mobile-tx-history-row" key={sale.id}> - <div className="price"> - <Mana showTooltip network={network} inline> - {formatWeiMANA(sale.price)} - </Mana> - </div> - <div className="when">{formatEventDate(sale.timestamp)}</div> - </div> - ))} - </div> - </Mobile> - {totalPages > 1 ? ( - <Row center> - <Pagination - activePage={page} - totalPages={totalPages} - onPageChange={(_event, props) => setPage(+props.activePage!)} - firstItem={null} - lastItem={null} - /> - </Row> - ) : null} - </> - ) : null} - </div> - ) + return sales.length > 0 ? ( + <TableContainer + tabsList={tabList} + children={ + <TableContent + hasHeaders + data={sales} + activePage={page} + isLoading={isLoading} + setPage={setPage} + totalPages={totalPages} + empty={() => null} + total={total} + rowsPerPage={ROWS_PER_PAGE} + /> + } + /> + ) : null } export default React.memo(TransactionHistory) diff --git a/webapp/src/components/AssetPage/TransactionHistory/utils.tsx b/webapp/src/components/AssetPage/TransactionHistory/utils.tsx new file mode 100644 index 000000000..1e047b7bc --- /dev/null +++ b/webapp/src/components/AssetPage/TransactionHistory/utils.tsx @@ -0,0 +1,47 @@ +import dateFnsFormat from 'date-fns/format' +import { Sale } from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { Mana } from 'decentraland-ui' +import { formatDistanceToNow } from '../../../lib/date' +import { formatWeiMANA } from '../../../lib/mana' +import { LinkedProfile } from '../../LinkedProfile' +import { DataTableType } from '../../Table/TableContent/TableContent.types' +import './TransactionHistory.css' + +const INPUT_FORMAT = 'PPP' +const WEEK_IN_MILLISECONDS = 7 * 24 * 60 * 60 * 1000 + +const formatEventDate = (updatedAt: number) => { + const newUpdatedAt = new Date(updatedAt) + return Date.now() - newUpdatedAt.getTime() > WEEK_IN_MILLISECONDS + ? dateFnsFormat(newUpdatedAt, INPUT_FORMAT) + : formatDistanceToNow(newUpdatedAt, { addSuffix: true }) +} + +export const formatDataToTable = ( + sales: Sale[], + isMobile = false +): DataTableType[] => { + return sales?.map((sale: Sale) => { + const value: DataTableType = { + ...(!isMobile && { + [t('transaction_history.from')]: ( + <LinkedProfile address={sale.seller} className="linkedProfile" /> + ) + }), + ...(!isMobile && { + [t('transaction_history.to')]: ( + <LinkedProfile address={sale.buyer} className="linkedProfile" /> + ) + }), + [t('transaction_history.type')]: t(`global.${sale.type}`), + [t('transaction_history.when')]: formatEventDate(sale.timestamp), + [t('transaction_history.price')]: ( + <Mana network={sale.network} inline> + {formatWeiMANA(sale.price)} + </Mana> + ) + } + return value + }) +} diff --git a/webapp/src/components/AssetPage/WearableDetail/WearableDetail.module.css b/webapp/src/components/AssetPage/WearableDetail/WearableDetail.module.css index 732797692..297fbb94a 100644 --- a/webapp/src/components/AssetPage/WearableDetail/WearableDetail.module.css +++ b/webapp/src/components/AssetPage/WearableDetail/WearableDetail.module.css @@ -1,3 +1,97 @@ +.WearableDetail { + display: flex; + flex-direction: column; +} + +.WearableDetail .actionsContainer { + width: 521px; +} + +.WearableDetail .assetImageContainer { + height: 602px; +} + +.WearableDetail .assetImageContainer :global(.AssetImage) { + border-radius: 12px; + overflow: hidden; +} + +.WearableDetail .badges { + margin-top: 8px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.WearableDetail .wearableInformation { + display: flex; + flex-direction: column; + flex: 1; + justify-content: space-between; +} + +.WearableDetail .wearableInformationContainer { + display: flex; + flex-direction: row; + gap: 30px; + width: 100%; + margin-top: 25px; +} + .issued { color: var(--secondary-text); } + +.WearableDetail .wearableBadgesContainer { + margin-bottom: 18px; +} + +@media (max-width: 768px) { + .WearableDetail .wearableInformationContainer { + gap: 16px; + } + + .WearableDetail .actionsContainer { + width: 100%; + } + + .WearableDetail .assetImageContainer { + height: unset; + } + + .WearableDetail .assetImageContainer.availableForMint { + margin-bottom: 100px; + } + + .WearableDetail .assetImageContainer :global(.AssetImage) { + overflow: visible; + } + + .WearableDetail .wearableInformationContainer { + flex-direction: column; + } + .WearableDetail .wearableBadgesContainer { + margin-bottom: 18px; + } + + .WearableDetail .wearableOwnerAndCollectionContainer { + margin-top: 18px; + display: flex; + flex-direction: column; + } + + .WearableDetail .wearableOwnerAndCollectionContainer :global(.dcl.stats) { + margin-bottom: 16px; + } + + .WearableDetail :global(.dcl.tabs.fullscreen) { + padding-left: 21px; + margin: 0; + border-bottom: 1px solid var(--divider); + } + + .WearableDetail :global(.filtertabsContainer .dcl.tab) { + height: unset; + margin-bottom: 22px; + } +} diff --git a/webapp/src/components/AssetPage/WearableDetail/WearableDetail.tsx b/webapp/src/components/AssetPage/WearableDetail/WearableDetail.tsx index 017616ff8..f671e5604 100644 --- a/webapp/src/components/AssetPage/WearableDetail/WearableDetail.tsx +++ b/webapp/src/components/AssetPage/WearableDetail/WearableDetail.tsx @@ -1,69 +1,114 @@ -import React from 'react' -import { NFTCategory } from '@dcl/schemas' +import React, { useState } from 'react' +import { NFTCategory, OrderSortBy } from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { AssetType } from '../../../modules/asset/types' import { Section } from '../../../modules/vendor/decentraland' import CampaignBadge from '../../Campaign/CampaignBadge' +import TableContainer from '../../Table/TableContainer' import { AssetImage } from '../../AssetImage' import GenderBadge from '../../GenderBadge' import RarityBadge from '../../RarityBadge' -import BaseDetail from '../BaseDetail' -import { BidList } from '../BidList' +import { BidsTable } from '../BidsTable' +import { YourOffer } from '../YourOffer' import CategoryBadge from '../CategoryBadge' +import { ListingsTable } from '../ListingsTable' import Collection from '../Collection' import { Description } from '../Description' import { Owner } from '../Owner' -import { SaleActionBox } from '../SaleActionBox' import SmartBadge from '../SmartBadge' import { TransactionHistory } from '../TransactionHistory' +import OnBack from '../OnBack' +import Title from '../Title' +import { BuyNFTBox } from '../BuyNFTBox' import { Props } from './WearableDetail.types' +import styles from './WearableDetail.module.css' const WearableDetail = ({ nft }: Props) => { const wearable = nft.data.wearable! + const [sortBy, setSortBy] = useState<OrderSortBy>(OrderSortBy.CHEAPEST) + + const tabList = [ + { + value: 'other_available_listings', + displayValue: t('listings_table.other_available_listings') + } + ] + + const listingSortByOptions = [ + { + text: t('listings_table.cheapest'), + value: OrderSortBy.CHEAPEST + }, + { + text: t('listings_table.newest'), + value: OrderSortBy.RECENTLY_LISTED + }, + { + text: t('listings_table.oldest'), + value: OrderSortBy.OLDEST + }, + { + text: t('listings_table.issue_number_asc'), + value: OrderSortBy.ISSUED_ID_ASC + }, + { + text: t('listings_table.issue_number_desc'), + value: OrderSortBy.ISSUED_ID_DESC + } + ] return ( - <BaseDetail - asset={nft} - assetImage={<AssetImage asset={nft} isDraggable />} - isOnSale={!!nft.activeOrderId} - badges={ - <> - <RarityBadge - rarity={wearable.rarity} - assetType={AssetType.NFT} - category={NFTCategory.WEARABLE} - /> - <CategoryBadge - category={wearable.category} - assetType={AssetType.NFT} - /> - <GenderBadge - bodyShapes={wearable.bodyShapes} - assetType={AssetType.NFT} - section={Section.WEARABLES} - /> - {wearable.isSmart ? <SmartBadge assetType={AssetType.NFT} /> : null} - <CampaignBadge contract={nft.contractAddress} /> - </> - } - left={ - <> + <div className={styles.WearableDetail}> + <OnBack asset={nft} /> + <div className={styles.assetImageContainer}> + <AssetImage asset={nft} isDraggable /> + </div> + <div className={styles.wearableInformationContainer}> + <div className={styles.wearableInformation}> + <div className={styles.wearableBadgesContainer}> + <Title asset={nft} /> + <div className={styles.badges}> + <RarityBadge + rarity={wearable.rarity} + assetType={AssetType.NFT} + category={NFTCategory.WEARABLE} + /> + <CategoryBadge + category={wearable.category} + assetType={AssetType.NFT} + /> + <GenderBadge + bodyShapes={wearable.bodyShapes} + assetType={AssetType.NFT} + section={Section.WEARABLES} + /> + {wearable.isSmart ? ( + <SmartBadge assetType={AssetType.NFT} /> + ) : null} + <CampaignBadge contract={nft.contractAddress} /> + </div> + </div> <Description text={wearable.description} /> - <div className="BaseDetail row"> + <div className={styles.wearableOwnerAndCollectionContainer}> <Owner asset={nft} /> <Collection asset={nft} /> </div> - </> - } - box={null} - showDetails - actions={<SaleActionBox asset={nft} />} - below={ - <> - <BidList nft={nft} /> - <TransactionHistory asset={nft} /> - </> - } - /> + </div> + <div className={styles.actionsContainer}> + <BuyNFTBox nft={nft} /> + </div> + </div> + <YourOffer nft={nft} /> + <BidsTable nft={nft} /> + <TransactionHistory asset={nft} /> + <TableContainer + tabsList={tabList} + handleSortByChange={(value: string) => setSortBy(value as OrderSortBy)} + sortbyList={listingSortByOptions} + sortBy={sortBy} + children={<ListingsTable asset={nft} sortBy={sortBy as OrderSortBy} />} + /> + </div> ) } diff --git a/webapp/src/components/AssetPage/YourOffer/YourOffer.container.ts b/webapp/src/components/AssetPage/YourOffer/YourOffer.container.ts new file mode 100644 index 000000000..3d5cf1d90 --- /dev/null +++ b/webapp/src/components/AssetPage/YourOffer/YourOffer.container.ts @@ -0,0 +1,20 @@ +import { connect } from 'react-redux' +import { push } from 'connected-react-router' +import { RootState } from '../../../modules/reducer' +import { getAddress } from '../../../modules/wallet/selectors' +import { locations } from '../../../modules/routing/locations' +import { cancelBidRequest } from '../../../modules/bid/actions' +import { MapStateProps, MapDispatchProps, MapDispatch } from './YourOffer.types' +import YourOffer from './YourOffer' + +const mapState = (state: RootState): MapStateProps => ({ + address: getAddress(state) +}) + +const mapDispatch = (dispatch: MapDispatch): MapDispatchProps => ({ + onUpdate: bid => + dispatch(push(locations.bid(bid.contractAddress, bid.tokenId))), + onCancel: bid => dispatch(cancelBidRequest(bid)) +}) + +export default connect(mapState, mapDispatch)(YourOffer) diff --git a/webapp/src/components/AssetPage/YourOffer/YourOffer.module.css b/webapp/src/components/AssetPage/YourOffer/YourOffer.module.css new file mode 100644 index 000000000..e9753be67 --- /dev/null +++ b/webapp/src/components/AssetPage/YourOffer/YourOffer.module.css @@ -0,0 +1,92 @@ +.YourOffer { + background-color: var(--card); + border-radius: 10px; + display: flex; + flex-direction: column; + margin-top: 30px; +} + +.YourOffer .actions { + height: 44px; + width: 136px; +} + +.YourOffer .actionsContainer { + display: flex; + gap: 14px; +} + +.YourOffer .bottomInformationPadding { + padding: 0px 26px 15px 26px; +} + +.YourOffer .calendar { + height: 26.5px; + width: 26.5px; +} + +.YourOffer .center { + align-items: center; +} + +.YourOffer .column { + display: flex; + flex-direction: column; +} + +.YourOffer .informationBold { + color: white; + font-size: 30px; + font-weight: 600; + margin: unset; +} + +.YourOffer .informationBold :global(.ui.header.large) { + font-size: 30px; + font-weight: 600; +} + +.YourOffer .informationBold :global(.dcl.mana .matic), +.YourOffer .informationBold :global(.dcl.mana .ethereum) { + height: 22px; + margin-bottom: 5px; + width: 22px; +} + +.YourOffer .informationText { + color: white; + font-size: 24px; + line-height: 1.7; +} + +.YourOffer .informationTooltip { + align-self: center; + height: 14px; + width: 14px; +} + +.YourOffer .offerIcon { + height: 28.5px; + width: 28.5px; +} + +.YourOffer .row { + align-items: center; + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.YourOffer .title { + align-items: center; + display: flex; + font-size: 22px; + font-weight: 700; + gap: 10px; + padding: 28px 0px 0px 26px; +} + +.YourOffer .texts { + display: flex; + font-weight: 600; +} diff --git a/webapp/src/components/AssetPage/YourOffer/YourOffer.tsx b/webapp/src/components/AssetPage/YourOffer/YourOffer.tsx new file mode 100644 index 000000000..4c2272638 --- /dev/null +++ b/webapp/src/components/AssetPage/YourOffer/YourOffer.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useState } from 'react' +import { Bid, Network } from '@dcl/schemas' +import { Button, Divider, Popup } from 'decentraland-ui' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import Mana from '../../Mana/Mana' +import { formatDistanceToNow } from '../../../lib/date' +import iconListings from '../../../images/iconListings.png' +import infoIcon from '../../../images/infoIcon.png' +import calendar from '../../../images/calendar.png' +import expiration from '../../../images/expiration.png' +import { bidAPI } from '../../../modules/vendor/decentraland' +import { formatWeiMANA } from '../../../lib/mana' +import { ManaToFiat } from '../../ManaToFiat' +import { Props } from './YourOffer.types' +import styles from './YourOffer.module.css' + +const FIRST = '1' + +const YourOffer = (props: Props) => { + const { nft, address, onUpdate, onCancel } = props + + const [bid, setBid] = useState<Bid>() + + useEffect(() => { + if (nft) { + bidAPI + .fetchByNFT( + nft.contractAddress, + nft.tokenId, + null, + undefined, + FIRST, + undefined, + address + ) + .then(response => { + setBid(response.data[0]) + }) + .catch(error => { + console.error(error) + }) + } + }, [nft, setBid, address]) + + return bid ? ( + <div className={styles.YourOffer}> + <span className={styles.title}> + <img + src={iconListings} + alt={t('offers_table.your_offer')} + className={styles.offerIcon} + /> + {t('offers_table.your_offer')} + </span> + + <Divider /> + <div className={`${styles.row} ${styles.bottomInformationPadding}`}> + <div className={styles.column}> + <span className={styles.texts}> + {t('offers_table.offer').toUpperCase()}  + <Popup + content={ + bid.network === Network.MATIC + ? t('best_buying_option.minting.polygon_mana') + : t('best_buying_option.minting.ethereum_mana') + } + position="top center" + trigger={ + <img + src={infoIcon} + alt="info" + className={styles.informationTooltip} + /> + } + on="hover" + /> + </span> + <div className={styles.row}> + <div className={styles.informationBold}> + <Mana + withTooltip + size="large" + network={bid.network} + className={styles.informationBold} + > + {formatWeiMANA(bid.price)} + </Mana> + </div> + {+bid.price > 0 && ( + <div className={styles.informationText}> +  {'('} + <ManaToFiat mana={bid.price} /> + {')'} + </div> + )} + </div> + </div> + <div className={`${styles.column} ${styles.center}`}> + <img src={calendar} alt="calendar" className={styles.calendar} /> + <span className={styles.texts}> + {t('offers_table.date_published').toUpperCase()} + </span> + + {formatDistanceToNow(+bid.createdAt, { + addSuffix: true + })} + </div> + + <div className={`${styles.column} ${styles.center}`}> + <img src={expiration} alt="expiration" className={styles.calendar} /> + <span className={styles.texts}> + {t('offers_table.expiration_date').toUpperCase()} + </span> + + {formatDistanceToNow(+bid.expiresAt, { + addSuffix: true + })} + </div> + + <div className={styles.actionsContainer}> + <Button + inverted + className={styles.actions} + onClick={() => onCancel(bid)} + > + {t('offers_table.remove')} + </Button> + <Button + primary + className={styles.actions} + onClick={() => onUpdate(bid)} + > + {t('global.update')} + </Button> + </div> + </div> + </div> + ) : null +} + +export default React.memo(YourOffer) diff --git a/webapp/src/components/AssetPage/YourOffer/YourOffer.types.ts b/webapp/src/components/AssetPage/YourOffer/YourOffer.types.ts new file mode 100644 index 000000000..831d5cb21 --- /dev/null +++ b/webapp/src/components/AssetPage/YourOffer/YourOffer.types.ts @@ -0,0 +1,21 @@ +import { Dispatch } from 'react' +import { CallHistoryMethodAction } from 'connected-react-router' +import { Bid } from '@dcl/schemas' +import { VendorName } from '../../../modules/vendor' +import { NFT } from '../../../modules/nft/types' +import { CancelBidRequestAction } from '../../../modules/bid/actions' + +export type Props = { + nft: NFT<VendorName.DECENTRALAND> | null + address?: string + onUpdate: (bid: Bid) => void + onCancel: (bid: Bid) => void +} + +export type MapStateProps = Pick<Props, 'address'> + +export type MapDispatchProps = Pick<Props, 'onUpdate' | 'onCancel'> + +export type MapDispatch = Dispatch< + CallHistoryMethodAction | CancelBidRequestAction +> diff --git a/webapp/src/components/AssetPage/YourOffer/index.tsx b/webapp/src/components/AssetPage/YourOffer/index.tsx new file mode 100644 index 000000000..4132a99a4 --- /dev/null +++ b/webapp/src/components/AssetPage/YourOffer/index.tsx @@ -0,0 +1,2 @@ +import YourOffer from './YourOffer.container' +export { YourOffer } diff --git a/webapp/src/components/AssetPage/index.ts b/webapp/src/components/AssetPage/index.ts index 02e8153fe..b90f38177 100644 --- a/webapp/src/components/AssetPage/index.ts +++ b/webapp/src/components/AssetPage/index.ts @@ -1,3 +1,3 @@ -import AssetPage from './AssetPage.container' +import AssetPage from './AssetPage' export { AssetPage } diff --git a/webapp/src/components/AssetProvider/AssetProvider.container.ts b/webapp/src/components/AssetProvider/AssetProvider.container.ts index c5f38aab5..61c0c7439 100644 --- a/webapp/src/components/AssetProvider/AssetProvider.container.ts +++ b/webapp/src/components/AssetProvider/AssetProvider.container.ts @@ -36,7 +36,6 @@ import { } from './AssetProvider.types' import AssetProvider from './AssetProvider' - const mapState = (state: RootState, ownProps: OwnProps): MapStateProps => { let contractAddress = ownProps.contractAddress let tokenId = ownProps.tokenId diff --git a/webapp/src/components/AssetTopbar/AssetTopbar.container.ts b/webapp/src/components/AssetTopbar/AssetTopbar.container.ts index b166f3d2b..23ba99858 100644 --- a/webapp/src/components/AssetTopbar/AssetTopbar.container.ts +++ b/webapp/src/components/AssetTopbar/AssetTopbar.container.ts @@ -12,6 +12,7 @@ import { getSearch, getSection, getSortBy, + getSortByOptions, hasFiltersEnabled } from '../../modules/routing/selectors' import { BrowseOptions } from '../../modules/routing/types' @@ -37,6 +38,7 @@ const mapState = (state: RootState): MapStateProps => { onlyOnRent: getOnlyOnRent(state), onlyOnSale: getOnlyOnSale(state), sortBy: getSortBy(state), + sortByOptions: getSortByOptions(state), assetType: getAssetType(state), section: getSection(state), hasFiltersEnabled: hasFiltersEnabled(state), diff --git a/webapp/src/components/AssetTopbar/AssetTopbar.module.css b/webapp/src/components/AssetTopbar/AssetTopbar.module.css index 51081f99e..eaf6c84ff 100644 --- a/webapp/src/components/AssetTopbar/AssetTopbar.module.css +++ b/webapp/src/components/AssetTopbar/AssetTopbar.module.css @@ -1,8 +1,15 @@ .assetTopbar { - margin-bottom: 20px; position: relative; } +.assetTopbar.sticky { + position: fixed; + top: 0; + width: 100%; + background-color: black; + z-index: 99999999; +} + .searchContainer :global(.dcl.field.full) { flex: 1; } @@ -10,13 +17,15 @@ .assetTopbar :global(.dcl.field.full) :global(.ui.input).searchField input { background: var(--secondary); font-size: 17px; + border-radius: 10px; } .assetTopbar :global(.dcl.field.full) :global(.ui.input).searchField input:not(:focus) { - border: 2px solid transparent; + border: 2px solid #979797; + border-radius: 10px; } .assetTopbar :global(.dcl.field.full) { @@ -33,6 +42,13 @@ align-items: center; font-size: 13px; min-height: 26px; + margin-bottom: 12px; +} + +.assetTopbar :global(.dcl.field .ui.input .icon) { + color: white; + left: 8px; + opacity: 1; } .assetTopbar :global(.dcl.field .message) { @@ -68,7 +84,7 @@ } .countText { - color: #736e7d; + color: white; margin-bottom: 0; margin-right: 10px; font-size: 17px; @@ -80,14 +96,9 @@ align-items: baseline; } -.clearFilters { - font-size: 13px; - line-height: 13px; - text-transform: uppercase; - cursor: pointer; - color: var(--primary); - background: transparent; - border: none; +.rightOptionsContainer :global(.ui.dropdown) { + display: flex; + align-items: center; } .clearFilters:hover { @@ -128,9 +139,15 @@ width: 100%; } +.searchContainer :global(.dcl.close) { + background: transparent; + position: absolute; + right: 12px; +} + .searchContainer.searchMap { align-self: flex-end; - background-color: #16141A; + background-color: #16141a; width: fit-content; display: flex; padding: 10px 20px; @@ -153,6 +170,15 @@ min-height: 32px; } +.selectedFiltersContainer { + flex-wrap: wrap; + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; + margin-bottom: 12px; +} + @media (max-width: 990px) { .countText { max-width: 160px; @@ -168,10 +194,8 @@ .selectedFiltersContainer { display: flex; - align-items: baseline; padding: 16px 0; margin-bottom: 6px; - flex-wrap: wrap; } .clearFilters { diff --git a/webapp/src/components/AssetTopbar/AssetTopbar.tsx b/webapp/src/components/AssetTopbar/AssetTopbar.tsx index 70ae76729..cc47f54d6 100644 --- a/webapp/src/components/AssetTopbar/AssetTopbar.tsx +++ b/webapp/src/components/AssetTopbar/AssetTopbar.tsx @@ -1,18 +1,18 @@ -import { useCallback, useMemo, useEffect } from 'react' +import { useCallback, useEffect } from 'react' import classNames from 'classnames' import { + Close, Dropdown, DropdownProps, Field, Icon, useTabletAndBelowMediaQuery } from 'decentraland-ui' -import { NFTCategory } from '@dcl/schemas' import { t } from 'decentraland-dapps/dist/modules/translation/utils' -import { AssetType } from '../../modules/asset/types' import { useInput } from '../../lib/input' -import { getCountText, getOrderByOptions } from './utils' +import { getCountText } from './utils' import { SortBy } from '../../modules/routing/types' +import { isCatalogView } from '../../modules/routing/utils' import { getCategoryFromSection, getSectionFromCategory @@ -24,7 +24,6 @@ import { persistIsMapProperty } from '../../modules/ui/utils' import { Chip } from '../Chip' -import { AssetTypeFilter } from './AssetTypeFilter' import { Props } from './AssetTopbar.types' import { SelectedFilters } from './SelectedFilters' import styles from './AssetTopbar.module.css' @@ -32,7 +31,6 @@ import styles from './AssetTopbar.module.css' export const AssetTopbar = ({ search, view, - assetType, count, isLoading, isMap, @@ -42,8 +40,9 @@ export const AssetTopbar = ({ section, hasFiltersEnabled, onBrowse, - onClearFilters, - onOpenFiltersModal + // onClearFilters, + onOpenFiltersModal, + sortByOptions }: Props): JSX.Element => { const isMobile = useTabletAndBelowMediaQuery() const category = section ? getCategoryFromSection(section) : undefined @@ -62,17 +61,10 @@ export const AssetTopbar = ({ const [searchValue, setSearchValue] = useInput(search, handleSearch, 500) - const handleAssetTypeChange = useCallback( - (value: AssetType) => { - onBrowse({ assetType: value }) - }, - [onBrowse] - ) - const handleOrderByDropdownChange = useCallback( (_, props: DropdownProps) => { const sortBy: SortBy = props.value as SortBy - if (!onlyOnRent && !onlyOnSale) { + if (!onlyOnRent && !onlyOnSale && isLandSection(section)) { if (sortBy === SortBy.CHEAPEST_SALE) { onBrowse({ onlyOnSale: true, sortBy: SortBy.CHEAPEST }) } else if (sortBy === SortBy.CHEAPEST_RENT) { @@ -82,7 +74,7 @@ export const AssetTopbar = ({ onBrowse({ sortBy }) } }, - [onBrowse, onlyOnSale, onlyOnRent] + [onlyOnRent, onlyOnSale, section, onBrowse] ) const handleIsMapChange = useCallback( @@ -103,19 +95,16 @@ export const AssetTopbar = ({ [onBrowse, onlyOnSale, onlyOnRent] ) - const orderByDropdownOptions = useMemo( - () => getOrderByOptions(onlyOnRent, onlyOnSale), - [onlyOnRent, onlyOnSale] - ) - useEffect(() => { - const option = orderByDropdownOptions.find( - option => option.value === sortBy - ) + const option = sortByOptions.find(option => option.value === sortBy) if (!option) { - onBrowse({ sortBy: orderByDropdownOptions[0].value }) + onBrowse({ sortBy: sortByOptions[0].value }) } - }, [onBrowse, sortBy, orderByDropdownOptions]) + }, [onBrowse, sortBy, sortByOptions]) + + const sortByValue = sortByOptions.find(option => option.value === sortBy) + ? sortBy + : sortByOptions[0].value return ( <div className={styles.assetTopbar}> @@ -131,10 +120,11 @@ export const AssetTopbar = ({ kind="full" value={searchValue} onChange={setSearchValue} - icon={<Icon name="search" />} + icon={<Icon name="search" className="searchIcon" />} iconPosition="left" /> )} + {searchValue ? <Close onClick={() => handleSearch('')} /> : null} {isLandSection(section) && !isAccountView(view!) && ( <div className={classNames(styles.mapToggle, { [styles.map]: isMap })} @@ -154,39 +144,31 @@ export const AssetTopbar = ({ </div> )} </div> - {view && - !isLandSection(section) && - !isAccountView(view) && - !isListsSection(section) && - (category === NFTCategory.WEARABLE || - category === NFTCategory.EMOTE) && ( - <AssetTypeFilter - view={view} - assetType={assetType} - onChange={handleAssetTypeChange} - /> - )} {!isMap && ( <div className={styles.infoRow}> - {!isLoading ? ( + {!isLoading || count ? ( <div className={styles.countContainer}> - <p className={styles.countText}>{getCountText(count, search)}</p> - {hasFiltersEnabled && !isMobile && ( - <button - className={styles.clearFilters} - onClick={onClearFilters} - > - {t('filters.clear')} - </button> - )} + <p className={styles.countText}> + {count && isCatalogView(view) + ? t( + search + ? 'nft_filters.query_results' + : 'nft_filters.results', + { + count: count.toLocaleString(), + search + } + ) + : getCountText(count, search)} + </p> </div> ) : null} {!isListsSection(section) ? ( <div className={styles.rightOptionsContainer}> <Dropdown direction="left" - value={sortBy} - options={orderByDropdownOptions} + value={sortByValue} + options={sortByOptions} onChange={handleOrderByDropdownChange} /> {isMobile ? ( @@ -206,11 +188,6 @@ export const AssetTopbar = ({ {!isMap && hasFiltersEnabled ? ( <div className={styles.selectedFiltersContainer}> <SelectedFilters /> - {isMobile && ( - <button className={styles.clearFilters} onClick={onClearFilters}> - {t('filters.clear')} - </button> - )} </div> ) : null} </div> diff --git a/webapp/src/components/AssetTopbar/AssetTopbar.types.ts b/webapp/src/components/AssetTopbar/AssetTopbar.types.ts index 0b5ecc5bb..4e905fd01 100644 --- a/webapp/src/components/AssetTopbar/AssetTopbar.types.ts +++ b/webapp/src/components/AssetTopbar/AssetTopbar.types.ts @@ -1,6 +1,6 @@ import { openModal } from 'decentraland-dapps/dist/modules/modal/actions' import { AssetType } from '../../modules/asset/types' -import { BrowseOptions } from '../../modules/routing/types' +import { BrowseOptions, SortByOption } from '../../modules/routing/types' import { Section } from '../../modules/vendor/routing/types' import { View } from '../../modules/ui/types' import { clearFilters } from '../../modules/routing/actions' @@ -11,6 +11,7 @@ export type Props = { isMap: boolean view: View | undefined sortBy: string | undefined + sortByOptions: SortByOption[] assetType: AssetType onlyOnSale: boolean | undefined onlyOnRent: boolean | undefined @@ -32,6 +33,7 @@ export type MapStateProps = Pick< | 'onlyOnRent' | 'onlyOnSale' | 'sortBy' + | 'sortByOptions' | 'section' | 'hasFiltersEnabled' | 'isLoading' diff --git a/webapp/src/components/AssetTopbar/SelectedFilters/Pill/Pill.module.css b/webapp/src/components/AssetTopbar/SelectedFilters/Pill/Pill.module.css index a30aeabea..39bb4f090 100644 --- a/webapp/src/components/AssetTopbar/SelectedFilters/Pill/Pill.module.css +++ b/webapp/src/components/AssetTopbar/SelectedFilters/Pill/Pill.module.css @@ -1,12 +1,10 @@ .pill { padding: 6px 16px; - background: rgba(115, 110, 125, 0.2); + background: var(--primary); border-radius: 16px; font-size: 13px; text-transform: uppercase; font-weight: 500; - margin-right: 10px; - margin-bottom: 10px; display: flex; align-items: center; } @@ -35,4 +33,3 @@ .pill .deleteBtn i { margin-right: 0; } - diff --git a/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.container.ts b/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.container.ts index 1343e4a09..d3ae1f6f2 100644 --- a/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.container.ts +++ b/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.container.ts @@ -1,7 +1,7 @@ import { connect } from 'react-redux' import { Dispatch } from 'redux' import { RootState } from '../../../modules/reducer' -import { browse } from '../../../modules/routing/actions' +import { browse, clearFilters } from '../../../modules/routing/actions' import { isLandSection } from '../../../modules/ui/utils' import { getCurrentBrowseOptions, @@ -24,7 +24,8 @@ const mapState = (state: RootState): MapStateProps => { const mapDispatch = (dispatch: Dispatch): MapDispatchProps => { return { - onBrowse: options => dispatch(browse(options)) + onBrowse: options => dispatch(browse(options)), + onClearFilters: () => dispatch(clearFilters()) } } diff --git a/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.module.css b/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.module.css index 2761bef4f..17ee6246d 100644 --- a/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.module.css +++ b/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.module.css @@ -1,7 +1,8 @@ .pillContainer { display: flex; flex-wrap: wrap; - margin-top: 21px; + grid-gap: 10px; + gap: 10px; } :global(.ui.header.mana.mana-label-icon) { @@ -20,6 +21,19 @@ padding-right: 0; } +.clearFilters { + font-size: 13px; + line-height: 13px; + text-transform: uppercase; + cursor: pointer; + color: white; + background: transparent; + border: none; + align-items: center; + display: flex; + gap: 6px; +} + @media (max-width: 768px) { .pillContainer { margin-top: 0; diff --git a/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.tsx b/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.tsx index c905bd91e..1caed5c75 100644 --- a/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.tsx +++ b/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.tsx @@ -8,9 +8,12 @@ import { getNetwork, getPriceLabel } from '../../../utils/filters' +import trash from '../../../images/trash.png' import { CreatorAccount } from '../../../modules/account/types' import ProfilesCache from '../../../lib/profiles' +import { AssetStatusFilter } from '../../../utils/filters' import { profileToCreatorAccount } from '../../AssetFilters/CreatorsFilter/utils' +import { AssetType } from '../../../modules/asset/types' import { Pill } from './Pill/Pill' import { Props } from './SelectedFilters.types' import { getCollectionByAddress } from './utils' @@ -20,7 +23,8 @@ export const SelectedFilters = ({ browseOptions, isLandSection, category, - onBrowse + onBrowse, + onClearFilters }: Props) => { const { rarities, @@ -39,7 +43,9 @@ export const SelectedFilters = ({ adjacentToRoad, minDistanceToPlaza, maxDistanceToPlaza, - rentalDays + rentalDays, + assetType, + status } = browseOptions const [collection, setCollection] = useState< Record<string, string> | undefined @@ -157,9 +163,20 @@ export const SelectedFilters = ({ onBrowse({ adjacentToRoad: undefined }) }, [onBrowse]) - const handleDeleteRentalDays = useCallback((removeDays) => { - onBrowse({ rentalDays: rentalDays?.filter((day) => removeDays.toString() !== day.toString() )}) - }, [onBrowse, rentalDays]) + const handleDeleteStatus = useCallback(() => { + onBrowse({ status: AssetStatusFilter.ON_SALE }) + }, [onBrowse]) + + const handleDeleteRentalDays = useCallback( + removeDays => { + onBrowse({ + rentalDays: rentalDays?.filter( + day => removeDays.toString() !== day.toString() + ) + }) + }, + [onBrowse, rentalDays] + ) return ( <div className={styles.pillContainer}> @@ -209,7 +226,7 @@ export const SelectedFilters = ({ onDelete={handleDeleteGender} /> ) : null} - {!onlyOnSale && !isLandSection ? ( + {!onlyOnSale && !isLandSection && assetType !== AssetType.ITEM ? ( // TODO UNIFIED: CHECK THIS <Pill label={t('nft_filters.not_on_sale')} id="onlyOnSale" @@ -257,17 +274,29 @@ export const SelectedFilters = ({ onDelete={handleDeleteDistanceToPlaza} id="distanceToPlaza" /> - ): null} - {rentalDays && rentalDays.length ? ( - rentalDays.map((days) => ( - <Pill - key={days} - label={t('nft_filters.periods.selection', { rentalDays: days })} - onDelete={handleDeleteRentalDays} - id={days.toString()} - /> - )) - ): null} + ) : null} + {rentalDays && rentalDays.length + ? rentalDays.map(days => ( + <Pill + key={days} + label={t('nft_filters.periods.selection', { rentalDays: days })} + onDelete={handleDeleteRentalDays} + id={days.toString()} + /> + )) + : null} + {status && status !== AssetStatusFilter.ON_SALE ? ( + <Pill + key={status} + label={t(`nft_filters.status.${status}`)} + onDelete={handleDeleteStatus} + id={status.toString()} + /> + ) : null} + <button className={styles.clearFilters} onClick={onClearFilters}> + <img src={trash} alt={t('filters.clear')} /> + {t('filters.clear')} + </button> </div> ) } diff --git a/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.types.ts b/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.types.ts index 7d8623d07..4f97ad28b 100644 --- a/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.types.ts +++ b/webapp/src/components/AssetTopbar/SelectedFilters/SelectedFilters.types.ts @@ -1,5 +1,5 @@ import { NFTCategory } from '@dcl/schemas' -import { browse } from '../../../modules/routing/actions' +import { browse, clearFilters } from '../../../modules/routing/actions' import { BrowseOptions } from '../../../modules/routing/types' export type Props = { @@ -7,10 +7,11 @@ export type Props = { browseOptions: BrowseOptions isLandSection: boolean onBrowse: typeof browse + onClearFilters: typeof clearFilters } export type MapStateProps = Pick< Props, 'browseOptions' | 'isLandSection' | 'category' > -export type MapDispatchProps = Pick<Props, 'onBrowse'> +export type MapDispatchProps = Pick<Props, 'onBrowse' | 'onClearFilters'> diff --git a/webapp/src/components/AssetTopbar/utils.ts b/webapp/src/components/AssetTopbar/utils.ts index f9b550216..e3c4586c7 100644 --- a/webapp/src/components/AssetTopbar/utils.ts +++ b/webapp/src/components/AssetTopbar/utils.ts @@ -1,5 +1,4 @@ import { t } from 'decentraland-dapps/dist/modules/translation/utils' -import { SortBy } from '../../modules/routing/types' import { MAX_QUERY_SIZE } from '../../modules/vendor/api' export function getCountText( @@ -30,46 +29,3 @@ export function getCountText( } ) } - -export function getOrderByOptions( - onlyOnRent: boolean | undefined, - onlyOnSale: boolean | undefined -) { - if (onlyOnRent && !onlyOnSale) { - return [ - { - value: SortBy.RENTAL_LISTING_DATE, - text: t('filters.recently_listed_for_rent') - }, - { value: SortBy.NAME, text: t('filters.name') }, - { value: SortBy.NEWEST, text: t('filters.newest') }, - { value: SortBy.MAX_RENTAL_PRICE, text: t('filters.cheapest') } - ] - } - - if (onlyOnSale && !onlyOnRent ) { - return [ - { - value: SortBy.RECENTLY_LISTED, - text: t('filters.recently_listed') - }, - { - value: SortBy.RECENTLY_SOLD, - text: t('filters.recently_sold') - }, - { - value: SortBy.CHEAPEST, - text: t('filters.cheapest') - }, - { value: SortBy.NEWEST, text: t('filters.newest') }, - { value: SortBy.NAME, text: t('filters.name') } - ] - } - - return [ - { value: SortBy.NEWEST, text: t('filters.newest') }, - { value: SortBy.NAME, text: t('filters.name') }, - { value: SortBy.CHEAPEST_SALE, text: t('filters.cheapest_sale') }, - { value: SortBy.CHEAPEST_RENT, text: t('filters.cheapest_rent') }, - ] -} diff --git a/webapp/src/components/CollectionPage/CollectionPage.module.css b/webapp/src/components/CollectionPage/CollectionPage.module.css index 08aa6196f..16d71c378 100644 --- a/webapp/src/components/CollectionPage/CollectionPage.module.css +++ b/webapp/src/components/CollectionPage/CollectionPage.module.css @@ -26,6 +26,7 @@ .headerContainer { width: 100%; + margin-left: 50px; } .title { @@ -33,9 +34,13 @@ } .ellipsis { - visibility: hidden; + visibility: visible; color: var(--summer-red); cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + padding-right: 5px; } .row:hover .ellipsis { diff --git a/webapp/src/components/CollectionPage/CollectionPage.tsx b/webapp/src/components/CollectionPage/CollectionPage.tsx index acf5223da..eab1a0bce 100644 --- a/webapp/src/components/CollectionPage/CollectionPage.tsx +++ b/webapp/src/components/CollectionPage/CollectionPage.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useState } from 'react' -import { Item, NFTCategory, Rarity } from '@dcl/schemas' +import { NFTCategory } from '@dcl/schemas' import { Back, Column, @@ -11,14 +11,7 @@ import { Icon, Color, Button, - Loader, - Table, - Dropdown, - Mobile, - NotMobile, - Tabs, - EmoteIcon, - WearableIcon + Loader } from 'decentraland-ui' import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { getBuilderCollectionDetailUrl } from '../../modules/collection/utils' @@ -29,32 +22,35 @@ import CollectionProvider from '../CollectionProvider' import { Navigation } from '../Navigation' import AssetCell from '../OnSaleOrRentList/AssetCell' import { Mana } from '../Mana' +import TableContainer from '../Table/TableContainer' +import { TableContent } from '../Table/TableContent' +import { formatDataToTable } from './utils' import { Props } from './CollectionPage.types' import styles from './CollectionPage.module.css' const CollectionPage = (props: Props) => { const { contractAddress, currentAddress, onBack } = props - const [tab, setTab] = useState<NFTCategory>(NFTCategory.WEARABLE) + + const tabList = [ + { + value: 'wearable', + displayValue: t('home_page.recently_sold.tabs.wearable') + }, + { + value: 'emote', + displayValue: t('home_page.recently_sold.tabs.emote') + } + ] + + const [tab, setTab] = useState<string>() const handleTabChange = useCallback( - (tab: NFTCategory) => { + (tab: string) => { setTab(tab) }, [setTab] ) - const getItemCategoryText = (item: Item) => { - switch (item.category) { - case NFTCategory.EMOTE: - case NFTCategory.WEARABLE: - return t( - `${item.category}.category.${item.data[item.category]?.category}` - ) - default: - return t(`global.${item.category}`) - } - } - return ( <div> <Navbar isFullscreen /> @@ -78,10 +74,16 @@ const CollectionPage = (props: Props) => { item => item.category === NFTCategory.EMOTE ) const hasOnlyEmotes = hasEmotes && !hasWearables - const filteredItems = items?.filter(item => - hasOnlyEmotes + + const filteredItems = items?.filter(item => { + return hasOnlyEmotes ? item.category === NFTCategory.EMOTE - : item.category === tab + : item.category === NFTCategory.WEARABLE + }) + + const tableItems = formatDataToTable( + filteredItems, + isCollectionOwner ) const showShowTabs = hasEmotes && hasWearables @@ -146,122 +148,30 @@ const CollectionPage = (props: Props) => { </Column> </Section> <Section> - <div> - {showShowTabs ? ( - <Tabs isFullscreen> - <div className={styles.tabs}> - <Tabs.Tab - active={tab === NFTCategory.WEARABLE} - onClick={() => - handleTabChange(NFTCategory.WEARABLE) - } - > - <div className={styles.tab}> - <WearableIcon /> - {t('home_page.recently_sold.tabs.wearable')} - </div> - </Tabs.Tab> - <Tabs.Tab - active={tab === NFTCategory.EMOTE} - onClick={() => handleTabChange(NFTCategory.EMOTE)} - > - <div className={styles.tab}> - <EmoteIcon /> - {t('home_page.recently_sold.tabs.emote')} - </div> - </Tabs.Tab> - </div> - </Tabs> - ) : null} - <Table basic="very"> - <Table.Header> - <NotMobile> - <Table.Row> - <Table.HeaderCell> - {t('global.item')} - </Table.HeaderCell> - <Table.HeaderCell> - {t('global.category')} - </Table.HeaderCell> - <Table.HeaderCell> - {t('global.rarity')} - </Table.HeaderCell> - <Table.HeaderCell> - {t('global.stock')} - </Table.HeaderCell> - <Table.HeaderCell> - {t('global.price')} - </Table.HeaderCell> - {isCollectionOwner && <Table.HeaderCell />} - </Table.Row> - </NotMobile> - </Table.Header> - <Table.Body> - <Mobile> - {filteredItems.map(item => ( - <div key={item.id} className="mobile-row"> - <AssetCell asset={item} /> - <Mana showTooltip network={item.network} inline> - {formatWeiMANA(item.price)} - </Mana> - </div> - ))} - </Mobile> - <NotMobile> - {filteredItems.map(item => ( - <Table.Row key={item.id} className={styles.row}> - <Table.Cell> - <AssetCell asset={item} /> - </Table.Cell> - <Table.Cell> - {getItemCategoryText(item)} - </Table.Cell> - <Table.Cell> - {t(`rarity.${item.rarity}`)} - </Table.Cell> - <Table.Cell> - {item.available.toLocaleString()}/ - {Rarity.getMaxSupply( - item.rarity - ).toLocaleString()} - </Table.Cell> - <Table.Cell> - <Mana - showTooltip - network={item.network} - inline - > - {formatWeiMANA(item.price)} - </Mana> - </Table.Cell> - {isCollectionOwner && ( - <Table.Cell> - <Dropdown - className={styles.ellipsis} - icon="ellipsis horizontal" - direction="left" - > - <Dropdown.Menu> - <Dropdown.Item - text={t('collection_page.edit_price')} - as="a" - href={builderCollectionUrl} - /> - <Dropdown.Item - text={t('collection_page.mint_item')} - as="a" - href={builderCollectionUrl} - /> - </Dropdown.Menu> - </Dropdown> - </Table.Cell> - )} - </Table.Row> - ))} - </NotMobile> - </Table.Body> - </Table> - </div> + <TableContainer + tabsList={showShowTabs ? tabList : []} + activeTab={tab} + handleTabChange={(tab: string) => handleTabChange(tab)} + children={ + <TableContent + data={tableItems} + isLoading={isLoading} + empty={() => ( + <div>{t('collection_page.no_collection')}</div> + )} + mobileTableBody={filteredItems.map(item => ( + <div key={item.id} className="mobile-row"> + <AssetCell asset={item} /> + <Mana network={item.network} inline> + {formatWeiMANA(item.price)} + </Mana> + </div> + ))} + total={0} + hasHeaders={showShowTabs} + /> + } + /> </Section> </> ) diff --git a/webapp/src/components/CollectionPage/utils.tsx b/webapp/src/components/CollectionPage/utils.tsx new file mode 100644 index 000000000..fb5d3f969 --- /dev/null +++ b/webapp/src/components/CollectionPage/utils.tsx @@ -0,0 +1,81 @@ +import { Item, NFTCategory, Rarity } from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { Dropdown, Mana } from 'decentraland-ui' +import { formatWeiMANA } from '../../lib/mana' +import { getBuilderCollectionDetailUrl } from '../../modules/collection/utils' +import AssetCell from '../OnSaleOrRentList/AssetCell' +import { DataTableType } from '../Table/TableContent/TableContent.types' +import styles from './CollectionPage.module.css' + +export const formatDataToTable = ( + rentals?: Item[], + isCollectionOwner: boolean = false +): DataTableType[] => { + const builderCollectionUrl = (contractAddress: string) => + getBuilderCollectionDetailUrl(contractAddress) + + const getItemCategoryText = (item: Item) => { + switch (item.category) { + case NFTCategory.EMOTE: + case NFTCategory.WEARABLE: + return t( + `${item.category}.category.${item.data[item.category]?.category}` + ) + default: + return t(`global.${item.category}`) + } + } + + return rentals + ? rentals?.map((item: Item) => { + const value: DataTableType = { + [t('global.item')]: <AssetCell asset={item} />, + + [t('global.category')]: getItemCategoryText(item), + + [t('global.rarity')]: t(`rarity.${item.rarity}`), + + [t('global.stock')]: ( + <> + {' '} + {item.available.toLocaleString()}/ + {Rarity.getMaxSupply(item.rarity).toLocaleString()} + </> + ), + + [t('global.price')]: ( + <Mana network={item.network} inline> + {formatWeiMANA(item.price)} + </Mana> + ) + } + + if (isCollectionOwner) { + value[''] = ( + <div className={styles.ellipsis}> + <Dropdown + className={styles.ellipsis} + icon="ellipsis horizontal" + direction="left" + > + <Dropdown.Menu> + <Dropdown.Item + text={t('collection_page.edit_price')} + as="a" + href={builderCollectionUrl} + /> + <Dropdown.Item + text={t('collection_page.mint_item')} + as="a" + href={builderCollectionUrl} + /> + </Dropdown.Menu> + </Dropdown> + </div> + ) + } + + return value + }) + : [] +} diff --git a/webapp/src/components/CollectionProvider/CollectionProvider.container.ts b/webapp/src/components/CollectionProvider/CollectionProvider.container.ts index e9f5940b4..8090f2ec7 100644 --- a/webapp/src/components/CollectionProvider/CollectionProvider.container.ts +++ b/webapp/src/components/CollectionProvider/CollectionProvider.container.ts @@ -12,6 +12,7 @@ import { isFetchingCollection, getError as getCollectionError } from '../../modules/collection/selectors' +import { View } from '../../modules/ui/types' import { fetchItemsRequest } from '../../modules/item/actions' import { getItemsByContractAddress, @@ -49,8 +50,9 @@ const mapDispatch = ( fetchItemsRequest({ filters: { first: collection.size, - contracts: [collection.contractAddress] - } + contractAddresses: [collection.contractAddress] + }, + view: View.DETAIL }) ) }) diff --git a/webapp/src/components/HomePage/Slideshow/Slideshow.css b/webapp/src/components/HomePage/Slideshow/Slideshow.css index b9321a0f7..8ce304c48 100644 --- a/webapp/src/components/HomePage/Slideshow/Slideshow.css +++ b/webapp/src/components/HomePage/Slideshow/Slideshow.css @@ -13,7 +13,6 @@ .Slideshow .assets { display: flex; flex-wrap: nowrap; - overflow: auto; position: relative; min-height: 336px; -ms-overflow-style: none; /* hide scrollbar IE and Edge */ @@ -39,7 +38,6 @@ flex: 0 0 auto; width: 257px; margin-right: 12px; - height: 300px; margin-top: 12px; } @@ -143,6 +141,9 @@ .Slideshow { overflow: hidden; } + .Slideshow .assets { + overflow: scroll; + } } .Slideshow .ItemsSection .tabs { @@ -178,6 +179,11 @@ } @media (max-width: 768px) { + .Slideshow .AssetCard .wrapBigText { + flex-direction: row; + align-items: center; + } + .Slideshow .ItemsSection { display: flex; align-items: center; diff --git a/webapp/src/components/ManaToFiat/ManaToFiat.tsx b/webapp/src/components/ManaToFiat/ManaToFiat.tsx index d1acab8ad..5b27f1af0 100644 --- a/webapp/src/components/ManaToFiat/ManaToFiat.tsx +++ b/webapp/src/components/ManaToFiat/ManaToFiat.tsx @@ -3,9 +3,13 @@ import { utils } from 'ethers' import { TokenConverter } from '../../modules/vendor/TokenConverter' import { Props } from './ManaToFiat.types' +const ONE_MILLION = { value: 1000000, displayValue: 'M' } +const ONE_BILLION = { value: 1000000000, displayValue: 'B' } +const ONE_TRILLION = { value: 1000000000000, displayValue: 'T' } + const ManaToFiat = (props: Props) => { const { mana, digits = 2 } = props - const [fiatValue, setFiatValue] = React.useState<number>() + const [fiatValue, setFiatValue] = React.useState<string>() useEffect(() => { try { @@ -13,22 +17,31 @@ const ManaToFiat = (props: Props) => { new TokenConverter() .marketMANAToUSD(value) .then(usd => { - setFiatValue(usd) + const divider = + usd > ONE_TRILLION.value + ? ONE_TRILLION + : usd > ONE_BILLION.value + ? ONE_BILLION + : usd > ONE_MILLION.value + ? ONE_MILLION + : { value: 1, displayValue: '' } + + setFiatValue( + `$ ${(+(+usd / divider.value).toFixed(digits)).toLocaleString( + undefined, + { + maximumFractionDigits: 2 + } + )}${divider.displayValue}` + ) }) .catch() } catch (error) { // do nothing } - }, [mana]) + }, [digits, mana]) - return fiatValue ? ( - <> - $ - {Number(fiatValue.toFixed(digits)).toLocaleString(undefined, { - maximumFractionDigits: 2 - })} - </> - ) : null + return fiatValue ? <>{fiatValue}</> : null } export default React.memo(ManaToFiat) diff --git a/webapp/src/components/ManageAssetPage/ManageAssetPage.module.css b/webapp/src/components/ManageAssetPage/ManageAssetPage.module.css index 66fb7756e..2085eb7df 100644 --- a/webapp/src/components/ManageAssetPage/ManageAssetPage.module.css +++ b/webapp/src/components/ManageAssetPage/ManageAssetPage.module.css @@ -98,7 +98,4 @@ .rentedMessage { margin-top: 0px; } - .main :global(.dcl.back.absolute) { - margin-bottom: 15px; - } } diff --git a/webapp/src/components/Navigation/Navigation.tsx b/webapp/src/components/Navigation/Navigation.tsx index c76b5de2c..f0d1b3a5a 100644 --- a/webapp/src/components/Navigation/Navigation.tsx +++ b/webapp/src/components/Navigation/Navigation.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames' import { Tabs, Mobile, Button, useMobileMediaQuery } from 'decentraland-ui' import { getAnalytics } from 'decentraland-dapps/dist/modules/analytics/utils' import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { AssetStatusFilter } from '../../utils/filters' import * as decentraland from '../../modules/vendor/decentraland' import { locations } from '../../modules/routing/locations' import { VendorName } from '../../modules/vendor' @@ -66,8 +67,8 @@ const Navigation = (props: Props) => { section: decentraland.Section.WEARABLES, vendor: VendorName.DECENTRALAND, page: 1, - sortBy: SortBy.RECENTLY_LISTED, - onlyOnSale: true + sortBy: SortBy.NEWEST, + status: AssetStatusFilter.ON_SALE, })} > <Tabs.Tab active={activeTab === NavigationTab.COLLECTIBLES}> diff --git a/webapp/src/components/OnSaleOrRentList/OnSaleOrRentList.css b/webapp/src/components/OnSaleOrRentList/OnSaleOrRentList.css new file mode 100644 index 000000000..211ac4071 --- /dev/null +++ b/webapp/src/components/OnSaleOrRentList/OnSaleOrRentList.css @@ -0,0 +1,90 @@ +.filters { + display: flex; + padding: 10px 10px 0px 10px; + align-items: center; +} + +.filters .ui.dropdown { + display: flex; + align-items: center; + font-size: 13px; + position: absolute; + right: 20px; +} + +.ui.dropdown > .icon { + height: 100%; + display: flex; + align-items: center; +} + +.search { + flex: 1; +} + +.onSaleOrRentTable { + display: flex; + flex-direction: column; + border-radius: 12px; + background-color: var(--secondary); +} + +.onSaleOrRentTable .ui.table { + font-size: 14px; + line-height: 24px; + color: white; + font-weight: normal; + font-style: normal; + font-stretch: normal; +} + +.onSaleOrRentTable .ui.basic.table th { + font-size: 16px; + font-weight: 700; + color: white; +} + +.onSaleOrRentTable .ui.table th { + padding: 18px; + border: 0.2px solid rgba(255, 255, 255, 0.2); +} + +.onSaleOrRentTable .ui.table td:first-child, +.onSaleOrRentTable .ui.table th:first-child { + padding: 18px !important; + border: 0.2px solid rgba(255, 255, 255, 0.2); +} + +.onSaleOrRentTable .ui.table tr td { + border: 0.2px solid rgba(255, 255, 255, 0.2); + padding: 18px; +} + +.onSaleOrRentTable .ui.table tr td { + border-top: 0.2px solid rgba(255, 255, 255, 0.2); +} + +.onSaleOrRentTable .ui.table td a { + color: var(--text); +} + +.onSaleOrRentTable .empty { + width: 100%; + height: 500px; + display: flex; + align-items: center; + justify-content: center; +} + +.onSaleOrRentTable .ui.loader.active { + display: flex; +} + +.onSaleOrRentTable .pagination { + justify-content: flex-end; + gap: 20px; + display: flex; + color: var(--secondary-text); + align-items: center; + margin-bottom: 11px; +} diff --git a/webapp/src/components/OnSaleOrRentList/OnSaleOrRentList.module.css b/webapp/src/components/OnSaleOrRentList/OnSaleOrRentList.module.css deleted file mode 100644 index ff4e716c8..000000000 --- a/webapp/src/components/OnSaleOrRentList/OnSaleOrRentList.module.css +++ /dev/null @@ -1,19 +0,0 @@ -.filters { - display: flex; -} - -.search { - flex: 1; -} - -.pagination { - display: flex; - justify-content: center; -} - -.empty { - margin-top: 40px; - font-size: 16px; - text-align: center; - color: var(--secondary-text); -} diff --git a/webapp/src/components/OnSaleOrRentList/OnSaleOrRentList.tsx b/webapp/src/components/OnSaleOrRentList/OnSaleOrRentList.tsx index b04d124ad..1e91c087a 100644 --- a/webapp/src/components/OnSaleOrRentList/OnSaleOrRentList.tsx +++ b/webapp/src/components/OnSaleOrRentList/OnSaleOrRentList.tsx @@ -13,11 +13,13 @@ import { SortBy } from '../../modules/routing/types' import { useProcessedElements } from './utils' import OnSaleListElement from './OnSaleListElement' import OnRentListElement from './OnRentListElement' -import styles from './OnSaleOrRentList.module.css' +import './OnSaleOrRentList.css' + +const ROWS_PER_PAGE = 12 const OnSaleOrRentList = ({ elements, isLoading, onSaleOrRentType }: Props) => { const showRents = onSaleOrRentType === OnSaleOrRentType.RENT - const perPage = useRef(12) + const perPage = useRef(ROWS_PER_PAGE) const sortOptions = useRef([ { value: SortBy.NEWEST, text: t('filters.newest') }, { value: SortBy.NAME, text: t('filters.name') } @@ -52,9 +54,9 @@ const OnSaleOrRentList = ({ elements, isLoading, onSaleOrRentType }: Props) => { ) return ( - <> - <div className={styles.filters}> - <div className={styles.search}>{searchNode}</div> + <div className="onSaleOrRentTable"> + <div className="filters"> + <div className="search">{searchNode}</div> <Dropdown direction="left" value={sort} @@ -73,15 +75,23 @@ const OnSaleOrRentList = ({ elements, isLoading, onSaleOrRentType }: Props) => { <NotMobile> <Table.Header> <Table.Row> - <Table.HeaderCell>{t('global.item')}</Table.HeaderCell> - <Table.HeaderCell>{t('global.type')}</Table.HeaderCell> <Table.HeaderCell> - {showRents ? t('global.status') : t('global.sale_type')} + <span>{t('global.item')}</span> + </Table.HeaderCell> + <Table.HeaderCell> + <span>{t('global.type')}</span> + </Table.HeaderCell> + <Table.HeaderCell> + <span> + {showRents ? t('global.status') : t('global.sale_type')} + </span> </Table.HeaderCell> <Table.HeaderCell> - {showRents - ? t('global.rent_price') - : t('global.sell_price')} + <span> + {showRents + ? t('global.rent_price') + : t('global.sell_price')} + </span> </Table.HeaderCell> </Table.Row> </Table.Header> @@ -108,10 +118,10 @@ const OnSaleOrRentList = ({ elements, isLoading, onSaleOrRentType }: Props) => { </Table.Body> </Table> {processedElements.total === 0 && ( - <div className={styles.empty}>{t('global.no_results')}</div> + <div className="empty">{t('global.no_results')}</div> )} {showPagination && ( - <div className={styles.pagination}> + <div className="pagination"> <Pagination totalPages={Math.ceil( processedElements.total / perPage.current @@ -123,7 +133,7 @@ const OnSaleOrRentList = ({ elements, isLoading, onSaleOrRentType }: Props) => { )} </> )} - </> + </div> ) } diff --git a/webapp/src/components/Price/Price.tsx b/webapp/src/components/Price/Price.tsx index 38a10c4ea..e89cd73ca 100644 --- a/webapp/src/components/Price/Price.tsx +++ b/webapp/src/components/Price/Price.tsx @@ -1,6 +1,7 @@ import React from 'react' import { t } from 'decentraland-dapps/dist/modules/translation/utils' -import { Mana, Stats } from 'decentraland-ui' +import { Stats } from 'decentraland-ui' +import Mana from '../Mana/Mana' import { formatWeiMANA } from '../../lib/mana' import { Props } from './Price.types' diff --git a/webapp/src/components/RankingsTable/RankingsTable.css b/webapp/src/components/RankingsTable/RankingsTable.css index eaae40433..a2a76ad39 100644 --- a/webapp/src/components/RankingsTable/RankingsTable.css +++ b/webapp/src/components/RankingsTable/RankingsTable.css @@ -7,21 +7,21 @@ padding-top: 0; border-radius: 10px; position: relative; - min-height: 575px; + min-height: 334px; display: flex; flex-direction: column; } .RankingsTable .rankings-card-tabs { display: flex; - background-color: #2f2b35; padding: 0 24px; border-top-right-radius: 10px; border-top-left-radius: 10px; } .RankingsTable .ui.very.basic.table { - padding: 0 24px; + padding: 0px; + margin: 0px; } .RankingsTable .dcl.tabs.fullscreen { @@ -179,3 +179,86 @@ width: 100%; } } + +.RankingsTable { + display: flex; + flex-direction: column; +} + +.RankingsTable .ui.table { + font-size: 14px; + line-height: 24px; + color: white; + font-weight: normal; + font-style: normal; + font-stretch: normal; +} + +.RankingsTable .ui.basic.table th { + font-size: 16px; + font-weight: 700; + color: white; +} + +.RankingsTable .ui.table tr:last-child td:first-child { + border-bottom-left-radius: 10px; +} + +.RankingsTable .ui.table tr:last-child td:last-child { + border-bottom-right-radius: 10px; +} + +.RankingsTable .ui.table th { + padding: 18px; + border: 1px solid var(--divider); +} + +.RankingsTable .ui.table td:first-child, +.RankingsTable .ui.table th:first-child { + padding: 18px !important; + border: 1px solid var(--divider); +} + +.RankingsTable .ui.table tr td { + border: 1px solid var(--divider); + padding: 18px; +} + +.RankingsTable .ui.table tr td { + border-top: 1px solid var(--divider); +} + +.RankingsTable .ui.table td a { + color: var(--text); +} + +.RankingsTable .emptyTable { + width: 100%; + height: 500px; + display: flex; + align-items: center; + justify-content: center; +} + +.RankingsTable .ui.loader.active { + display: flex; + position: initial; +} + +.RankingsTable .pagination { + justify-content: flex-end; + gap: 20px; + display: flex; + color: var(--secondary-text); + align-items: center; + margin-bottom: 11px; +} + +.RankingsTable .ui.dropdown { + display: flex; +} + +.RankingsTable .ui.loader.active { + display: flex; + position: absolute; +} diff --git a/webapp/src/components/RankingsTable/RankingsTable.tsx b/webapp/src/components/RankingsTable/RankingsTable.tsx index 1de86bd09..ca54bb6b5 100644 --- a/webapp/src/components/RankingsTable/RankingsTable.tsx +++ b/webapp/src/components/RankingsTable/RankingsTable.tsx @@ -195,32 +195,36 @@ const RankingsTable = (props: Props) => { <Table.Row> <Mobile> <Table.HeaderCell> - {t(`home_page.analytics.rankings.${label}.item`)} + <span>{t(`home_page.analytics.rankings.${label}.item`)}</span> </Table.HeaderCell> <Table.HeaderCell> - {t('home_page.analytics.rankings.total_volume')} + <span>{t('home_page.analytics.rankings.total_volume')}</span> </Table.HeaderCell> </Mobile> <NotMobile> <Table.HeaderCell> - {t(`home_page.analytics.rankings.${label}.item`)} + <span>{t(`home_page.analytics.rankings.${label}.item`)}</span> </Table.HeaderCell> <Table.HeaderCell> - {t('home_page.analytics.rankings.category')} + <span>{t('home_page.analytics.rankings.category')}</span> </Table.HeaderCell> <Table.HeaderCell> - {t('home_page.analytics.rankings.rarity')} + <span>{t('home_page.analytics.rankings.rarity')}</span> </Table.HeaderCell> <Table.HeaderCell> - {t(`home_page.analytics.rankings.${label}.items_sold`)} + <span> + {t(`home_page.analytics.rankings.${label}.items_sold`)} + </span> </Table.HeaderCell> <Table.HeaderCell> - {t('home_page.analytics.rankings.total_volume')} - <InfoTooltip - content={t( - 'home_page.analytics.rankings.total_volume_tooltip' - )} - /> + <span> + {t('home_page.analytics.rankings.total_volume')} + <InfoTooltip + content={t( + 'home_page.analytics.rankings.total_volume_tooltip' + )} + /> + </span> </Table.HeaderCell> </NotMobile> </Table.Row> @@ -232,39 +236,54 @@ const RankingsTable = (props: Props) => { <Table.Row> <Mobile> <Table.HeaderCell> - {t('home_page.analytics.rankings.items.creator')} + <span>{t('home_page.analytics.rankings.items.creator')}</span> </Table.HeaderCell> <Table.HeaderCell> - {t('home_page.analytics.rankings.total_volume_sales')} + {' '} + <span> + {t('home_page.analytics.rankings.total_volume_sales')} + </span> </Table.HeaderCell> </Mobile> <NotMobile> <Table.HeaderCell> - {t('home_page.analytics.rankings.creators.creator')} + <span> + {t('home_page.analytics.rankings.creators.creator')} + </span> </Table.HeaderCell> <Table.HeaderCell> - {t('home_page.analytics.rankings.creators.collections')} + <span> + {t('home_page.analytics.rankings.creators.collections')} + </span> </Table.HeaderCell> <Table.HeaderCell> - {t('home_page.analytics.rankings.creators.items_sold')} + <span> + {t('home_page.analytics.rankings.creators.items_sold')} + </span> </Table.HeaderCell> <Table.HeaderCell> - {t('home_page.analytics.rankings.creators.unique_collectors')} - <InfoTooltip - content={t( - 'home_page.analytics.rankings.creators.unique_collectors_tooltip' + <span> + {t( + 'home_page.analytics.rankings.creators.unique_collectors' )} - /> + <InfoTooltip + content={t( + 'home_page.analytics.rankings.creators.unique_collectors_tooltip' + )} + /> + </span> </Table.HeaderCell> <Table.HeaderCell> - {t( - 'home_page.analytics.rankings.creators.total_volume_sales' - )} - <InfoTooltip - content={t( - 'home_page.analytics.rankings.creators.total_volume_sales_tooltip' + <span> + {t( + 'home_page.analytics.rankings.creators.total_volume_sales' )} - /> + <InfoTooltip + content={t( + 'home_page.analytics.rankings.creators.total_volume_sales_tooltip' + )} + /> + </span> </Table.HeaderCell> </NotMobile> </Table.Row> @@ -276,46 +295,60 @@ const RankingsTable = (props: Props) => { <Table.Row> <Mobile> <Table.HeaderCell> - {t('home_page.analytics.rankings.collectors.collector')} + <span> + {t('home_page.analytics.rankings.collectors.collector')} + </span> </Table.HeaderCell> <Table.HeaderCell> - {t('home_page.analytics.rankings.collectors.total_spent')} + <span> + {t('home_page.analytics.rankings.collectors.total_spent')} + </span> </Table.HeaderCell> </Mobile> <NotMobile> <Table.HeaderCell> - {t('home_page.analytics.rankings.collectors.collector')} + <span> + {t('home_page.analytics.rankings.collectors.collector')} + </span> </Table.HeaderCell> <Table.HeaderCell> - {t('home_page.analytics.rankings.collectors.items_bought')} + <span> + {t('home_page.analytics.rankings.collectors.items_bought')} + </span> </Table.HeaderCell> <Table.HeaderCell> - {t( - 'home_page.analytics.rankings.collectors.creators_supported' - )} - <InfoTooltip - content={t( - 'home_page.analytics.rankings.collectors.creators_supported_tooltip' + <span> + {t( + 'home_page.analytics.rankings.collectors.creators_supported' )} - /> + <InfoTooltip + content={t( + 'home_page.analytics.rankings.collectors.creators_supported_tooltip' + )} + /> + </span> </Table.HeaderCell> <Table.HeaderCell> - {t( - 'home_page.analytics.rankings.collectors.unique_items_bought' - )} - <InfoTooltip - content={t( - 'home_page.analytics.rankings.collectors.unique_items_bought_tooltip' + <span> + {t( + 'home_page.analytics.rankings.collectors.unique_items_bought' )} - /> + <InfoTooltip + content={t( + 'home_page.analytics.rankings.collectors.unique_items_bought_tooltip' + )} + /> + </span> </Table.HeaderCell> <Table.HeaderCell> - {t('home_page.analytics.rankings.collectors.total_spent')} - <InfoTooltip - content={t( - 'home_page.analytics.rankings.collectors.total_spent_tooltip' - )} - /> + <span> + {t('home_page.analytics.rankings.collectors.total_spent')} + <InfoTooltip + content={t( + 'home_page.analytics.rankings.collectors.total_spent_tooltip' + )} + /> + </span> </Table.HeaderCell> </NotMobile> </Table.Row> diff --git a/webapp/src/components/RecentlySoldTable/RecentlySoldTable.css b/webapp/src/components/RecentlySoldTable/RecentlySoldTable.css index 9895fa96f..d65d07452 100644 --- a/webapp/src/components/RecentlySoldTable/RecentlySoldTable.css +++ b/webapp/src/components/RecentlySoldTable/RecentlySoldTable.css @@ -11,7 +11,7 @@ } .RecentlySoldTable .recently-sold-card .ui.table td { - padding: 16px 0; + padding: 18px; } .RecentlySoldTable .recently-sold-card table.table td { @@ -20,7 +20,6 @@ .RecentlySoldTable .recently-sold-card-tabs { display: flex; - background-color: #2f2b35; padding: 0 24px; border-top-right-radius: 10px; border-top-left-radius: 10px; @@ -28,7 +27,7 @@ } .RecentlySoldTable .ui.very.basic.table { - padding: 0 24px; + margin: 0px; } .RecentlySoldTable .dcl.tabs.fullscreen { @@ -183,10 +182,6 @@ border-bottom: none; } - .RecentlySoldTable .separator { - padding: 0 6px; - } - .RecentlySoldTable .recently-sold-sale-info { color: #858585; } @@ -250,3 +245,84 @@ position: relative; } } + +.RecentlySoldTable { + display: flex; + flex-direction: column; +} + +.RecentlySoldTable .ui.table { + font-size: 14px; + line-height: 24px; + color: white; + font-weight: normal; + font-style: normal; + font-stretch: normal; +} + +.RecentlySoldTable .ui.table th { + padding: 18px !important; + border: 1px solid var(--divider); +} + +.RecentlySoldTable .ui.table td:first-child, +.RecentlySoldTable .ui.table th:first-child { + padding: 18px !important; + border: 1px solid var(--divider); +} + +.RecentlySoldTable .ui.table tr td { + border: 1px solid var(--divider); + padding: 18px; +} + +.RecentlySoldTable .ui.table tr:last-child td:first-child { + border-bottom-left-radius: 10px; +} + +.RecentlySoldTable .ui.table tr:last-child td:last-child { + border-bottom-right-radius: 10px; +} + +.RecentlySoldTable .ui.table tr td { + border-top: 1px solid var(--divider); +} + +.RecentlySoldTable .ui.table td a { + color: var(--text); +} + +.RecentlySoldTable .emptyTable { + width: 100%; + height: 500px; + display: flex; + align-items: center; + justify-content: center; +} + +.RecentlySoldTable .ui.loader.active { + display: flex; +} + +.RecentlySoldTable .pagination { + justify-content: flex-end; + gap: 20px; + display: flex; + color: var(--secondary-text); + align-items: center; + margin-bottom: 11px; +} + +.RecentlySoldTable .ui.dropdown { + display: flex; +} + +.RecentlySoldTable .ui.loader.active { + display: flex; +} + +.RecentlySoldTable .ui.basic.table th { + font-size: 16px; + font-weight: 700; + color: white; +} diff --git a/webapp/src/components/Routes/Routes.tsx b/webapp/src/components/Routes/Routes.tsx index 0f4df88f3..6ca0255f7 100644 --- a/webapp/src/components/Routes/Routes.tsx +++ b/webapp/src/components/Routes/Routes.tsx @@ -1,3 +1,4 @@ +import { useCallback } from 'react' import { Switch, Route, Redirect, RouteComponentProps } from 'react-router-dom' import { Center, Page } from 'decentraland-ui' import Intercom from 'decentraland-dapps/dist/components/Intercom' @@ -31,6 +32,15 @@ import { Props } from './Routes.types' const Routes = ({ inMaintenance, isFavoritesEnabled }: Props) => { const APP_ID = config.get('INTERCOM_APP_ID') + const renderItemAssetPage = useCallback( + () => <AssetPage type={AssetType.ITEM} />, + [] + ) + + const renderNFTAssetPage = useCallback( + () => <AssetPage type={AssetType.NFT} />, + [] + ) if (inMaintenance) { return ( @@ -92,16 +102,8 @@ const Routes = ({ inMaintenance, isFavoritesEnabled }: Props) => { <StatusPage {...props} type={AssetType.ITEM} /> )} /> - <Route - exact - path={locations.nft()} - component={() => <AssetPage type={AssetType.NFT} />} - /> - <Route - exact - path={locations.item()} - component={() => <AssetPage type={AssetType.ITEM} />} - /> + <Route exact path={locations.nft()} component={renderNFTAssetPage} /> + <Route exact path={locations.item()} component={renderItemAssetPage} /> <Route exact path={locations.settings()} component={SettingsPage} /> <Route exact path={locations.activity()} component={ActivityPage} /> <Route exact path={locations.root()} component={HomePage} /> diff --git a/webapp/src/components/Table/TableContainer/TableContainer.css b/webapp/src/components/Table/TableContainer/TableContainer.css new file mode 100644 index 000000000..eb33714ce --- /dev/null +++ b/webapp/src/components/Table/TableContainer/TableContainer.css @@ -0,0 +1,85 @@ +.supply { + color: var(--secondary-text); +} + +.ownerButtons { + display: flex; + flex-direction: column; + gap: 8px; +} + +.filtertabsContainer { + display: flex; + border-top-right-radius: 10px; + border-top-left-radius: 10px; + align-items: center; + border-bottom: 1px solid var(--divider); + padding-left: 20px; +} + +.filtertabsContainer .dcl.tab { + padding: 0px; + height: 46px; + align-items: center; + margin-top: 22px; +} + +.tableContainer { + display: flex; + flex-direction: column; + border-radius: 12px; + margin-top: 48px; + background-color: var(--secondary); +} + +.sortByDropdown { + border-radius: 12px; + margin-right: 20px; + justify-content: space-between; + display: flex; + flex-direction: row; +} + +.filtertabsContainer .ui.dropdown { + display: flex; + align-items: center; + font-size: 13px; + position: absolute; + right: 20px; +} + +.ui.dropdown > .icon { + height: 100%; + display: flex; + align-items: center; +} + +.tabStyle { + font-size: 22px; + font-weight: 700; +} + +@media (max-width: 768px) { + :global(.ui.button.basic) { + text-align: left; + } + + .filtertabsContainer { + flex-direction: column; + padding: unset; + } + + .tabStyle { + padding-left: unset; + } + + .filtertabsContainer .ui.dropdown { + position: relative; + left: 0; + margin: 16px 12px 16px 0px; + align-self: flex-end; + } + .tableContainer { + margin-top: 16px; + } +} diff --git a/webapp/src/components/Table/TableContainer/TableContainer.tsx b/webapp/src/components/Table/TableContainer/TableContainer.tsx new file mode 100644 index 000000000..fe011ae31 --- /dev/null +++ b/webapp/src/components/Table/TableContainer/TableContainer.tsx @@ -0,0 +1,53 @@ +import { forwardRef } from 'react' +import { Dropdown, Tabs } from 'decentraland-ui' +import { Props } from './TableContianer.types' +import './TableContainer.css' + +const TableContainer = forwardRef<HTMLDivElement, Props>((props, ref) => { + const { + children, + tabsList, + activeTab, + handleTabChange, + sortbyList, + handleSortByChange, + sortBy + } = props + + return ( + <div className={'tableContainer'} ref={ref}> + <div className={'filtertabsContainer'}> + {tabsList.length > 0 ? ( + <Tabs isFullscreen> + {tabsList.map(tab => ( + <Tabs.Tab + key={tab.value} + active={activeTab === tab.value} + onClick={() => { + handleTabChange && handleTabChange(tab.value) + }} + > + <div className={'tabStyle'}>{tab.displayValue}</div> + </Tabs.Tab> + ))} + </Tabs> + ) : null} + {sortbyList && ( + <Dropdown + direction="left" + className={'sortByDropdown'} + value={sortBy} + onChange={(_event, data) => { + const value = data.value as string + handleSortByChange && handleSortByChange(value) + }} + options={sortbyList} + /> + )} + </div> + {children} + </div> + ) +}) + +export default TableContainer diff --git a/webapp/src/components/Table/TableContainer/TableContianer.types.tsx b/webapp/src/components/Table/TableContainer/TableContianer.types.tsx new file mode 100644 index 000000000..3f78e6ab5 --- /dev/null +++ b/webapp/src/components/Table/TableContainer/TableContianer.types.tsx @@ -0,0 +1,13 @@ +import { DropdownItemProps } from 'decentraland-ui' +import React, { RefObject } from 'react' + +export type Props = { + children: React.ReactNode + ref: RefObject<HTMLDivElement> + tabsList: { displayValue: string; value: string }[] + activeTab?: string + handleTabChange?: (tab: string) => void + sortbyList?: DropdownItemProps[] + handleSortByChange?: (value: string) => void + sortBy?: string +} diff --git a/webapp/src/components/Table/TableContainer/index.tsx b/webapp/src/components/Table/TableContainer/index.tsx new file mode 100644 index 000000000..911125c07 --- /dev/null +++ b/webapp/src/components/Table/TableContainer/index.tsx @@ -0,0 +1,2 @@ +import TableContainer from './TableContainer' +export default TableContainer diff --git a/webapp/src/components/Table/TableContent/TableContent.css b/webapp/src/components/Table/TableContent/TableContent.css new file mode 100644 index 000000000..624c93ea5 --- /dev/null +++ b/webapp/src/components/Table/TableContent/TableContent.css @@ -0,0 +1,113 @@ +.TableContent { + display: flex; + flex-direction: column; +} + +.TableContent .ui.table { + font-size: 14px; + line-height: 24px; + color: white; + font-weight: normal; + font-style: normal; + font-stretch: normal; +} + +.TableContent .header { + font-size: 16px; + font-weight: 700; +} + +.TableContent .ui.table th { + padding: 18px; + border: 1px solid var(--divider); +} + +.TableContent.radiusEnding .ui.table tr:last-child td:first-child { + border-bottom-left-radius: 10px; +} + +.TableContent.radiusEnding .ui.table tr:last-child td:last-child { + border-bottom-right-radius: 10px; +} + +.TableContent.emptyHeaders .ui.table th:first-child { + border-top-left-radius: 10px; +} + +.TableContent.emptyHeaders .ui.table th:last-child { + border-top-right-radius: 10px; +} + +.TableContent .ui.table td:first-child, +.TableContent .ui.table th:first-child { + padding: 18px !important; + border: 1px solid var(--divider); +} + +.TableContent .ui.table tr td { + border: 1px solid var(--divider); + padding: 18px; +} + +.TableContent .ui.table tr td { + border-top: 1px solid var(--divider); +} + +.TableContent .ui.table td a { + color: var(--text); +} + +.TableContent .emptyTable { + width: 100%; + height: 500px; + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.TableContent .pagination { + justify-content: flex-end; + gap: 20px; + display: flex; + color: var(--secondary-text); + align-items: center; + margin-bottom: 11px; +} + +.TableContent .ui.basic.table th { + font-size: 16px; + font-weight: 700; + color: white; +} + +@media (max-width: 768px) { + .TableContent .ui.basic.table tbody tr { + display: flex !important; + flex-direction: row; + align-items: center; + padding: 0; + height: 65px; + } + .TableContent .pagination { + flex-direction: column; + } + .TableContent .pagination .ui.pagination.menu { + display: flex; + flex-direction: row; + } + .TableContent .pagination .ui.pagination.menu .item + .item { + margin: 0; + } + .TableContent .pagination .ui.pagination.menu { + width: 100%; + justify-content: center; + } + .TableContent .pagination { + grid-gap: 10px; + gap: 10px; + } + .TableContent .ui.menu:after { + display: none; + } +} diff --git a/webapp/src/components/Table/TableContent/TableContent.spec.tsx b/webapp/src/components/Table/TableContent/TableContent.spec.tsx new file mode 100644 index 000000000..b756b926e --- /dev/null +++ b/webapp/src/components/Table/TableContent/TableContent.spec.tsx @@ -0,0 +1,145 @@ +import { renderWithProviders } from '../../../utils/tests' +import { DataTableType } from './TableContent.types' +import TableContent from './TableContent' +import { within } from '@testing-library/react' +import { ROWS_PER_PAGE } from '../../AssetPage/OwnersTable/OwnersTable' + +describe('Table content', () => { + let data: DataTableType[] = [ + { first_header: 'content header 1', second_header: <div>second text</div> }, + { + first_header: 'second content header 1', + second_header: <div>second text header 2</div> + } + ] + + afterEach(() => { + jest.resetAllMocks() + }) + + describe('Empty table', () => { + it('should render the empty table message', async () => { + const { getByText } = renderWithProviders( + <TableContent + data={[]} + isLoading={false} + empty={() => <div>empty table</div>} + total={0} + /> + ) + expect(getByText('empty table')).toBeInTheDocument() + }) + }) + + describe('Should render the table correctly', () => { + it('should render the table', async () => { + const screen = renderWithProviders( + <TableContent + data={data} + isLoading={false} + empty={() => <div>empty table</div>} + total={0} + /> + ) + + const { getByTestId } = screen + + expect(getByTestId('table-content')).not.toBe(null) + }) + + it('should render the headers', async () => { + const screen = renderWithProviders( + <TableContent + data={data} + isLoading={false} + empty={() => <div>empty table</div>} + total={0} + /> + ) + + const { getByText } = screen + + expect(getByText('first_header')).not.toBe(null) + expect(getByText('second_header')).not.toBe(null) + }) + + it('should render the content', async () => { + const screen = renderWithProviders( + <TableContent + data={data} + isLoading={false} + empty={() => <div>empty table</div>} + total={0} + /> + ) + + const { getByText } = screen + + expect(getByText('content header 1')).not.toBe(null) + expect(getByText('second text')).not.toBe(null) + expect(getByText('second content header 1')).not.toBe(null) + expect(getByText('second text header 2')).not.toBe(null) + }) + }) + + describe('Should render the loader if its loading', () => { + it('should render the loader', async () => { + const screen = renderWithProviders( + <TableContent + data={data} + isLoading={true} + empty={() => <div>empty table</div>} + total={0} + /> + ) + + expect(screen.getByTestId('loader')).toBeInTheDocument() + }) + }) + + describe('Pagination', () => { + describe('Should have pagination', () => { + it('should render the pagination correctly', async () => { + data = Array(ROWS_PER_PAGE).fill({ + first_header: 'contetnt 1', + second_header: 'content 2' + }) + + const screen = renderWithProviders( + <TableContent + data={data} + isLoading={false} + empty={() => <div>empty table</div>} + total={data.length} + totalPages={2} + /> + ) + + const { getByRole } = screen + + const navigation = getByRole('navigation') + + expect(within(navigation).getByText('1')).toBeInTheDocument() + expect(within(navigation).getByText('2')).toBeInTheDocument() + }) + }) + + describe('Should not have pagination', () => { + it('should not render pagination as there is no need', async () => { + const screen = renderWithProviders( + <TableContent + data={data} + isLoading={false} + empty={() => <div>empty table</div>} + total={data.length} + totalPages={1} + /> + ) + + const { queryByRole } = screen + + expect(queryByRole('navigation')).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/webapp/src/components/Table/TableContent/TableContent.tsx b/webapp/src/components/Table/TableContent/TableContent.tsx new file mode 100644 index 000000000..df9269ea5 --- /dev/null +++ b/webapp/src/components/Table/TableContent/TableContent.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { Loader, Pagination, Table } from 'decentraland-ui' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { ROWS_PER_PAGE } from '../../AssetPage/OwnersTable/OwnersTable' +import { Props } from './TableContent.types' +import './TableContent.css' + +const TableContent = (props: Props) => { + const { + empty, + data, + isLoading, + totalPages, + activePage = 1, + setPage, + total, + rowsPerPage = ROWS_PER_PAGE, + hasHeaders = false + } = props + + const headers = data.length > 0 ? Object.keys(data[0]) : null + const hasPagination = totalPages && totalPages > 1 + + return ( + <div + className={`TableContent ${!hasPagination ? 'radiusEnding' : ''} ${ + !hasHeaders ? 'emptyHeaders' : '' + }`} + > + {isLoading ? ( + <div className={'emptyTable'}> + <Loader active data-testid="loader" /> + </div> + ) : headers ? ( + <Table basic="very" data-testid="table-content"> + <Table.Body className={isLoading ? 'is-loading' : ''}> + <Table.Row> + {headers.map(header => ( + <Table.HeaderCell key={header}> + <span>{header}</span> + </Table.HeaderCell> + ))} + </Table.Row> + {data?.map((data: any, index) => ( + <Table.Row key={index}> + {headers.map((header: string) => ( + <Table.Cell key={header}>{data[header]}</Table.Cell> + ))} + </Table.Row> + ))} + </Table.Body> + </Table> + ) : ( + empty() + )} + {hasPagination ? ( + <div className="pagination"> + {`${t('global.showing')} ${activePage}-${activePage * + rowsPerPage} ${t('global.of')} ${total}`} + <Pagination + siblingRange={0} + activePage={activePage} + totalPages={totalPages} + onPageChange={(_event, props) => + setPage && setPage(+props.activePage!) + } + firstItem={null} + lastItem={null} + /> + </div> + ) : null} + </div> + ) +} + +export default React.memo(TableContent) diff --git a/webapp/src/components/Table/TableContent/TableContent.types.tsx b/webapp/src/components/Table/TableContent/TableContent.types.tsx new file mode 100644 index 000000000..7057c19c5 --- /dev/null +++ b/webapp/src/components/Table/TableContent/TableContent.types.tsx @@ -0,0 +1,18 @@ +import React from 'react' + +export type Props = { + activePage?: number + data: DataTableType[] + isLoading: boolean + setPage?: (page: number) => void + totalPages?: number | null + empty: () => void + total: number | null + rowsPerPage?: number + mobileTableBody?: React.ReactNode + hasHeaders?: boolean +} + +export type DataTableType = { + [key: string]: React.ReactNode +} diff --git a/webapp/src/components/Table/TableContent/index.tsx b/webapp/src/components/Table/TableContent/index.tsx new file mode 100644 index 000000000..d7b7881cc --- /dev/null +++ b/webapp/src/components/Table/TableContent/index.tsx @@ -0,0 +1,2 @@ +import TableContent from './TableContent' +export { TableContent } diff --git a/webapp/src/components/Vendor/NFTSidebar/NFTSidebar.tsx b/webapp/src/components/Vendor/NFTSidebar/NFTSidebar.tsx index 0bab40414..bcf24d639 100644 --- a/webapp/src/components/Vendor/NFTSidebar/NFTSidebar.tsx +++ b/webapp/src/components/Vendor/NFTSidebar/NFTSidebar.tsx @@ -2,6 +2,10 @@ import React, { useCallback } from 'react' import { Sections } from '../../../modules/vendor/routing/types' import { Section as DecentralandSection } from '../../../modules/vendor/decentraland/routing/types' +import { + getMarketAssetTypeFromCategory, + getCategoryFromSection +} from '../../../modules/routing/search' import { VendorName } from '../../../modules/vendor/types' import { NFTSidebar as DecentralandNFTSidebar } from '../decentraland/NFTSidebar' import { Props } from './NFTSidebar.types' @@ -11,7 +15,13 @@ const NFTSidebar = (props: Props) => { const handleOnBrowse = useCallback( (section: string) => { - onBrowse({ section }) + const category = getCategoryFromSection(section) + onBrowse({ + section, + assetType: category + ? getMarketAssetTypeFromCategory(category) + : undefined + }) }, [onBrowse] ) diff --git a/webapp/src/components/Vendor/decentraland/NFTFilters/NFTFilters.tsx b/webapp/src/components/Vendor/decentraland/NFTFilters/NFTFilters.tsx index c1ead22a1..90d060538 100644 --- a/webapp/src/components/Vendor/decentraland/NFTFilters/NFTFilters.tsx +++ b/webapp/src/components/Vendor/decentraland/NFTFilters/NFTFilters.tsx @@ -200,16 +200,16 @@ const NFTFilters = (props: Props) => { const searchPlaceholder = isMap ? t('nft_filters.search_land') : count === undefined - ? t('global.loading') + '...' - : t('nft_filters.search', { + ? t('global.loading') + '...' + : t('nft_filters.search', { suffix: count < MAX_QUERY_SIZE ? t('nft_filters.results', { - count: count.toLocaleString() - }) + count: count.toLocaleString() + }) : t('nft_filters.more_than_results', { - count: count.toLocaleString() - }) + count: count.toLocaleString() + }) }) const toggleBoxI18nKey = @@ -302,8 +302,9 @@ const NFTFilters = (props: Props) => { > <div className="label">{t('nft_filters.filter')}</div> <div - className={`open-filters ${showFiltersMenu || appliedFilters.length > 0 ? 'active' : '' - }`} + className={`open-filters ${ + showFiltersMenu || appliedFilters.length > 0 ? 'active' : '' + }`} /> </div> </Responsive> diff --git a/webapp/src/components/Vendor/decentraland/NFTSidebar/NFTSidebar.css b/webapp/src/components/Vendor/decentraland/NFTSidebar/NFTSidebar.css index 56bf61ef4..ad817114a 100644 --- a/webapp/src/components/Vendor/decentraland/NFTSidebar/NFTSidebar.css +++ b/webapp/src/components/Vendor/decentraland/NFTSidebar/NFTSidebar.css @@ -1,3 +1,9 @@ +.NFTSidebar { + /* 64px navbar + 65 navigation + 12 margin + 56px footer = 319 */ + height: calc(100vh - 197px); + overflow-y: scroll; +} + .NFTSidebar > .ui.header.sub { margin-top: 3px; margin-bottom: 22px; diff --git a/webapp/src/config/env/dev.json b/webapp/src/config/env/dev.json index 4c8eb5cb5..a5788b6a7 100644 --- a/webapp/src/config/env/dev.json +++ b/webapp/src/config/env/dev.json @@ -1,19 +1,19 @@ { - "NETWORK": "goerli", - "CHAIN_ID": "5", - "NFT_SERVER_URL": "https://nft-api.decentraland.zone/v1", - "BUILDER_SERVER_URL": "https://builder-api.decentraland.zone/v1", - "ATLAS_SERVER_URL": "https://api.decentraland.zone", - "PEER_URL": "https://peer-ap1.decentraland.zone", - "SIGNATURES_SERVER_URL": "https://signatures-api.decentraland.zone/v1", - "MARKETPLACE_FAVORITES_SERVER_URL": "https://marketplace-favorites-api.decentraland.zone/v1", + "NETWORK": "mainnet", + "CHAIN_ID": "1", + "NFT_SERVER_URL": "https://nft-api.decentraland.today/v1", + "BUILDER_SERVER_URL": "https://builder-api.decentraland.today/v1", + "ATLAS_SERVER_URL": "https://api.decentraland.today", + "PEER_URL": "https://peer.decentraland.org", + "SIGNATURES_SERVER_URL": "https://signatures-api.decentraland.today/v1", + "MARKETPLACE_FAVORITES_SERVER_URL": "https://marketplace-favorites-api.decentraland.today/v1", "DEFAULT_FAVORITES_LIST_ID": "70ab6873-4a03-4eb2-b331-4b8be0e0b8af", "DECENTRALAND_BLOG": "https://decentraland.org/blog", "REFRESH_SIGNATURES_DELAY": "10000", - "TRANSACTIONS_API_URL": "https://transactions-api.decentraland.zone/v1", - "TRANSAK_API_URL": "https://api-stg.transak.com/partners/api", - "TRANSAK_KEY": "273ac855-3472-40eb-8ee8-840e317f98e6", - "TRANSAK_ENV": "STAGING", + "TRANSACTIONS_API_URL": "https://transactions-api.decentraland.today/v1", + "TRANSAK_API_URL": "https://api.transak.com/partners/api", + "TRANSAK_KEY": "cb87de5f-add2-48a1-bab4-6a9409b811b1", + "TRANSAK_ENV": "PRODUCTION", "TRANSAK_POLLING_DELAY": "6000", "TRANSAK_PUSHER_APP_KEY": "1d9ffac87de599c61283", "TRANSAK_PUSHER_APP_CLUSTER": "ap2", @@ -21,18 +21,18 @@ "MOON_PAY_API_KEY": "pk_test_WVS2xVSCnSnR7A7qIueWwCtcKnrGb55p", "MOON_PAY_POLLING_DELAY": "6000", "MOON_PAY_WIDGET_URL": "https://buy-staging.moonpay.io", - "BUILDER_URL": "https://builder.decentraland.zone", + "BUILDER_URL": "https://builder.decentraland.today", "COINGECKO_API_URL": "https://api.coingecko.com/api/v3", "DOCS_URL": "https://docs.decentraland.org", "MARKETPLACE_ADAPTER_FEE_PER_MILLION": "25000", "MAX_PRICE_INCREASE_PERCENTAGE": "15", - "MANA_ADDRESS": "0x2a8fd99c19271f4f04b1b7b9c4f7cf264b626edb", - "CONVERTER_ADDRESS": "0x2782eb28Dcb1eF4E7632273cd4e347e130Ce4646", + "MANA_ADDRESS": "0x0f5d2fb29fb7d3cfee444a200298f468908cc942", + "CONVERTER_ADDRESS": "0x2859581da59bd4e16a866dd06b461b76d8e489a4", "CONVERTER_EXCHANGE": "uniswap_v2", "INTERCOM_APP_ID": "z0h94kay", - "SEGMENT_API_KEY": "gMnkewUBzA6J879kAtMvp2WhohEk36uy", + "SEGMENT_API_KEY": "WijgHCmOGJjV04XBGghZGEadMD4454R3", "DISCORD_URL": "https://dcl.gg/discord", "ROLLBAR_ACCESS_TOKEN": "ad890a7699424114a771373ee60720ac", - "ENVIRONMENT": "development", + "ENVIRONMENT": "staging", "MIN_SALE_VALUE_IN_WEI": "1000000000000000000" } diff --git a/webapp/src/images/calendar.png b/webapp/src/images/calendar.png new file mode 100644 index 000000000..d0f0da97f Binary files /dev/null and b/webapp/src/images/calendar.png differ diff --git a/webapp/src/images/clock.png b/webapp/src/images/clock.png new file mode 100644 index 000000000..13c98398b Binary files /dev/null and b/webapp/src/images/clock.png differ diff --git a/webapp/src/images/emptyOwners.png b/webapp/src/images/emptyOwners.png new file mode 100644 index 000000000..1d7785b37 Binary files /dev/null and b/webapp/src/images/emptyOwners.png differ diff --git a/webapp/src/images/expiration.png b/webapp/src/images/expiration.png new file mode 100644 index 000000000..c2b9f2272 Binary files /dev/null and b/webapp/src/images/expiration.png differ diff --git a/webapp/src/images/iconListings.png b/webapp/src/images/iconListings.png new file mode 100644 index 000000000..369eda6e5 Binary files /dev/null and b/webapp/src/images/iconListings.png differ diff --git a/webapp/src/images/infoIcon.png b/webapp/src/images/infoIcon.png new file mode 100644 index 000000000..1662f62c5 Binary files /dev/null and b/webapp/src/images/infoIcon.png differ diff --git a/webapp/src/images/makeOffer.png b/webapp/src/images/makeOffer.png new file mode 100644 index 000000000..6dfdae73b Binary files /dev/null and b/webapp/src/images/makeOffer.png differ diff --git a/webapp/src/images/minting.png b/webapp/src/images/minting.png new file mode 100644 index 000000000..9204793d4 Binary files /dev/null and b/webapp/src/images/minting.png differ diff --git a/webapp/src/images/noListings.png b/webapp/src/images/noListings.png new file mode 100644 index 000000000..d7cb64941 Binary files /dev/null and b/webapp/src/images/noListings.png differ diff --git a/webapp/src/images/noResults.svg b/webapp/src/images/noResults.svg new file mode 100644 index 000000000..cd96c03dc --- /dev/null +++ b/webapp/src/images/noResults.svg @@ -0,0 +1,4 @@ +<svg width="86" height="86" viewBox="0 0 86 86" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M76.2017 83.675C73.5429 83.5486 71.0816 82.2358 69.4987 80.0993C64.0521 74.6406 51.4897 62.0501 46.6223 57.1955C41.2605 60.1558 35.085 61.3078 29.0149 60.48C22.9445 59.6522 17.3038 56.8892 12.9332 52.6017C-7.20388 32.7319 12.3499 -1.54586 39.673 5.61649C56.8656 8.91551 66.5482 31.823 57.2448 46.5814L81.5295 70.8321C82.9143 72.2533 83.6846 74.1612 83.6754 76.1441C83.6667 78.1273 82.8784 80.0272 81.4801 81.4357C80.0824 82.8442 78.1872 83.6483 76.2017 83.675ZM49.5422 55.3315C55.4465 61.2242 67.3311 73.1461 73.2647 79.0877C74.319 80.1093 75.8363 80.4997 77.2537 80.1122C78.6717 79.7247 79.7784 78.6184 80.1652 77.203C80.5521 75.7869 80.1611 74.273 79.137 73.2201L55.3609 49.4802C53.7407 51.724 51.778 53.698 49.5422 55.3315ZM32.5087 8.09532C11.2682 7.87953 -0.350647 35.1914 15.3459 50.2334C25.5832 61.2946 46.0697 59.0195 53.6808 45.8936L53.6831 45.8896L53.6872 45.8844C64.2457 30.1533 51.5078 7.35874 32.5087 8.09532ZM32.6406 53.6935C17.008 54.0955 6.27546 35.7008 14.578 22.4156C22.6834 7.2637 46.0588 8.65578 52.1754 24.9831C54.8202 31.4436 54.0488 38.798 50.1202 44.5708C46.1921 50.3439 39.6296 53.7701 32.6406 53.6935ZM32.4983 15.1433C27.8522 15.2216 23.425 17.1266 20.1778 20.446C16.9305 23.7653 15.1262 28.2303 15.1558 32.8704C15.186 37.5106 17.0484 41.9512 20.3381 45.2282C27.5384 53.1013 42.4203 51.462 47.6535 42.1787C55.3273 31.042 45.9595 14.4765 32.4983 15.1433Z" fill="white"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M38.8323 26.248C37.9235 25.3392 36.4546 25.3392 35.5457 26.248L32.5404 29.2533L29.535 26.248C28.6262 25.3392 27.1573 25.3392 26.2484 26.248C25.3396 27.1568 25.3396 28.6257 26.2484 29.5346L29.2538 32.5399L26.2484 35.5453C25.3396 36.4541 25.3396 37.923 26.2484 38.8319C26.7017 39.2851 27.2967 39.5129 27.8917 39.5129C28.4868 39.5129 29.0818 39.2851 29.535 38.8319L32.5404 35.8265L35.5457 38.8319C35.999 39.2851 36.594 39.5129 37.189 39.5129C37.7841 39.5129 38.3791 39.2851 38.8323 38.8319C39.7412 37.923 39.7412 36.4541 38.8323 35.5453L35.827 32.5399L38.8323 29.5346C39.7412 28.6257 39.7412 27.1568 38.8323 26.248ZM32.5404 51.1345C22.2878 51.1345 13.9458 42.7925 13.9458 32.5399C13.9458 22.2873 22.2878 13.9453 32.5404 13.9453C42.793 13.9453 51.135 22.2873 51.135 32.5399C51.135 42.7925 42.793 51.1345 32.5404 51.1345Z" fill="white"/> +</svg> diff --git a/webapp/src/images/onBack.png b/webapp/src/images/onBack.png new file mode 100644 index 000000000..86592a57b Binary files /dev/null and b/webapp/src/images/onBack.png differ diff --git a/webapp/src/images/trash.png b/webapp/src/images/trash.png new file mode 100644 index 000000000..453fbc7a2 Binary files /dev/null and b/webapp/src/images/trash.png differ diff --git a/webapp/src/index.css b/webapp/src/index.css index 626c17c85..c4ad344cb 100644 --- a/webapp/src/index.css +++ b/webapp/src/index.css @@ -57,13 +57,33 @@ code { padding: 0; } +.Profile.inline .dcl.blockie { + border-radius: 50%; +} + @media only screen and (min-width: 768px) { .dcl.toast .toast-info .body .list-flow-toast { width: 350px; } + .dcl.footer.ui.container { + position: fixed; + bottom: 0; + background-color: var(--background); + } + + .Navigation, + .dcl.navbar.fullscreen, + .dcl.page, + .dcl.footer { + margin-top: 0; + } } @media (max-width: 767px) { + .dcl.footer.ui.container { + margin-top: 0; + } + .Navigation, .dcl.navbar.fullscreen, .dcl.page { diff --git a/webapp/src/lib/date.ts b/webapp/src/lib/date.ts index 775d1afe0..6258637d1 100644 --- a/webapp/src/lib/date.ts +++ b/webapp/src/lib/date.ts @@ -3,6 +3,7 @@ import formatDistanceToNowI18N from 'date-fns/formatDistanceToNow' import en from 'date-fns/locale/en-US' import es from 'date-fns/locale/es' import zh from 'date-fns/locale/zh-CN' +import format from 'date-fns/format' const locales: Record<string, Locale> = { en, @@ -26,3 +27,11 @@ export function formatDistanceToNow( return formatDistanceToNowI18N(date, options) } + +export function getDateAndMonthName(date: number | Date) { + const locale = locales[getCurrentLocale().locale] + + return `${format(new Date(date), 'LLLL', { locale: locale })} ${new Date( + date + ).getDate()}` +} diff --git a/webapp/src/modules/asset/utils.ts b/webapp/src/modules/asset/utils.ts index 4992a743b..cf8184abf 100644 --- a/webapp/src/modules/asset/utils.ts +++ b/webapp/src/modules/asset/utils.ts @@ -109,6 +109,10 @@ export function isNFT(asset: Asset): asset is NFT { return 'tokenId' in asset } +export function isCatalogItem(asset: Asset): boolean { + return 'minPrice' in asset +} + export function isWearableOrEmote(asset: Asset): boolean { const categories: Array<typeof asset.category> = [ NFTCategory.WEARABLE, diff --git a/webapp/src/modules/collection/sagas.spec.ts b/webapp/src/modules/collection/sagas.spec.ts index 439557f91..8ee9daa93 100644 --- a/webapp/src/modules/collection/sagas.spec.ts +++ b/webapp/src/modules/collection/sagas.spec.ts @@ -95,13 +95,13 @@ describe('when handling a fetch collections request', () => { // Fetches items for contract address 2 because sizes are different .put( fetchItemsRequest({ - filters: { contracts: [contractAddress2], first: size } + filters: { contractAddresses: [contractAddress2], first: size } }) ) // Fetches items for contract address 3 because there are no items for that collection stored .put( fetchItemsRequest({ - filters: { contracts: [contractAddress3], first: size } + filters: { contractAddresses: [contractAddress3], first: size } }) ) .dispatch(fetchCollectionsRequest(filters, true)) diff --git a/webapp/src/modules/collection/sagas.ts b/webapp/src/modules/collection/sagas.ts index 9521ff8f8..6b4a7c330 100644 --- a/webapp/src/modules/collection/sagas.ts +++ b/webapp/src/modules/collection/sagas.ts @@ -50,7 +50,7 @@ export function* handleFetchCollectionsRequest( fetchItemsRequest({ filters: { first: collection.size, - contracts: [collection.contractAddress] + contractAddresses: [collection.contractAddress] } }) ) @@ -100,7 +100,7 @@ export function* handleFetchSingleCollectionRequest( fetchItemsRequest({ filters: { first: collection.size, - contracts: [collection.contractAddress] + contractAddresses: [collection.contractAddress] } }) ) diff --git a/webapp/src/modules/favorites/sagas.spec.ts b/webapp/src/modules/favorites/sagas.spec.ts index 0d8c80498..43534dc19 100644 --- a/webapp/src/modules/favorites/sagas.spec.ts +++ b/webapp/src/modules/favorites/sagas.spec.ts @@ -11,6 +11,7 @@ import { ItemBrowseOptions } from '../item/types' import { View } from '../ui/types' import { getIdentity as getAccountIdentity } from '../identity/utils' import { ItemAPI } from '../vendor/decentraland/item/api' +import { CatalogAPI } from '../vendor/decentraland/catalog/api' import { cancelPickItemAsFavorite, deleteListFailure, @@ -336,10 +337,13 @@ describe('when handling the request for fetching favorited items', () => { matchers.call.fn(FavoritesAPI.prototype.getPicksByList), Promise.resolve({ results: favoritedItemIds, total }) ], - [matchers.call.fn(ItemAPI.prototype.get), Promise.reject(error)] + [ + matchers.call.fn(CatalogAPI.prototype.get), + Promise.reject(error) + ] ]) .call.like({ - fn: ItemAPI.prototype.get, + fn: CatalogAPI.prototype.get, args: [ { ...options.filters, @@ -372,7 +376,7 @@ describe('when handling the request for fetching favorited items', () => { Promise.resolve({ results: favoritedItemIds, total }) ], [ - matchers.call.fn(ItemAPI.prototype.get), + matchers.call.fn(CatalogAPI.prototype.get), Promise.resolve({ data: [item] }) ] ]) @@ -381,7 +385,7 @@ describe('when handling the request for fetching favorited items', () => { args: [listId, options.filters] }) .call.like({ - fn: ItemAPI.prototype.get, + fn: CatalogAPI.prototype.get, args: [ { ...options.filters, diff --git a/webapp/src/modules/favorites/sagas.ts b/webapp/src/modules/favorites/sagas.ts index 7bd11ec8c..0a57f8b9c 100644 --- a/webapp/src/modules/favorites/sagas.ts +++ b/webapp/src/modules/favorites/sagas.ts @@ -7,6 +7,7 @@ import { CONNECT_WALLET_SUCCESS } from 'decentraland-dapps/dist/modules/wallet/actions' import { isErrorWithMessage } from '../../lib/error' +import { config } from '../../config' import { ItemBrowseOptions } from '../item/types' import { closeModal, @@ -19,10 +20,9 @@ import { MARKETPLACE_FAVORITES_SERVER_URL } from '../vendor/decentraland/favorites/api' import { getIdentity as getAccountIdentity } from '../identity/utils' +import { CatalogAPI } from '../vendor/decentraland/catalog/api' import { retryParams } from '../vendor/decentraland/utils' import { getAddress } from '../wallet/selectors' -import { ItemAPI } from '../vendor/decentraland/item/api' -import { NFT_SERVER_URL } from '../vendor/decentraland' import { cancelPickItemAsFavorite, fetchFavoritedItemsFailure, @@ -57,18 +57,19 @@ import { import { getListId } from './selectors' import { FavoritedItems, List } from './types' -export function* favoritesSaga(getIdentity: () => AuthIdentity | undefined) { - const itemAPI = new ItemAPI(NFT_SERVER_URL, { - retries: retryParams.attempts, - retryDelay: retryParams.delay, - identity: getIdentity - }) +export const NFT_SERVER_URL = config.get('NFT_SERVER_URL')! - const favoritesAPI = new FavoritesAPI(MARKETPLACE_FAVORITES_SERVER_URL, { +export function* favoritesSaga(getIdentity: () => AuthIdentity | undefined) { + const API_OPTS = { retries: retryParams.attempts, retryDelay: retryParams.delay, identity: getIdentity - }) + } + const favoritesAPI = new FavoritesAPI( + MARKETPLACE_FAVORITES_SERVER_URL, + API_OPTS + ) + const catalogAPI = new CatalogAPI(NFT_SERVER_URL, API_OPTS) yield takeEvery( PICK_ITEM_AS_FAVORITE_REQUEST, @@ -196,18 +197,20 @@ export function* favoritesSaga(getIdentity: () => AuthIdentity | undefined) { favoritedItem.createdAt ]) ) + const ids = results.map(({ itemId }) => itemId) + const optionsFilters = { + first: results.length, + ids + } const options: ItemBrowseOptions = { ...action.payload.options, - filters: { - first: results.length, - ids: results.map(({ itemId }) => itemId) - } + filters: optionsFilters } if (results.length > 0) { const result: { data: Item[] } = yield call( - [itemAPI, 'get'], - options.filters + [catalogAPI, 'get'], + optionsFilters ) items = result.data } diff --git a/webapp/src/modules/item/reducer.spec.ts b/webapp/src/modules/item/reducer.spec.ts index 8e29d60aa..4e5b3e773 100644 --- a/webapp/src/modules/item/reducer.spec.ts +++ b/webapp/src/modules/item/reducer.spec.ts @@ -145,24 +145,48 @@ failureActions.forEach(action => { describe('when reducing the successful action of fetching items', () => { const requestAction = fetchItemsRequest(itemBrowseOptions) - const successAction = fetchItemsSuccess( - [item], - 1, - itemBrowseOptions, - 223423423 - ) + let successAction = fetchItemsSuccess([item], 1, itemBrowseOptions, 223423423) - const initialState = { + let initialState = { ...INITIAL_STATE, data: { anotherId: anotherItem }, loading: loadingReducer([], requestAction) } - it('should return a state with the the loaded items and the loading state cleared', () => { - expect(itemReducer(initialState, successAction)).toEqual({ - ...INITIAL_STATE, - loading: [], - data: { ...initialState.data, [item.id]: item } + describe('and the fetched items are not in the state', () => { + it('should return a state with the the loaded items and the loading state cleared', () => { + expect(itemReducer(initialState, successAction)).toEqual({ + ...INITIAL_STATE, + loading: [], + data: { ...initialState.data, [item.id]: item } + }) + }) + }) + + describe('and the fetched items are in the state', () => { + let newItemData: Item + beforeEach(() => { + newItemData = { + minPrice: '1234' + } as Item + successAction = fetchItemsSuccess( + [{ ...item, ...newItemData }], + 1, + itemBrowseOptions, + 223423423 + ) + initialState = { + ...INITIAL_STATE, + data: { anotherId: anotherItem, [item.id]: item }, + loading: loadingReducer([], requestAction) + } + }) + it('should return a state with the old items merged with the new fetched items and the loading state cleared', () => { + expect(itemReducer(initialState, successAction)).toEqual({ + ...INITIAL_STATE, + loading: [], + data: { ...initialState.data, [item.id]: { ...item, ...newItemData } } + }) }) }) }) @@ -207,19 +231,43 @@ describe.each([ describe('when reducing the successful action of fetching an item', () => { const requestAction = fetchItemRequest(item.contractAddress, item.itemId) - const successAction = fetchItemSuccess(item) + let successAction = fetchItemSuccess(item) - const initialState = { + let initialState = { ...INITIAL_STATE, data: { anotherId: anotherItem }, loading: loadingReducer([], requestAction) } - it('should return a state with the the loaded items with the fetched item and the loading state cleared', () => { - expect(itemReducer(initialState, successAction)).toEqual({ - ...INITIAL_STATE, - loading: [], - data: { ...initialState.data, [item.id]: item } + describe('and the fetched item is not in the state', () => { + it('should return a state with the loaded items, the fetched item and the loading state cleared', () => { + expect(itemReducer(initialState, successAction)).toEqual({ + ...INITIAL_STATE, + loading: [], + data: { ...initialState.data, [item.id]: item } + }) + }) + }) + + describe('and the item is already in the state', () => { + let newItemData: Item + beforeEach(() => { + newItemData = { + minPrice: '1234' + } as Item + initialState = { + ...INITIAL_STATE, + data: { anotherId: anotherItem, [item.id]: item }, + loading: loadingReducer([], requestAction) + } + successAction = fetchItemSuccess({ ...item, ...newItemData }) + }) + it('should return a state containing the old items merged with the new fetched item and the loading state cleared', () => { + expect(itemReducer(initialState, successAction)).toEqual({ + ...INITIAL_STATE, + loading: [], + data: { ...initialState.data, [item.id]: { ...item, ...newItemData } } + }) }) }) }) diff --git a/webapp/src/modules/item/reducer.ts b/webapp/src/modules/item/reducer.ts index fde713308..5a4dac225 100644 --- a/webapp/src/modules/item/reducer.ts +++ b/webapp/src/modules/item/reducer.ts @@ -1,3 +1,4 @@ +import isEqual from 'lodash/isEqual' import { Item } from '@dcl/schemas' import { loadingReducer, @@ -97,7 +98,9 @@ export function itemReducer( data: { ...state.data, ...items.reduce((obj, item) => { - obj[item.id] = item + if (!state.data[item.id] || !isEqual(state.data[item.id], item)) { + obj[item.id] = { ...state.data[item.id], ...item } + } return obj }, {} as Record<string, Item>) }, @@ -111,7 +114,7 @@ export function itemReducer( loading: loadingReducer(state.loading, action), data: { ...state.data, - [item.id]: { ...item } + [item.id]: { ...state.data[item.id], ...item } }, error: null } diff --git a/webapp/src/modules/item/sagas.spec.ts b/webapp/src/modules/item/sagas.spec.ts index f2b9657e2..5b1431a8e 100644 --- a/webapp/src/modules/item/sagas.spec.ts +++ b/webapp/src/modules/item/sagas.spec.ts @@ -14,6 +14,7 @@ import { NetworkGatewayType } from 'decentraland-ui' import { getWallet } from '../wallet/selectors' import { View } from '../ui/types' import { ItemAPI } from '../vendor/decentraland/item/api' +import { CatalogAPI } from '../vendor/decentraland/catalog/api' import { closeModal, openModal } from '../modal/actions' import { buyAssetWithCard, @@ -370,7 +371,7 @@ describe('when handling the fetch items request action', () => { it('should dispatch a successful action with the fetched items', () => { return expectSaga(itemSaga, getIdentity) .provide([ - [matchers.call.fn(ItemAPI.prototype.get), fetchResult], + [matchers.call.fn(CatalogAPI.prototype.get), fetchResult], [matchers.call.fn(waitForWalletConnectionIfConnecting), undefined] ]) .put( @@ -390,7 +391,7 @@ describe('when handling the fetch items request action', () => { it('should dispatching a failing action with the error and the options', () => { return expectSaga(itemSaga, getIdentity) .provide([ - [matchers.call.fn(ItemAPI.prototype.get), Promise.reject(anError)], + [matchers.call.fn(CatalogAPI.prototype.get), Promise.reject(anError)], [matchers.call.fn(waitForWalletConnectionIfConnecting), undefined] ]) .put(fetchItemsFailure(anError.message, itemBrowseOptions)) @@ -453,6 +454,7 @@ describe('when handling the fetch trending items request action', () => { return expectSaga(itemSaga, getIdentity) .provide([ [matchers.call.fn(ItemAPI.prototype.getTrendings), fetchResult], + [matchers.call.fn(CatalogAPI.prototype.get), fetchResult], [matchers.call.fn(waitForWalletConnectionIfConnecting), undefined] ]) .put(fetchTrendingItemsSuccess(fetchResult.data)) diff --git a/webapp/src/modules/item/sagas.ts b/webapp/src/modules/item/sagas.ts index 929c3eaed..517c1df09 100644 --- a/webapp/src/modules/item/sagas.ts +++ b/webapp/src/modules/item/sagas.ts @@ -16,8 +16,10 @@ import { config } from '../../config' import { ItemAPI } from '../vendor/decentraland/item/api' import { getWallet } from '../wallet/selectors' import { buyAssetWithCard } from '../asset/utils' +import { isCatalogView } from '../routing/utils' import { waitForWalletConnectionIfConnecting } from '../wallet/utils' import { retryParams } from '../vendor/decentraland/utils' +import { CatalogAPI } from '../vendor/decentraland/catalog/api' import { buyItemFailure, BuyItemRequestAction, @@ -51,11 +53,13 @@ import { getItem } from './utils' export const NFT_SERVER_URL = config.get('NFT_SERVER_URL')! export function* itemSaga(getIdentity: () => AuthIdentity | undefined) { - const itemAPI = new ItemAPI(NFT_SERVER_URL, { + const API_OPTS = { retries: retryParams.attempts, retryDelay: retryParams.delay, identity: getIdentity - }) + } + const itemAPI = new ItemAPI(NFT_SERVER_URL, API_OPTS) + const catalogAPI = new CatalogAPI(NFT_SERVER_URL, API_OPTS) yield takeEvery(FETCH_ITEMS_REQUEST, handleFetchItemsRequest) yield takeEvery(FETCH_TRENDING_ITEMS_REQUEST, handleFetchTrendingItemsRequest) @@ -77,7 +81,14 @@ export function* itemSaga(getIdentity: () => AuthIdentity | undefined) { [itemAPI, 'getTrendings'], size ) - yield put(fetchTrendingItemsSuccess(data)) + const ids = data.map(item => item.id) + const { data: itemData }: { data: Item[]; total: number } = yield call( + [catalogAPI, 'get'], + { + ids + } + ) + yield put(fetchTrendingItemsSuccess(itemData)) } catch (error) { yield put( fetchTrendingItemsFailure( @@ -88,17 +99,17 @@ export function* itemSaga(getIdentity: () => AuthIdentity | undefined) { } function* handleFetchItemsRequest(action: FetchItemsRequestAction) { - const { filters } = action.payload + const { filters, view } = action.payload // If the wallet is getting connected, wait until it finishes to fetch the items so it can fetch them with authentication yield call(waitForWalletConnectionIfConnecting) try { + const api = isCatalogView(view) ? catalogAPI : itemAPI const { data, total }: { data: Item[]; total: number } = yield call( - [itemAPI, 'get'], + [api, 'get'], filters ) - yield put(fetchItemsSuccess(data, total, action.payload, Date.now())) } catch (error) { yield put( diff --git a/webapp/src/modules/item/selectors.spec.ts b/webapp/src/modules/item/selectors.spec.ts index c6925f32b..31edd3a9d 100644 --- a/webapp/src/modules/item/selectors.spec.ts +++ b/webapp/src/modules/item/selectors.spec.ts @@ -113,7 +113,7 @@ describe('when getting if the items of a collection are being fetched', () => { beforeEach(() => { state.item.loading.push( fetchItemsRequest({ - filters: { contracts: ['anotherContractAddress'] } + filters: { contractAddresses: ['anotherContractAddress'] } }) ) }) @@ -126,7 +126,7 @@ describe('when getting if the items of a collection are being fetched', () => { describe("and they're being fetched", () => { beforeEach(() => { state.item.loading.push( - fetchItemsRequest({ filters: { contracts: [contractAddress] } }) + fetchItemsRequest({ filters: { contractAddresses: [contractAddress] } }) ) }) diff --git a/webapp/src/modules/item/selectors.ts b/webapp/src/modules/item/selectors.ts index 9c83c2c5a..042baf396 100644 --- a/webapp/src/modules/item/selectors.ts +++ b/webapp/src/modules/item/selectors.ts @@ -3,7 +3,11 @@ import { createMatchSelector } from 'connected-react-router' import { Item } from '@dcl/schemas' import { locations } from '../routing/locations' import { RootState } from '../reducer' -import { FETCH_ITEMS_REQUEST, FETCH_ITEM_REQUEST } from './actions' +import { + FETCH_ITEMS_REQUEST, + FETCH_ITEM_REQUEST, + FetchItemsRequestAction +} from './actions' export const getState = (state: RootState) => state.item export const getData = (state: RootState) => getState(state).data @@ -29,7 +33,9 @@ export const isFetchingItemsOfCollection = ( getLoading(state).find( action => action.type === FETCH_ITEMS_REQUEST && - action.payload.filters?.contracts?.includes(contractAddress) + (action as FetchItemsRequestAction).payload.filters?.contractAddresses?.includes( + contractAddress + ) ) !== undefined export const getItems = createSelector< diff --git a/webapp/src/modules/item/types.ts b/webapp/src/modules/item/types.ts index 2924b3235..e040aabdf 100644 --- a/webapp/src/modules/item/types.ts +++ b/webapp/src/modules/item/types.ts @@ -1,3 +1,4 @@ +import { CatalogFilters, CatalogSortBy, ItemSortBy } from '@dcl/schemas' import { View } from '../ui/types' import { ItemFilters } from '../vendor/decentraland/item/types' import { Section } from '../vendor/routing/types' @@ -5,6 +6,9 @@ import { Section } from '../vendor/routing/types' export type ItemBrowseOptions = { view?: View page?: number - filters?: ItemFilters + filters?: Omit<ItemFilters, 'sortBy'> & + Omit<CatalogFilters, 'sortBy'> & { + sortBy?: ItemSortBy | CatalogSortBy + } section?: Section } diff --git a/webapp/src/modules/item/utils.ts b/webapp/src/modules/item/utils.ts index 962e7b87d..6aa330fa7 100644 --- a/webapp/src/modules/item/utils.ts +++ b/webapp/src/modules/item/utils.ts @@ -9,12 +9,12 @@ export function getItem( return null } - const itemId = getItemId(contractAddress, tokenId) - return itemId in items ? items[itemId] : null -} - -export function getItemId(contractAddress: string, tokenId: string) { - return contractAddress + '-' + tokenId + return ( + Object.values(items).find( + item => + item.itemId === tokenId && item.contractAddress === contractAddress + ) || null + ) } export function parseItemId(itemId: string) { diff --git a/webapp/src/modules/nft/utils.ts b/webapp/src/modules/nft/utils.ts index 4ced9700a..fd3a704ea 100644 --- a/webapp/src/modules/nft/utils.ts +++ b/webapp/src/modules/nft/utils.ts @@ -1,4 +1,5 @@ -import { BodyShape, Item, NFTCategory } from '@dcl/schemas' +import { BodyShape, NFTCategory } from '@dcl/schemas' +import { Asset } from '../asset/types' import { NFT } from './types' export function getNFTId(contractAddress: string, tokenId: string) { @@ -35,13 +36,13 @@ export function isUnisex(bodyShapes: BodyShape[]) { return bodyShapes.length === 2 } -export function isLand(nft: NFT | Item) { +export function isLand(nft: Asset) { return ( nft.category === NFTCategory.PARCEL || nft.category === NFTCategory.ESTATE ) } -export function isParcel(nft: NFT | Item) { +export function isParcel(nft: Asset) { return nft.category === NFTCategory.PARCEL } diff --git a/webapp/src/modules/routing/sagas.spec.ts b/webapp/src/modules/routing/sagas.spec.ts index a0fc4675a..1a859eca6 100644 --- a/webapp/src/modules/routing/sagas.spec.ts +++ b/webapp/src/modules/routing/sagas.spec.ts @@ -2,8 +2,8 @@ import { EmotePlayMode, GenderFilterOption, ItemSortBy, - Network, NFTCategory, + Network, Rarity } from '@dcl/schemas' import { @@ -15,6 +15,7 @@ import { } from 'connected-react-router' import { expectSaga } from 'redux-saga-test-plan' import { call, select } from 'redux-saga/effects' +import { AssetStatusFilter } from '../../utils/filters' import { AssetType } from '../asset/types' import { getData as getEventData } from '../event/selectors' import { fetchFavoritedItemsRequest } from '../favorites/actions' @@ -121,36 +122,76 @@ describe('when handling the clear filters request action', () => { }) describe('and it is not the LAND section', () => { - it("should fetch assets and change the URL by clearing the filter's browse options and restarting the page counter and delete the onlyOnSale filter", () => { - return expectSaga(routingSaga) - .provide([ - [ - select(getCurrentBrowseOptions), - { - ...browseOptions, - onlyOnSale: false, - section: Section.COLLECTIONS - } - ], - [select(getPage), 1], - [select(getLocation), { pathname }], - [select(getEventData), {}], - [ - call(fetchAssetsFromRoute, browseOptionsWithoutFilters), - Promise.resolve() - ] - ]) - .put( - push( - buildBrowseURL(pathname, { - ...browseOptionsWithoutFilters, - section: Section.COLLECTIONS, - onlyOnSale: true - }) + describe('and the asset type is Item', () => { + it("should fetch assets and change the URL by clearing the filter's browse options and restarting the page counter and delete the onlyOnSale filter", () => { + return expectSaga(routingSaga) + .provide([ + [ + select(getCurrentBrowseOptions), + { + ...browseOptions, + onlyOnSale: false, + section: Section.COLLECTIONS, + assetType: AssetType.NFT + } + ], + [select(getPage), 1], + [select(getLocation), { pathname }], + [select(getEventData), {}], + [ + call(fetchAssetsFromRoute, browseOptionsWithoutFilters), + Promise.resolve() + ] + ]) + .put( + push( + buildBrowseURL(pathname, { + ...browseOptionsWithoutFilters, + section: Section.COLLECTIONS, + assetType: AssetType.NFT, + status: AssetStatusFilter.ON_SALE + }) + ) ) - ) - .dispatch(clearFilters()) - .run({ silenceTimeout: true }) + .dispatch(clearFilters()) + .run({ silenceTimeout: true }) + }) + }) + describe('and the asset type is NFT', () => { + it("should fetch assets and change the URL by clearing the filter's browse options and restarting the page counter and remove the onlyOnSale filter", () => { + return expectSaga(routingSaga) + .provide([ + [ + select(getCurrentBrowseOptions), + { + ...browseOptions, + onlyOnSale: true, + section: Section.WEARABLES, + assetType: AssetType.NFT, + view: View.CURRENT_ACCOUNT + } + ], + [select(getPage), 1], + [select(getLocation), { pathname }], + [select(getEventData), {}], + [ + call(fetchAssetsFromRoute, browseOptionsWithoutFilters), + Promise.resolve() + ] + ]) + .put( + push( + buildBrowseURL(pathname, { + ...browseOptionsWithoutFilters, + section: Section.WEARABLES, + onlyOnSale: true, + assetType: AssetType.NFT + }) + ) + ) + .dispatch(clearFilters()) + .run({ silenceTimeout: true }) + }) }) }) }) @@ -167,7 +208,8 @@ describe('when handling the fetchAssetsFromRoute request action', () => { return expectSaga(routingSaga) .provide([ [select(getCurrentBrowseOptions), browseOptions], - [select(getPage), 1] + [select(getPage), 1], + [select(getSection), Section.WEARABLES] ]) .put(fetchTrendingItemsRequest()) .dispatch(fetchAssetsFromRouteAction(browseOptions)) @@ -191,7 +233,7 @@ describe('when handling the fetchAssetsFromRoute request action', () => { filters: { first: 24, skip: 0, - sortBy: ItemSortBy.RECENTLY_REVIEWED, + sortBy: ItemSortBy.CHEAPEST, creator: [address], category: NFTCategory.EMOTE, isWearableHead: false, @@ -202,18 +244,20 @@ describe('when handling the fetchAssetsFromRoute request action', () => { isWearableSmart: undefined, search: undefined, rarities: undefined, - contracts: undefined, + contractAddresses: undefined, wearableGenders: undefined, emotePlayMode: undefined, minPrice: undefined, - maxPrice: undefined + maxPrice: undefined, + network: undefined } } return expectSaga(routingSaga) .provide([ [call(getNewBrowseOptions, browseOptions), browseOptions], - [select(getPage), 1] + [select(getPage), 1], + [select(getSection), Section.WEARABLES] ]) .put(fetchItemsRequest(filters)) .dispatch(fetchAssetsFromRouteAction(browseOptions)) @@ -242,6 +286,7 @@ describe('when handling the fetchAssetsFromRoute request action', () => { return expectSaga(routingSaga) .provide([ [select(getCurrentBrowseOptions), browseOptions], + [select(getSection), Section.LISTS], [select(getPage), 1] ]) .put(fetchFavoritedItemsRequest(filters)) @@ -277,11 +322,12 @@ describe('when handling the fetchAssetsFromRoute request action', () => { isWearableSmart: undefined, search: undefined, rarities: undefined, - contracts: undefined, + contractAddresses: undefined, wearableGenders: undefined, emotePlayMode: undefined, minPrice: undefined, - maxPrice: undefined + maxPrice: undefined, + network: undefined } } it('should fetch assets with the correct skip size', () => { @@ -291,7 +337,8 @@ describe('when handling the fetchAssetsFromRoute request action', () => { select(getCurrentBrowseOptions), { ...browseOptions, section: Section.WEARABLES_TRENDING } ], - [select(getPage), pageInState] + [select(getPage), pageInState], + [select(getSection), Section.WEARABLES_TRENDING] ]) .put(fetchItemsRequest(filters)) .dispatch(fetchAssetsFromRouteAction(browseOptions)) @@ -326,11 +373,12 @@ describe('when handling the fetchAssetsFromRoute request action', () => { isWearableSmart: undefined, search: undefined, rarities: undefined, - contracts: undefined, + contractAddresses: undefined, wearableGenders: undefined, emotePlayMode: undefined, minPrice: undefined, - maxPrice: undefined + maxPrice: undefined, + network: undefined } } it('should fetch assets with the correct skip size', () => { @@ -340,7 +388,8 @@ describe('when handling the fetchAssetsFromRoute request action', () => { select(getCurrentBrowseOptions), { ...browseOptions, section: Section.WEARABLES_TRENDING } ], - [select(getPage), undefined] + [select(getPage), undefined], + [select(getSection), Section.WEARABLES_TRENDING] ]) .put(fetchItemsRequest(filters)) .dispatch(fetchAssetsFromRouteAction(browseOptions)) @@ -400,6 +449,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -427,6 +477,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -454,6 +505,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -481,6 +533,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -519,6 +572,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -546,6 +600,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -579,6 +634,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -605,6 +661,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -631,6 +688,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -658,6 +716,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -696,6 +755,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -723,6 +783,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -750,6 +811,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -777,6 +839,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -815,6 +878,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -842,6 +906,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -875,6 +940,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -901,6 +967,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -927,6 +994,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -954,6 +1022,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), {}], [ call(fetchAssetsFromRoute, expectedBrowseOptions), @@ -989,6 +1058,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), eventContracts], [call(fetchAssetsFromRoute, expectedBrowseOptions), Promise.resolve()] ]) @@ -1012,6 +1082,7 @@ describe('when handling the browse action', () => { .provide([ [select(getCurrentBrowseOptions), browseOptions], [select(getLocation), { pathname }], + [select(getSection), Section.WEARABLES], [select(getEventData), eventContracts], [call(fetchAssetsFromRoute, browseOptions), Promise.resolve()] ]) @@ -1045,6 +1116,7 @@ describe('when handling the browse action', () => { return expectSaga(routingSaga) .provide([ [select(getCurrentBrowseOptions), {}], + [select(getSection), Section.WEARABLES], [select(getLocation), { pathname }], [select(getEventData), {}], [call(fetchAssetsFromRoute, expectedBrowseOptions), Promise.resolve()] diff --git a/webapp/src/modules/routing/sagas.ts b/webapp/src/modules/routing/sagas.ts index 959eba1b0..05425c945 100644 --- a/webapp/src/modules/routing/sagas.ts +++ b/webapp/src/modules/routing/sagas.ts @@ -14,9 +14,11 @@ import { goBack, LOCATION_CHANGE, replace, - LocationChangeAction + // LocationChangeAction } from 'connected-react-router' import { + CatalogFilters, + CatalogSortBy, NFTCategory, RentalStatus, Sale, @@ -36,7 +38,12 @@ import { getNetwork, getOnlySmart, getCurrentBrowseOptions, - getCurrentLocationAddress + getCurrentLocationAddress, + getSection, + getMaxPrice, + getMinPrice, + getStatus, + getEmotePlayMode } from '../routing/selectors' import { fetchNFTRequest, @@ -56,10 +63,11 @@ import { getCategoryFromSection, getDefaultOptionsByView, getSearchWearableCategory, + getCollectionSortBy, + getSearchEmoteCategory, getItemSortBy, getAssetOrderBy, - getCollectionSortBy, - getSearchEmoteCategory + getCatalogSortBy } from './search' import { getRarities, @@ -71,7 +79,7 @@ import { BROWSE, BrowseAction, FETCH_ASSETS_FROM_ROUTE, - fetchAssetsFromRoute as fetchAssetsFromRouteAction, + // fetchAssetsFromRoute as fetchAssetsFromRouteAction, FetchAssetsFromRouteAction, CLEAR_FILTERS, GO_BACK, @@ -83,6 +91,7 @@ import { fetchCollectionsRequest } from '../collection/actions' import { COLLECTIONS_PER_PAGE, getClearedBrowseOptions, + isCatalogView, rentalFilters, SALES_PER_PAGE, sellFilters @@ -107,10 +116,11 @@ import { import { getData } from '../event/selectors' import { getPage } from '../ui/browse/selectors' import { fetchFavoritedItemsRequest } from '../favorites/actions' +import { AssetStatusFilter } from '../../utils/filters' import { buildBrowseURL } from './utils' export function* routingSaga() { - yield takeEvery(LOCATION_CHANGE, handleLocationChange) + // yield takeEvery(LOCATION_CHANGE, handleLocationChange) yield takeEvery(FETCH_ASSETS_FROM_ROUTE, handleFetchAssetsFromRoute) yield takeEvery(BROWSE, handleBrowse) yield takeEvery(CLEAR_FILTERS, handleClearFilters) @@ -130,13 +140,13 @@ export function* routingSaga() { ) } -function* handleLocationChange(action: LocationChangeAction) { - // Re-triggers fetchAssetsFromRoute action when the user goes back - if (action.payload.action === 'POP') { - const options: BrowseOptions = yield select(getCurrentBrowseOptions) - yield put(fetchAssetsFromRouteAction(options)) - } -} +// function* handleLocationChange(action: LocationChangeAction) { +// // Re-triggers fetchAssetsFromRoute action when the user goes back +// // if (action.payload.action === 'POP') { +// // const options: BrowseOptions = yield select(getCurrentBrowseOptions) +// // // yield put(fetchAssetsFromRouteAction(options)) +// // } +// } function* handleFetchAssetsFromRoute(action: FetchAssetsFromRouteAction) { const newOptions: BrowseOptions = yield call( @@ -209,7 +219,9 @@ export function* fetchAssetsFromRoute(options: BrowseOptions) { tenant, minPrice, maxPrice, - creators + creators, + network, + status } = options const address = @@ -222,7 +234,7 @@ export function* fetchAssetsFromRoute(options: BrowseOptions) { const category = getCategoryFromSection(section) const currentPageInState: number = yield select(getPage) - const offset = currentPageInState ? page - 1 : 0 + const offset = currentPageInState && currentPageInState < page ? page - 1 : 0 const skip = Math.min(offset, MAX_PAGE) * PAGE_SIZE const first = Math.min(page * PAGE_SIZE - skip, getMaxQuerySize(vendor)) @@ -279,24 +291,35 @@ export function* fetchAssetsFromRoute(options: BrowseOptions) { ) break default: - if (isItems) { - // TODO: clean up - const isWearableHead = - section === Sections[VendorName.DECENTRALAND].WEARABLES_HEAD - const isWearableAccessory = - section === Sections[VendorName.DECENTRALAND].WEARABLES_ACCESSORIES - - const wearableCategory = !isWearableAccessory - ? getSearchWearableCategory(section) + const isWearableHead = + section === Sections[VendorName.DECENTRALAND].WEARABLES_HEAD + const isWearableAccessory = + section === Sections[VendorName.DECENTRALAND].WEARABLES_ACCESSORIES + + const wearableCategory = !isWearableAccessory + ? getSearchWearableCategory(section) + : undefined + + const emoteCategory = + category === NFTCategory.EMOTE + ? getSearchEmoteCategory(section) : undefined - const emoteCategory = - category === NFTCategory.EMOTE - ? getSearchEmoteCategory(section) - : undefined - - const { rarities, wearableGenders, emotePlayMode } = options - + const { rarities, wearableGenders, emotePlayMode } = options + + const statusParameters: Partial<Omit<CatalogFilters, 'sortBy'>> = { + ...(status === AssetStatusFilter.ON_SALE ? { isOnSale: true } : {}), + ...(status === AssetStatusFilter.NOT_FOR_SALE + ? { isOnSale: false } + : {}), + ...(status === AssetStatusFilter.ONLY_LISTING + ? { onlyListing: true } + : {}), + ...(status === AssetStatusFilter.ONLY_MINTING + ? { onlyMinting: true } + : {}) + } + if (isItems) { yield put( fetchItemsRequest({ view, @@ -304,7 +327,11 @@ export function* fetchAssetsFromRoute(options: BrowseOptions) { filters: { first, skip, - sortBy: getItemSortBy(sortBy), + sortBy: isCatalogView(view) + ? view === View.HOME_NEW_ITEMS + ? CatalogSortBy.NEWEST + : getCatalogSortBy(sortBy) + : getItemSortBy(sortBy), isOnSale: onlyOnSale, creator: address ? [address] : creators, wearableCategory, @@ -315,11 +342,13 @@ export function* fetchAssetsFromRoute(options: BrowseOptions) { search, category, rarities: rarities, - contracts, + contractAddresses: contracts, wearableGenders, emotePlayMode, minPrice, - maxPrice + maxPrice, + network, + ...statusParameters } }) ) @@ -355,6 +384,9 @@ export function* getNewBrowseOptions( let previous: BrowseOptions = yield select(getCurrentBrowseOptions) current = yield deriveCurrentOptions(previous, current) const view = deriveView(previous, current) + const section: Section = current.section + ? current.section + : yield select(getSection) const vendor = deriveVendor(previous, current) if (shouldResetOptions(previous, current)) { @@ -369,8 +401,7 @@ export function* getNewBrowseOptions( } } - const defaults = getDefaultOptionsByView(view, current.section as Section) - + const defaults = getDefaultOptionsByView(view, section) return { ...defaults, ...previous, @@ -518,9 +549,12 @@ function* deriveCurrentOptions( onlyOnRent: current.hasOwnProperty('onlyOnRent') ? current.onlyOnRent : previous.onlyOnRent, - onlyOnSale: current.hasOwnProperty('onlyOnSale') - ? current.onlyOnSale - : previous.onlyOnSale + onlyOnSale: + current.assetType === AssetType.ITEM + ? undefined + : current.hasOwnProperty('onlyOnSale') + ? current.onlyOnSale + : previous.onlyOnSale } // Checks if the sorting categories are correctly set for the onlyOnRental and the onlyOnSell filters @@ -561,6 +595,9 @@ function* deriveCurrentOptions( network: yield select(getNetwork), contracts: yield select(getContracts), onlySmart: yield select(getOnlySmart), + maxPrice: yield select(getMaxPrice), + minPrice: yield select(getMinPrice), + status: yield select(getStatus), ...newOptions } } @@ -573,11 +610,25 @@ function* deriveCurrentOptions( if (prevCategory && prevCategory === nextCategory) { newOptions = { rarities: yield select(getRarities), + maxPrice: yield select(getMaxPrice), + minPrice: yield select(getMinPrice), + status: yield select(getStatus), + emotePlayMode: yield select(getEmotePlayMode), ...newOptions } } break } + case NFTCategory.ENS: { + // for ENS, if the previous page had `onlyOnSale` as `undefined` like wearables or emotes, it defaults to `true`, otherwise use the current value + newOptions = { + ...newOptions, + assetType: AssetType.NFT, + onlyOnSale: + previous.onlyOnSale === undefined ? true : current.onlyOnSale + } + break + } default: { newOptions = { ...newOptions, assetType: AssetType.NFT } } diff --git a/webapp/src/modules/routing/search.ts b/webapp/src/modules/routing/search.ts index ab1c1ee50..0c2db3f01 100644 --- a/webapp/src/modules/routing/search.ts +++ b/webapp/src/modules/routing/search.ts @@ -1,4 +1,5 @@ import { + CatalogSortBy, CollectionSortBy, EmoteCategory, EmotePlayMode, @@ -12,6 +13,9 @@ import { BrowseOptions, SortBy, SortDirection } from './types' import { Section } from '../vendor/decentraland' import { NFTSortBy } from '../nft/types' import { isAccountView, isLandSection } from '../ui/utils' +import { AssetStatusFilter } from '../../utils/filters' +import { AssetType } from '../asset/types' +import { isCatalogView, isCatalogViewWithStatusFilter } from './utils' const SEARCH_ARRAY_PARAM_SEPARATOR = '_' @@ -21,8 +25,8 @@ export function getDefaultOptionsByView( ): BrowseOptions { if (section === Section.LISTS) return {} - return { - onlyOnSale: !view || !isAccountView(view), + let defaultOptions: Partial<BrowseOptions> = { + onlyOnSale: view && isAccountView(view) ? false : undefined, sortBy: view && isAccountView(view) ? SortBy.NEWEST @@ -30,6 +34,30 @@ export function getDefaultOptionsByView( ? SortBy.NEWEST : SortBy.RECENTLY_LISTED } + if (section && isCatalogView(view)) { + const currentCategoryBySection = getCategoryFromSection(section) + if ( + currentCategoryBySection && + [NFTCategory.EMOTE, NFTCategory.WEARABLE].includes( + currentCategoryBySection + ) + ) { + defaultOptions = { + ...defaultOptions, + onlyOnSale: view === View.CURRENT_ACCOUNT ? false : undefined, // current account shows on sale false as default + status: isCatalogViewWithStatusFilter(view) // for market view, we show status on sale filter as default + ? AssetStatusFilter.ON_SALE + : undefined + } + } else if (currentCategoryBySection === NFTCategory.ENS) { + defaultOptions = { + ...defaultOptions, + status: undefined, // status doesn't apply to ENS + onlyOnSale: true // show ENS names on sale by default + } + } + } + return defaultOptions } export function getSearchParams(options?: BrowseOptions) { @@ -74,6 +102,9 @@ export function getSearchParams(options?: BrowseOptions) { options.rarities.join(SEARCH_ARRAY_PARAM_SEPARATOR) ) } + if (options.status) { + params.set('status', options.status.toString()) + } if (options.wearableGenders && options.wearableGenders.length > 0) { params.set( 'genders', @@ -214,6 +245,21 @@ export function getSectionFromCategory(category: NFTCategory) { } } +export function getMarketAssetTypeFromCategory(category: NFTCategory) { + switch (category) { + case NFTCategory.PARCEL: + return AssetType.NFT + case NFTCategory.ESTATE: + return AssetType.NFT + case NFTCategory.ENS: + return AssetType.NFT + case NFTCategory.EMOTE: + return AssetType.ITEM + case NFTCategory.WEARABLE: + return AssetType.ITEM + } +} + export function getSearchSection(category: WearableCategory | EmoteCategory) { for (const section of Object.values(Section)) { const sectionCategory = Object.values(EmoteCategory).includes( @@ -302,6 +348,23 @@ export function getItemSortBy(sortBy: SortBy): ItemSortBy { } } +export function getCatalogSortBy(sortBy: SortBy): CatalogSortBy { + switch (sortBy) { + case SortBy.CHEAPEST: + return CatalogSortBy.CHEAPEST + case SortBy.MOST_EXPENSIVE: + return CatalogSortBy.MOST_EXPENSIVE + case SortBy.NEWEST: + return CatalogSortBy.NEWEST + case SortBy.RECENTLY_LISTED: + return CatalogSortBy.RECENTLY_LISTED + case SortBy.RECENTLY_SOLD: + return CatalogSortBy.RECENTLY_SOLD + default: + return CatalogSortBy.CHEAPEST + } +} + export function getCollectionSortBy(sortBy: SortBy): CollectionSortBy { switch (sortBy) { case SortBy.NAME: diff --git a/webapp/src/modules/routing/selectors.spec.ts b/webapp/src/modules/routing/selectors.spec.ts index 5f9959b7a..24841c49d 100644 --- a/webapp/src/modules/routing/selectors.spec.ts +++ b/webapp/src/modules/routing/selectors.spec.ts @@ -4,6 +4,7 @@ import { Network, Rarity } from '@dcl/schemas' +import { AssetStatusFilter } from '../../utils/filters' import { AssetType } from '../asset/types' import { VendorName } from '../vendor' import { Section } from '../vendor/routing/types' @@ -11,6 +12,7 @@ import { View } from '../ui/types' import { PageName, Sections, SortBy } from './types' import { locations } from './locations' import { + getAllSortByOptions, getAssetType, getCreators, getIsMap, @@ -24,6 +26,8 @@ import { getSection, getSortBy, getViewAsGuest, + getSortByOptions, + getStatus, hasFiltersEnabled } from './selectors' @@ -123,6 +127,32 @@ describe('when getting if the are filters set', () => { }) }) + describe('and the status is set', () => { + describe('and the status is ON SALE', () => { + it('should return false', () => { + expect( + hasFiltersEnabled.resultFunc({ + status: AssetStatusFilter.ON_SALE + }) + ).toBe(false) + }) + }) + + describe.each([ + [AssetStatusFilter.NOT_FOR_SALE], + [AssetStatusFilter.ONLY_LISTING], + [AssetStatusFilter.ONLY_MINTING] + ])('and the status is %s', status => { + it('should return true', () => { + expect( + hasFiltersEnabled.resultFunc({ + status + }) + ).toBe(true) + }) + }) + }) + describe('and it is the lists section', () => { it('should return false', () => { expect( @@ -223,7 +253,7 @@ describe("when there's assetType URL param, the assetType is not NFT or ITEM and }) describe("when there's assetType URL param, the assetType is not NFT or ITEM and the vendor is DECENTRALAND and the location is in browse", () => { - it('should return ITEM as the assetType', () => { + it('should return CATALOG_ITEM as the assetType', () => { expect( getAssetType.resultFunc( 'assetType=something', @@ -581,3 +611,99 @@ describe('when getting if the page name', () => { }) }) }) + +describe('when there a status defined', () => { + let url: string + let status: string + beforeEach(() => { + status = 'only_minting' + url = `status=${status}` + }) + it('should return an empty array', () => { + expect(getStatus.resultFunc(url)).toEqual(status) + }) +}) + +describe('when getting the Sort By options', () => { + const baseSortByOptions = [ + getAllSortByOptions()[SortBy.NEWEST], + getAllSortByOptions()[SortBy.RECENTLY_LISTED], + getAllSortByOptions()[SortBy.RECENTLY_SOLD], + getAllSortByOptions()[SortBy.CHEAPEST], + getAllSortByOptions()[SortBy.MOST_EXPENSIVE] + ] + let status: AssetStatusFilter + describe('and the status is defined', () => { + describe('and the status is ON_SALE', () => { + beforeEach(() => { + status = AssetStatusFilter.ON_SALE + }) + it('should return the base sort options array', () => { + expect(getSortByOptions.resultFunc(true, true, status)).toEqual( + baseSortByOptions + ) + }) + }) + describe('and the status is ONLY_MINTING', () => { + beforeEach(() => { + status = AssetStatusFilter.ONLY_MINTING + }) + it('should return the base sort options array', () => { + expect(getSortByOptions.resultFunc(true, true, status)).toEqual( + baseSortByOptions + ) + }) + }) + describe('and the status is ONLY_LISTING', () => { + beforeEach(() => { + status = AssetStatusFilter.ONLY_LISTING + }) + it('should return the base sort options', () => { + expect(getSortByOptions.resultFunc(true, true, status)).toEqual( + baseSortByOptions + ) + }) + }) + describe('and the status is NOT_FOR_SALE', () => { + beforeEach(() => { + status = AssetStatusFilter.NOT_FOR_SALE + }) + it('should return an array with just the newest option', () => { + expect(getSortByOptions.resultFunc(true, true, status)).toEqual([ + getAllSortByOptions()[SortBy.NEWEST] + ]) + }) + }) + }) + describe('and the status is not defined', () => { + let status: string + beforeEach(() => { + status = '' + }) + describe('and the "onlyOnRent" is true', () => { + describe('and the "onlyOnSale" is false', () => { + it('should return an array with the valid on rent sort options', () => { + expect(getSortByOptions.resultFunc(true, false, status)).toEqual([ + getAllSortByOptions()[SortBy.RENTAL_LISTING_DATE], + getAllSortByOptions()[SortBy.NAME], + getAllSortByOptions()[SortBy.NEWEST], + getAllSortByOptions()[SortBy.MAX_RENTAL_PRICE] + ]) + }) + }) + }) + describe('and the "onlyOnSale" is true', () => { + describe('and the "onlyOnRent" is false', () => { + it('should return an array with just the valid on sale sort options', () => { + expect(getSortByOptions.resultFunc(false, true, status)).toEqual([ + getAllSortByOptions()[SortBy.RECENTLY_LISTED], + getAllSortByOptions()[SortBy.RECENTLY_SOLD], + getAllSortByOptions()[SortBy.CHEAPEST], + getAllSortByOptions()[SortBy.NEWEST], + getAllSortByOptions()[SortBy.NAME] + ]) + }) + }) + }) + }) +}) diff --git a/webapp/src/modules/routing/selectors.ts b/webapp/src/modules/routing/selectors.ts index 120d9b724..14ab4be0f 100644 --- a/webapp/src/modules/routing/selectors.ts +++ b/webapp/src/modules/routing/selectors.ts @@ -10,6 +10,8 @@ import { Network, Rarity } from '@dcl/schemas' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { AssetStatusFilter } from '../../utils/filters' import { getView } from '../ui/browse/selectors' import { View } from '../ui/types' import { VendorName } from '../vendor/types' @@ -26,7 +28,7 @@ import { getURLParam, getURLParamArray_nonStandard } from './search' -import { BrowseOptions, PageName, SortBy } from './types' +import { BrowseOptions, PageName, SortBy, SortByOption } from './types' import { locations } from './locations' export const getState = (state: RootState) => state.routing @@ -92,14 +94,12 @@ export const getSortBy = createSelector< View | undefined, Section, SortBy | undefined ->( - getRouterSearch, - getView, - getSection, - (search, view, section) => +>(getRouterSearch, getView, getSection, (search, view, section) => { + return ( getURLParam<SortBy>(search, 'sortBy') || getDefaultOptionsByView(view, section).sortBy -) + ) +}) export const getOnlyOnSale = createSelector< RootState, @@ -137,6 +137,107 @@ export const getOnlyOnRent = createSelector< } }) +export const getAllSortByOptions = () => ({ + [SortBy.NEWEST]: { value: SortBy.NEWEST, text: t('filters.newest') }, + [SortBy.NAME]: { value: SortBy.NAME, text: t('filters.name') }, + [SortBy.RECENTLY_SOLD]: { + value: SortBy.RECENTLY_SOLD, + text: t('filters.recently_sold') + }, + [SortBy.CHEAPEST]: { + value: SortBy.CHEAPEST, + text: t('filters.cheapest') + }, + [SortBy.MOST_EXPENSIVE]: { + value: SortBy.MOST_EXPENSIVE, + text: t('filters.most_expensive') + }, + [SortBy.MAX_RENTAL_PRICE]: { + value: SortBy.MAX_RENTAL_PRICE, + text: t('filters.cheapest') + }, + [SortBy.RECENTLY_LISTED]: { + value: SortBy.RECENTLY_LISTED, + text: t('filters.recently_listed') + }, + [SortBy.RENTAL_LISTING_DATE]: { + value: SortBy.RENTAL_LISTING_DATE, + text: t('filters.recently_listed_for_rent') + } +}) + +export const getStatus = createSelector<RootState, string, string>( + getRouterSearch, + search => getURLParam(search, 'status') || '' +) + +export const getSortByOptions = createSelector< + RootState, + boolean | undefined, + boolean | undefined, + string, + SortByOption[] +>(getOnlyOnRent, getOnlyOnSale, getStatus, (onlyOnRent, onlyOnSale, status) => { + const SORT_BY_MAP = getAllSortByOptions() + let orderByDropdownOptions: SortByOption[] = [] + if (status) { + const baseFilters = [ + SORT_BY_MAP[SortBy.NEWEST], + SORT_BY_MAP[SortBy.RECENTLY_LISTED], + SORT_BY_MAP[SortBy.RECENTLY_SOLD], + SORT_BY_MAP[SortBy.CHEAPEST], + SORT_BY_MAP[SortBy.MOST_EXPENSIVE] + ] + switch (status) { + case AssetStatusFilter.ON_SALE: + case AssetStatusFilter.ONLY_MINTING: + case AssetStatusFilter.ONLY_LISTING: + orderByDropdownOptions = baseFilters + break + case AssetStatusFilter.NOT_FOR_SALE: + orderByDropdownOptions = [SORT_BY_MAP[SortBy.NEWEST]] + break + } + return orderByDropdownOptions + } + if (onlyOnRent) { + orderByDropdownOptions = [ + { + value: SortBy.RENTAL_LISTING_DATE, + text: t('filters.recently_listed_for_rent') + }, + { value: SortBy.NAME, text: t('filters.name') }, + { value: SortBy.NEWEST, text: t('filters.newest') }, + { value: SortBy.MAX_RENTAL_PRICE, text: t('filters.cheapest') } + ] + } else { + orderByDropdownOptions = [ + { value: SortBy.NEWEST, text: t('filters.newest') }, + { value: SortBy.NAME, text: t('filters.name') } + ] + } + + if (onlyOnSale) { + orderByDropdownOptions = [ + { + value: SortBy.RECENTLY_LISTED, + text: t('filters.recently_listed') + }, + { + value: SortBy.RECENTLY_SOLD, + text: t('filters.recently_sold') + }, + { + value: SortBy.CHEAPEST, + text: t('filters.cheapest') + }, + ...orderByDropdownOptions + ] + } + + return orderByDropdownOptions +}) + export const getIsSoldOut = createSelector< RootState, string, @@ -233,6 +334,7 @@ export const getAssetType = createSelector< if (vendor === VendorName.DECENTRALAND && pathname === locations.browse()) { return AssetType.ITEM } + return AssetType.NFT } return assetTypeParam as AssetType @@ -394,13 +496,23 @@ export const getWearablesUrlParams = createSelector( getViewAsGuest, getMinPrice, getMaxPrice, - (rarities, wearableGenders, view, viewAsGuest, minPrice, maxPrice) => ({ + getStatus, + ( rarities, wearableGenders, view, viewAsGuest, minPrice, - maxPrice + maxPrice, + status + ) => ({ + rarities, + wearableGenders, + view, + viewAsGuest, + minPrice, + maxPrice, + status }) ) @@ -471,7 +583,9 @@ export const hasFiltersEnabled = createSelector< maxDistanceToPlaza, adjacentToRoad, creators, - rentalDays + rentalDays, + status, + onlySmart } = browseOptions const isLand = isLandSection(section as Section) @@ -505,13 +619,15 @@ export const hasFiltersEnabled = createSelector< return ( hasNetworkFilter || hasGenderFilter || + onlySmart || hasRarityFilter || hasContractsFilter || hasCreatorFilter || hasEmotePlayModeFilter || !!minPrice || !!maxPrice || - hasNotOnSaleFilter + hasNotOnSaleFilter || + (!!status && status !== AssetStatusFilter.ON_SALE) ) }) diff --git a/webapp/src/modules/routing/types.ts b/webapp/src/modules/routing/types.ts index 41eb98fa8..d1c1bd116 100644 --- a/webapp/src/modules/routing/types.ts +++ b/webapp/src/modules/routing/types.ts @@ -7,6 +7,7 @@ import { WearableGender, GenderFilterOption } from '@dcl/schemas' +import { AssetStatusFilter } from '../../utils/filters' import { AssetType } from '../asset/types' import { VendorName } from '../vendor/types' import { View } from '../ui/types' @@ -18,6 +19,7 @@ export enum SortBy { NEWEST = 'newest', RECENTLY_LISTED = 'recently_listed', CHEAPEST = 'cheapest', + MOST_EXPENSIVE = 'most_expensive', RECENTLY_REVIEWED = 'recently_reviewed', RECENTLY_SOLD = 'recently_sold', SIZE = 'size', @@ -29,6 +31,11 @@ export enum SortBy { CHEAPEST_RENT = 'cheapest_rent' } +export type SortByOption = { + value: SortBy + text: string +} + export enum SortDirection { ASC = 'asc', DESC = 'desc' @@ -47,6 +54,7 @@ export type BrowseOptions = { isMap?: boolean isFullscreen?: boolean rarities?: Rarity[] + status?: AssetStatusFilter wearableGenders?: (WearableGender | GenderFilterOption)[] search?: string contracts?: string[] diff --git a/webapp/src/modules/routing/utils.spec.ts b/webapp/src/modules/routing/utils.spec.ts index d9d8a4077..7e93692f1 100644 --- a/webapp/src/modules/routing/utils.spec.ts +++ b/webapp/src/modules/routing/utils.spec.ts @@ -1,7 +1,13 @@ +import { AssetStatusFilter } from '../../utils/filters' import { Section } from '../vendor/decentraland/routing' import { getPersistedIsMapProperty } from '../ui/utils' import { View } from '../ui/types' -import { getClearedBrowseOptions, isMapSet } from './utils' +import { + CATALOG_VIEWS, + getClearedBrowseOptions, + isCatalogView, + isMapSet +} from './utils' import { BrowseOptions } from './types' jest.mock('../ui/utils') @@ -112,6 +118,38 @@ describe('when checking if the map is set', () => { describe('when clearing browser options', () => { let baseBrowseOptions: BrowseOptions let options: BrowseOptions + describe('and its a catalog view that should render the status filter', () => { + beforeEach(() => { + baseBrowseOptions = { + page: 1, + view: View.MARKET + } + }) + it('should set the default status filter option', () => { + expect(getClearedBrowseOptions(baseBrowseOptions)).toStrictEqual({ + ...baseBrowseOptions, + status: AssetStatusFilter.ON_SALE + }) + }) + }) + + describe('and its not a catalog view', () => { + beforeEach(() => { + baseBrowseOptions = { + onlyOnSale: false, + page: 1, + view: View.CURRENT_ACCOUNT, + section: Section.WEARABLES + } + }) + it('should not set the status filter option', () => { + expect(getClearedBrowseOptions(baseBrowseOptions)).toStrictEqual({ + ...baseBrowseOptions, + onlyOnSale: true + }) + }) + }) + describe('and the creators filter is set', () => { beforeEach(() => { baseBrowseOptions = { @@ -128,3 +166,11 @@ describe('when clearing browser options', () => { }) }) }) + +describe('when checking if a view is a Catalog View type', () => { + describe('and the creators filter is set', () => { + it.each(CATALOG_VIEWS)('should return true for %s', view => { + expect(isCatalogView(view)).toBe(true) + }) + }) +}) diff --git a/webapp/src/modules/routing/utils.ts b/webapp/src/modules/routing/utils.ts index 25dce5888..0587423b4 100644 --- a/webapp/src/modules/routing/utils.ts +++ b/webapp/src/modules/routing/utils.ts @@ -1,5 +1,7 @@ +import { NFTCategory } from '@dcl/schemas' import { BrowseOptions, SortBy } from './types' import { Section } from '../vendor/decentraland' +import { AssetStatusFilter } from '../../utils/filters' import { getPersistedIsMapProperty, isAccountView, @@ -7,7 +9,7 @@ import { } from '../ui/utils' import { omit, reset } from '../../lib/utils' import { View } from '../ui/types' -import { getSearchParams } from './search' +import { getCategoryFromSection, getSearchParams } from './search' export const rentalFilters = [ SortBy.NAME, @@ -20,6 +22,7 @@ export const rentalFilters = [ export const sellFilters = [ SortBy.NAME, SortBy.CHEAPEST, + SortBy.MOST_EXPENSIVE, SortBy.NEWEST, SortBy.RECENTLY_REVIEWED, SortBy.RECENTLY_SOLD, @@ -29,6 +32,38 @@ export const sellFilters = [ export const COLLECTIONS_PER_PAGE = 6 export const SALES_PER_PAGE = 6 +export const CATALOG_VIEWS: View[] = [ + View.MARKET, + View.CURRENT_ACCOUNT, + View.ACCOUNT, + View.HOME_NEW_ITEMS, + View.LISTS, + View.HOME_TRENDING_ITEMS +] + +export function isCatalogView(view: View | undefined) { + return view && CATALOG_VIEWS.includes(view) +} + +export function isCatalogViewWithStatusFilter(view: View | undefined) { + return view && view === View.MARKET +} + +export function isCatalogViewAndSection( + view: View | undefined, + section: Section | undefined +) { + return ( + view && + CATALOG_VIEWS.includes(view) && + section && + [getCategoryFromSection(section)].some( + category => + category === NFTCategory.EMOTE || category === NFTCategory.WEARABLE + ) + ) +} + export function buildBrowseURL( pathname: string, browseOptions: BrowseOptions @@ -85,17 +120,28 @@ export function getClearedBrowseOptions( 'maxDistanceToPlaza', 'adjacentToRoad', 'creators', - 'rentalDays' + 'rentalDays', + 'status' ] const clearedBrowseOptions = fillWithUndefined ? reset(browseOptions, keys) : omit(browseOptions, keys) - // The onlyOnSale filter is ON by default. The clear should remove it if it's off so it's back on (default state) + // The status as only on sale filter is ON by default. The clear should remove it if it's off so it's back on (default state) + if ( + !clearedBrowseOptions.status && + !isLandSection(browseOptions.section as Section) && + isCatalogViewWithStatusFilter(browseOptions.view) + ) { + clearedBrowseOptions.status = AssetStatusFilter.ON_SALE + } + + // The onlyOnSale filter is ON by default for some sections. The clear should remove it if it's off so it's back on (default state) if ( !clearedBrowseOptions.onlyOnSale && - !isLandSection(browseOptions.section as Section) + !isLandSection(browseOptions.section as Section) && + !isCatalogViewWithStatusFilter(browseOptions.view) ) { clearedBrowseOptions.onlyOnSale = true } diff --git a/webapp/src/modules/translation/locales/en.json b/webapp/src/modules/translation/locales/en.json index c61edf6d9..0d1ffcea4 100644 --- a/webapp/src/modules/translation/locales/en.json +++ b/webapp/src/modules/translation/locales/en.json @@ -61,7 +61,9 @@ "unknown_error": "Unknown error", "the_parcel": "the Parcel", "the_estate": "the Estate", - "discord_server": "Discord Server" + "discord_server": "Discord Server", + "showing": "Showing", + "of": "of" }, "address": { "invalid_address": "That's not a valid address", @@ -163,6 +165,7 @@ "cheapest": "Cheapest", "cheapest_sale": "Cheapest (Sale)", "cheapest_rent": "Cheapest (Rent)", + "most_expensive": "Most expensive", "no_results": "No results were found.", "type_to_search": "Type to search for collections.", "clear": "Clear all filters", @@ -335,16 +338,16 @@ }, "nft_list": { "empty": { - "title": "We couldn’t find any results in the {currentSection}.", - "action": "<searchStore>Search the {section}</searchStore><if-filters> or <clearFilters>clear filters</clearFilters></if-filters>" + "title": "No results found for these filters.", + "action": "<if-filters><clearFilters>Reset filters</clearFilters></if-filters>" }, "simple_empty": { "title": "We couldn’t find any results.", "action": "<if-filters><clearFilters>Clear filters</clearFilters></if-filters>" }, "empty_search": { - "title": "No results for \"{search}\" in the {currentSection}.", - "action": "<searchStore>Try searching the {section}</searchStore>" + "title": "No results for \"{search}\".", + "action": "Check for any typos or spelling errors, or try a different search term." } }, "nft_filters": { @@ -370,8 +373,19 @@ "available_for_female": "Available for female", "available_for_male": "Available for male" }, + "status": { + "title": "Status", + "on_sale": "On Sale", + "on_sale_tooltip": "Includes items available for minting and/or with available listings.", + "only_minting": "Only available for minting", + "only_minting_tooltip": "Only includes items that are available for minting (buying directly from the creators).", + "only_listing": "Only listings", + "only_listing_tooltip": "Only includes items that are being resold.", + "not_for_sale": "Not for sale" + }, "rarities": { "title": "Rarity", + "tooltip": "The Rarity determines the total number of NFTs that can be minted", "all_items": "All rarities", "count_items": "{count} {count, plural, one {rarity} other {rarities}}" }, @@ -506,7 +520,13 @@ "not_enough_mana": "You don't have enough MANA", "select_period": "Select Period" }, + "available_for_mint_popup": { + "available_for_mint": "Currently available for minting", + "buy_directly": "Buy directly from the creator" + }, "description": "Description", + "no_description": "No description", + "read_more": "Read more", "owner": "Owner", "creator": "Creator", "price": "Price", @@ -544,6 +564,73 @@ "when": "When", "price": "Price" }, + "best_buying_option": { + "empty": { + "title": "There are no available listings for this item", + "you_can": "You can", + "check_the_current_owners": "check the current owners", + "and_make_an_offer": "and make an offer." + }, + "minting": { + "title": "Available for Minting", + "price": "Price", + "stock": "Stock", + "minting_popup": "When you mint an item you become its first owner. It also means buying directly from the creator. ", + "polygon_mana": "Polygon MANA", + "ethereum_mana": "Ethereum MANA" + }, + "buy_listing": { + "title": "Cheapest Listing", + "price": "Price", + "highest_offer": "Highest Offer", + "issue_number": "Issue Number", + "owner": "Owner", + "view_listing": "View listing", + "expires": "Expires", + "no_offer": "No offer", + "make_offer": "Make Offer" + } + }, + "offers_table": { + "offers": "Offers", + "from": "From", + "published_date": "Published date", + "expiration_date": "Expiration date", + "offer": "Offer", + "your_offer": "Your offer", + "date_published": "Date published", + "remove": "Remove", + "most_expensive": "Highest", + "recenty_offered": "Recently offered", + "recently_updated": "Recently updated", + "accept": "Accept" + }, + "listings_table": { + "other_available_listings": "Other available listings", + "listings": "Listings", + "owner": "Owner", + "published_date": "Published date", + "expiration_date": "Expiration date", + "issue_number": "Issue Number", + "price": "Price", + "offer": "Offer", + "cheapest": "Cheapest", + "newest": "Newest", + "oldest": "Oldest", + "issue_number_asc": "Issue Number ↑", + "issue_number_desc": "Issue Number ↓", + "there_are_no_listings": "No available listings", + "view_listing": "View listing" + }, + "owners_table": { + "owner": "Owner", + "issue_number": "Issue Number", + "issue_number_asc": "Issue Number ↑", + "issue_number_desc": "Issue Number ↓", + "owners": "Owners", + "there_are_no_owners": "There are no owners yet", + "become_the_first_one": "Become the first one!" + }, "rental_history": { "title": "Latest rentals", "lessor": "Lessor", @@ -1119,7 +1206,18 @@ "rented_until": "Rented until {endDate, date, medium}", "claiming_back": "Claiming back {asset_type, select, parcel {parcel} estate {estate} other {LAND}}", "rental_ended": "Rental period over" - } + }, + "listings": "{count} {count, plural, one {Listing} other {Listings}}", + "available_for_mint": "Available for mint", + "available_listings_in_range": "Available listings in this range", + "cheapest_listing": "Cheapest Listing", + "not_for_sale": "Not for sale", + "owners": "{count} {count, plural, one {Owner} other {Owners}}", + "cheapest_option": "Cheapest Option", + "cheapest_option_range": "Cheapest Option within range", + "most_expensive": "Most Expensive", + "most_expensive_range": "Most Expensive within range", + "also_minting": "Also available for minting" }, "rentals_promotional_modal": { "title": "Announcing LAND rentals", diff --git a/webapp/src/modules/translation/locales/es.json b/webapp/src/modules/translation/locales/es.json index 95a3c3b55..50987cfc1 100644 --- a/webapp/src/modules/translation/locales/es.json +++ b/webapp/src/modules/translation/locales/es.json @@ -59,7 +59,9 @@ "unknown_error": "Error desconocido", "the_parcel": "La Parcela", "the_estate": "El Estate", - "discord_server": "Servidor de Discord" + "discord_server": "Servidor de Discord", + "showing": "Mostrando", + "of": "de" }, "address": { "invalid_address": "Dirección inválida", @@ -157,8 +159,7 @@ "recently_listed_for_rent": "Listados recientemente para rentar", "recently_sold": "Vendidos recientemente", "cheapest": "Más baratos", - "cheapest_sale": "Más baratos (Venta)", - "cheapest_rent": "Más baratos (Renta)", + "most_expensive": "Más caros", "no_results": "No se encontraron resultados.", "type_to_search": "Escriba para buscar collecciones.", "clear": "Borrar filtros", @@ -330,16 +331,16 @@ }, "nft_list": { "empty": { - "title": "No pudimos encontrar ningún resultado en la sección {currentSection}.", - "action": "<searchStore>Busca en {section}</searchStore><if-filters> o <clearFilters>limpia los filtros</clearFilters></if-filters>" + "title": "No pudimos encontrar ningún resultado con esos filtros.", + "action": "<if-filters><clearFilters>Limpia los filtros</clearFilters></if-filters>" }, "simple_empty": { "title": "No pudimos encontrar ningún resultado.", "action": "<if-filters><br></br><clearFilters>Limpia los filtros</clearFilters></if-filters>" }, "empty_search": { - "title": "No hay resultados para \"{search}\" en {currentSection}.", - "action": "<searchStore> Intenta buscando en {section}</searchStore>" + "title": "No hay resultados para \"{search}\".", + "action": "Compruebe si hay errores tipográficos o de ortografía, o pruebe con un término de búsqueda diferente." } }, "nft_filters": { @@ -366,8 +367,19 @@ "male": "Solo masculino", "unisex": "Solo unisex" }, + "status": { + "title": "Estado", + "on_sale": "En venta", + "on_sale_tooltip": "Incluye items disponible para mintear o publicaciones disponibles", + "only_minting": "Solo disponible para mintear", + "only_minting_tooltip": "Solo incluye items disponibles para mintear (comprando directamente al creador).", + "only_listing": "Solo publicaciones", + "only_listing_tooltip": "Solo incluye items que estan siendo re-vendidos", + "not_for_sale": "No a la venta" + }, "rarities": { "title": "Rareza", + "tooltip": "La rareza determina la cantidad de NFTs que pueden ser minteados", "all_items": "Todas las rarezas", "count_items": "{count} {count, plural, one {rareza} other {rarezas}}" }, @@ -502,7 +514,13 @@ "not_enough_mana": "No tienes suficiente MANA", "select_period": "Seleccionar Período" }, + "available_for_mint_popup": { + "available_for_mint": "Disponible para mintear", + "buy_directly": "Compra directo al creador" + }, "description": "Descripción", + "no_description": "Sin descripción", + "read_more": "Leer más", "owner": "Dueño", "creator": "Creador", "price": "Precio", @@ -540,6 +558,73 @@ "when": "Fecha", "price": "Precio" }, + "best_buying_option": { + "empty": { + "title": "No hay listados disponibles para este artículo", + "you_can": "Puedes", + "check_the_current_owners": "verificar los propietarios actuales", + "and_make_an_offer": "y hacer una oferta" + }, + "minting": { + "title": "Disponible para comprar", + "price": "Precio", + "stock": "Stock", + "minting_popup": "Cuando compras un artículo, te conviertes en su primer propietario. También significa comprarlo directamente al creador", + "polygon_mana": "Polygon MANA", + "ethereum_mana": "Ethereum MANA" + }, + "buy_listing": { + "title": "Listado más barato", + "price": "Precio", + "highest_offer": "Oferta más alta", + "issue_number": "Número de problema", + "owner": "Propietario", + "view_listing": "Ver listado", + "expires": "Expira", + "no_offer": "Sin ofertas", + "make_offer": "Hacer Oferta" + } + }, + "offers_table": { + "offers": "Ofertas", + "from": "De", + "published_date": "Fecha de publicación", + "expiration_date": "Fecha de expiración", + "offer": "Ofertas", + "your_offer": "Your offer", + "date_published": "Fecha de publicación", + "remove": "Eliminar", + "most_expensive": "Más Caro", + "recenty_offered": "Ofrecido recientemente", + "recently_updated": "Actualizado recientemente", + "accept": "Aceptar" + }, + "listings_table": { + "other_available_listings": "Otros coleccionables disponibles", + "owner": "Dueño", + "issue_number": "Número de emisión", + "issue_number_asc": "Número de emisión ↑", + "issue_number_desc": "Número de emisión ↓", + "listings": "Coleccionables", + "published_date": "Fecha de publicación", + "expiration_date": "Fecha de expiración", + "price": "Precio", + "offer": "Oferta", + "cheapest": "Más baratos", + "newest": "Más nuevos", + "oldest": "Más viejos", + "there_are_no_listings": "No hay collecionables disponibles", + "view_listing": "Ver listado" + }, + "owners_table": { + "owner": "Dueño", + "issue_number": "Número de emisión", + "issue_number_asc": "Número de emisión ↑", + "issue_number_desc": "Número de emisión ↓", + "owners": "Dueños", + "there_are_no_owners": "Aún no hay dueños", + "become_the_first_one": "Se el primero!" + }, "rental_history": { "title": "Últimas rentas", "lessor": "Locador", @@ -1113,7 +1198,17 @@ "rented_until": "Rentada hasta {endDate, date, medium}", "claiming_back": "Reclamando {asset_type, select, parcel {la parcel} estate {el estate} other {LAND}}", "rental_ended": "Período de renta terminado" - } + }, + "listings": "{count} {count, plural, one {Collecionable} other {Collecionables}}", + "available_for_mint": "Disponible para mintear", + "cheapest_listing": "Coleccionable más barato", + "not_for_sale": "No disponible para la venta", + "owners": "{count} {count, plural, one {Dueño} other {Dueños}}", + "cheapest_option": "Más barato", + "cheapest_option_range": "Más barato en el rango", + "most_expensive": "Más Caro", + "most_expensive_range": "Más Caro en el rango", + "also_minting": "También disponibles para mintear" }, "rentals_promotional_modal": { "title": "¡La renta de tierras está disponible!", diff --git a/webapp/src/modules/translation/locales/zh.json b/webapp/src/modules/translation/locales/zh.json index 58cdcbc46..079e8ce57 100644 --- a/webapp/src/modules/translation/locales/zh.json +++ b/webapp/src/modules/translation/locales/zh.json @@ -59,7 +59,9 @@ "unknown_error": "不明错误", "the_parcel": "地块", "the_estate": "产业", - "discord_server": "Discord 服务器" + "discord_server": "Discord 服务器", + "showing": "顯示", + "of": "的" }, "address": { "invalid_address": "这不是一个有效的地址", @@ -159,6 +161,7 @@ "cheapest": "最低价", "cheapest_sale": "最低价 (文塔)", "cheapest_rent": "最低价 (莲太)", + "most_expensive": "最贵的", "no_results": "未找到结果。", "type_to_search": "键入以搜索集合。", "clear": "清除过滤器", @@ -331,16 +334,16 @@ }, "nft_list": { "empty": { - "title": "我们在 {currentSection} 中找不到任何结果。", - "action": "<searchStore>搜索 {section}</searchStore><if-filters>或<clearFilters>清除过滤器</clearFilters></if-filters>" + "title": "没有找到这些过滤器的结果", + "action": "<if-filters><clearFilters>重置过滤器</clearFilters></if-filters>" }, "simple_empty": { "title": "我们找不到任何结果。", "action": "<if-filters><br></br><clearFilters>清除过滤器</clearFilters></if-filters>" }, "empty_search": { - "title": "{currentSection} 中没有“{search}”的结果。", - "action": "<searchStore>尝试搜索{section}</searchStore>" + "title": "中没有“{search}”的结果。", + "action": "检查是否有任何拼写错误或拼写错误,或尝试使用不同的搜索词。" } }, "nft_filters": { @@ -367,8 +370,19 @@ "male": "只有男性", "unisex": "仅男女皆宜" }, + "status": { + "title": "地位", + "on_sale": "特价中", + "on_sale_tooltip": "包括可用于铸造和/或具有可用列表的项目。", + "only_minting": "仅可用于铸币", + "only_minting_tooltip": "仅包括可铸造的物品(直接从创作者处购买)。", + "only_listing": "仅列表", + "only_listing_tooltip": "仅包括正在转售的项目。", + "not_for_sale": "不作为产品销售" + }, "rarities": { "title": "稀有度", + "tooltip": "稀有度决定了可以铸造的 NFT 总数", "all_items": "所有稀有", "count_items": "{count} {count, plural, one {稀有度} other {珍品}}" }, @@ -503,7 +517,13 @@ "not_enough_mana": "你没有足够的 MANA", "select_period": "選擇期間" }, + "available_for_mint_popup": { + "available_for_mint": "可用於鑄幣", + "buy_directly": "直接從創作者那裡購買" + }, "description": "描述", + "no_description": "沒有說明", + "read_more": "閱讀更多", "owner": "持有者", "creator": "创造者", "price": "价格", @@ -541,6 +561,73 @@ "when": "时间", "price": "价格" }, + "best_buying_option": { + "empty": { + "title": "此項目沒有可用的列表", + "you_can": "你可以", + "check_the_current_owners": "檢查當前所有者", + "and_make_an_offer": "然後出價。" + }, + "minting": { + "title": "可鑄造", + "price": "價格", + "stock": "庫存", + "minting_popup": "當你鑄造一件物品時,你就成為了它的第一個所有者。這也意味著直接從創造者那裡購買。", + "polygon_mana": "Polygon MANA", + "ethereum_mana": "Ethereum MANA" + }, + "buy_listing": { + "title": "最便宜的清單", + "price": "價格", + "highest_offer": "最高報價", + "issue_number": "發行編號", + "owner": "所有者", + "view_listing": "查看房源", + "expires": "過期", + "no_offer": "沒有報價", + "make_offer": "報價" + } + }, + "offers_table": { + "offers": "優惠", + "from": "從", + "published_date": "發布日期", + "expiration_date": "到期日期", + "offer": "優惠", + "your_offer": "你的報價", + "date_published": "發布日期", + "remove": "刪除", + "most_expensive": "最貴", + "recenty_offered": "最近提供", + "recently_updated": "最近更新", + "accept": "接受" + }, + "listings_table": { + "other_available_listings": "其他可用的收藏品", + "listings": "收藏品", + "owner": "所有者", + "published_date": "發布日期", + "expiration_date": "到期日期", + "issue_number": "發行編號", + "price": "價格", + "offer": "提供", + "cheapest": "最便宜", + "newest": "最新", + "oldest": "最老", + "issue_number_asc": "發行編號↑", + "issue_number_desc": "發行編號↓", + "there_are_no_listings": "沒有可用的列表", + "view_listing": "查看房源" + }, + "owners_table": { + "owner": "所有者", + "issue_number": "发行数量", + "issue_number_asc": "发行数量 ↑", + "issue_number_desc": "发行数量 ↓", + "owners": "所有者", + "there_are_no_owners": "還沒有所有者", + "become_the_first_one": "成為第一個!" + }, "rental_history": { "title": "最新租金", "lessor": "出租人", @@ -1116,7 +1203,17 @@ "rented_until": "租到 {endDate, date, medium}", "claiming_back": "土地正在收回", "rental_ended": "租赁已结束" - } + }, + "listings": "{count} {count, plural, one {收藏品} other {收藏品}}", + "available_for_mint": "可用於鑄幣", + "cheapest_listing": "最便宜的收藏品", + "not_for_sale": "不可出售", + "owners": "{count} {count, plural, one {所有者} other {所有者}}", + "cheapest_option": "最便宜", + "cheapest_option_range": "范围内最便宜的选择", + "most_expensive": "最貴", + "most_expensive_range": "范围内最贵", + "also_minting": "也可用於鑄造" }, "rentals_promotional_modal": { "title": "Announcing official LAND rentals", diff --git a/webapp/src/modules/ui/browse/reducer.ts b/webapp/src/modules/ui/browse/reducer.ts index 562729ee3..a2daedf5b 100644 --- a/webapp/src/modules/ui/browse/reducer.ts +++ b/webapp/src/modules/ui/browse/reducer.ts @@ -35,6 +35,7 @@ export type BrowseUIState = { nftIds: string[] listIds: string[] itemIds: string[] + catalogIds: string[] lastTimestamp: number count?: number } @@ -45,6 +46,7 @@ export const INITIAL_STATE: BrowseUIState = { nftIds: [], listIds: [], itemIds: [], + catalogIds: [], count: undefined, lastTimestamp: 0 } @@ -69,6 +71,9 @@ export function browseReducer( ): BrowseUIState { switch (action.type) { case SET_VIEW: { + if (action.payload.view === state.view) { + return state + } return { ...state, view: action.payload.view, @@ -140,7 +145,7 @@ export function browseReducer( : action.payload const isDifferentView = view !== state.view - if (isDifferentView) { + if (isDifferentView && view !== View.DETAIL) { return { ...state, [key]: [], @@ -148,15 +153,21 @@ export function browseReducer( } } - const elements = isLoadingMoreResults(state, page) ? [...state[key]] : [] + const isLoadingMore = isLoadingMoreResults(state, page) + const elements = isLoadingMore ? [...state[key]] : [] switch (view) { + case View.DETAIL: case View.ATLAS: return state case View.LISTS: case View.CURRENT_ACCOUNT: case View.ACCOUNT: case View.MARKET: - return { ...state, [key]: elements, count: undefined } + return { + ...state, + [key]: elements, + count: isLoadingMore ? state.count : undefined // when loading more results, the total count shouldn't change + } default: return { ...state, diff --git a/webapp/src/modules/ui/browse/selectors.ts b/webapp/src/modules/ui/browse/selectors.ts index d646bc6d5..20339e22f 100644 --- a/webapp/src/modules/ui/browse/selectors.ts +++ b/webapp/src/modules/ui/browse/selectors.ts @@ -55,7 +55,16 @@ const getItems = createSelector< browse.itemIds.map(id => itemsById[id]) ) -const getOnSaleItems = createSelector< +// export const getCatalogItems = createSelector< +// RootState, +// BrowseUIState, +// CatalogState['data'], +// CatalogItem[] +// >(getState, getCatalogData, (browse, catalogsById) => +// browse.catalogIds.map(id => catalogsById[id]) +// ) + +export const getOnSaleItems = createSelector< RootState, ReturnType<typeof getAddress>, ReturnType<typeof getItemData>, diff --git a/webapp/src/modules/ui/browse/types.ts b/webapp/src/modules/ui/browse/types.ts index aa0e724d6..faccadaab 100644 --- a/webapp/src/modules/ui/browse/types.ts +++ b/webapp/src/modules/ui/browse/types.ts @@ -1,8 +1,15 @@ -import { Item, Order, RentalListing } from '@dcl/schemas' +import { CatalogFilters, Item, Order, RentalListing } from '@dcl/schemas' import { NFT } from '../../nft/types' import { VendorName } from '../../vendor' +import { View } from '../types' export type OnSaleNFT = [NFT<VendorName.DECENTRALAND>, Order] export type OnRentNFT = [NFT<VendorName.DECENTRALAND>, RentalListing] export type OnSaleElement = Item | OnSaleNFT + +export type CatalogBrowseOptions = { + view?: View + page?: number + filters?: CatalogFilters +} diff --git a/webapp/src/modules/ui/types.ts b/webapp/src/modules/ui/types.ts index b693e7ea9..d10ea05e4 100644 --- a/webapp/src/modules/ui/types.ts +++ b/webapp/src/modules/ui/types.ts @@ -1,4 +1,5 @@ export const View = { + DETAIL: 'detail', MARKET: 'market', ACCOUNT: 'account', LISTS: 'lists', diff --git a/webapp/src/modules/vendor/decentraland/BidService.ts b/webapp/src/modules/vendor/decentraland/BidService.ts index efef8fd17..2287a4484 100644 --- a/webapp/src/modules/vendor/decentraland/BidService.ts +++ b/webapp/src/modules/vendor/decentraland/BidService.ts @@ -17,12 +17,12 @@ export class BidService implements BidServiceInterface<VendorName.DECENTRALAND> { async fetchBySeller(seller: string) { const bids = await bidAPI.fetchBySeller(seller) - return bids + return bids.data } async fetchByBidder(bidder: string) { const bids = await bidAPI.fetchByBidder(bidder) - return bids + return bids.data } async fetchByNFT(nft: NFT, status: ListingStatus = ListingStatus.OPEN) { @@ -31,7 +31,7 @@ export class BidService nft.tokenId, status ) - return bids + return bids.data } async place( diff --git a/webapp/src/modules/vendor/decentraland/OrderService.spec.ts b/webapp/src/modules/vendor/decentraland/OrderService.spec.ts index 72cc1a0dd..cd4acb420 100644 --- a/webapp/src/modules/vendor/decentraland/OrderService.spec.ts +++ b/webapp/src/modules/vendor/decentraland/OrderService.spec.ts @@ -1,5 +1,11 @@ import { ethers } from 'ethers' -import { ChainId, ListingStatus, Order } from '@dcl/schemas' +import { + ChainId, + ListingStatus, + Order, + OrderFilters, + OrderSortBy +} from '@dcl/schemas' import { ContractData, ContractName, @@ -34,17 +40,25 @@ describe("Decentraland's OrderService", () => { }) describe('when fetching orders by NFT', () => { - const status = ListingStatus.OPEN + const params: OrderFilters = { + contractAddress: '0x2323233423', + first: 6, + skip: 0, + itemId: '1', + status: ListingStatus.OPEN + } + + const sortBy = OrderSortBy.CHEAPEST describe('when the fetch fails', () => { beforeEach(() => { - ;(orderAPI.orderAPI.fetchByNFT as jest.Mock).mockRejectedValueOnce( + ;(orderAPI.orderAPI.fetchOrders as jest.Mock).mockRejectedValueOnce( aBasicErrorMessage ) }) it('should reject into an exception', () => { - expect(orderService.fetchByNFT(nft, status)).rejects.toBe( + expect(orderService.fetchOrders(params, sortBy)).rejects.toBe( aBasicErrorMessage ) }) @@ -54,13 +68,15 @@ describe("Decentraland's OrderService", () => { const orders = [{ id: 'anOrderId' }] as Order[] beforeEach(() => { - ;(orderAPI.orderAPI.fetchByNFT as jest.Mock).mockResolvedValueOnce( + ;(orderAPI.orderAPI.fetchOrders as jest.Mock).mockResolvedValueOnce( orders ) }) it('should reject into an exception', () => { - expect(orderService.fetchByNFT(nft, status)).resolves.toEqual(orders) + expect(orderService.fetchOrders(params, sortBy)).resolves.toEqual( + orders + ) }) }) }) diff --git a/webapp/src/modules/vendor/decentraland/OrderService.ts b/webapp/src/modules/vendor/decentraland/OrderService.ts index e8ba4fa9d..f95740b97 100644 --- a/webapp/src/modules/vendor/decentraland/OrderService.ts +++ b/webapp/src/modules/vendor/decentraland/OrderService.ts @@ -1,5 +1,5 @@ import { ethers } from 'ethers' -import { ListingStatus, Network, Order } from '@dcl/schemas' +import { Network, Order, OrderFilters, OrderSortBy } from '@dcl/schemas' import { ContractName, getContract, @@ -11,11 +11,15 @@ import { NFT } from '../../nft/types' import { orderAPI } from './order/api' import { VendorName } from '../types' import { OrderService as OrderServiceInterface } from '../services' +import { OrderResponse } from './order/types' export class OrderService implements OrderServiceInterface<VendorName.DECENTRALAND> { - fetchByNFT(nft: NFT, status?: ListingStatus): Promise<Order[]> { - return orderAPI.fetchByNFT(nft.contractAddress, nft.tokenId, status) + async fetchOrders( + params: OrderFilters, + sortBy: OrderSortBy + ): Promise<OrderResponse> { + return orderAPI.fetchOrders(params, sortBy) } async create( diff --git a/webapp/src/modules/vendor/decentraland/bid/api.spec.ts b/webapp/src/modules/vendor/decentraland/bid/api.spec.ts index 6f8268716..50fa6d02a 100644 --- a/webapp/src/modules/vendor/decentraland/bid/api.spec.ts +++ b/webapp/src/modules/vendor/decentraland/bid/api.spec.ts @@ -38,7 +38,7 @@ describe('when fetching bids by nft', () => { expect(bidAPI.request).toHaveBeenCalledWith( 'get', - '/bids?contractAddress=0x123&tokenId=123&status=open&first=1000' + '/bids?contractAddress=0x123&tokenId=123&status=open&first=1000&skip=0' ) }) }) diff --git a/webapp/src/modules/vendor/decentraland/bid/api.ts b/webapp/src/modules/vendor/decentraland/bid/api.ts index 9966b2648..2ef65d39d 100644 --- a/webapp/src/modules/vendor/decentraland/bid/api.ts +++ b/webapp/src/modules/vendor/decentraland/bid/api.ts @@ -1,4 +1,4 @@ -import { Bid, ListingStatus } from '@dcl/schemas' +import { Bid, BidSortBy, ListingStatus } from '@dcl/schemas' import { BaseAPI } from 'decentraland-dapps/dist/lib/api' import { NFT_SERVER_URL } from '../nft' import { retryParams } from '../utils' @@ -6,19 +6,25 @@ import { retryParams } from '../utils' const FIRST = '1000' class BidAPI extends BaseAPI { - async fetch(options: Record<string, string>): Promise<Bid[]> { + async fetch( + options: Record<string, string>, + sortBy?: BidSortBy, + bidder?: string + ): Promise<{ data: Bid[]; total: number }> { const queryParams = new URLSearchParams() for (const key of Object.keys(options)) { queryParams.append(key, options[key]) } + sortBy && queryParams.append('sortBy', sortBy.toString()) + bidder && queryParams.append('bidder', bidder) try { const response: { data: Bid[]; total: number } = await this.request( 'get', `/bids?${queryParams.toString()}` ) - return response.data + return response } catch (error) { - return [] + return { data: [], total: 0 } } } async fetchBySeller(seller: string) { @@ -36,9 +42,23 @@ class BidAPI extends BaseAPI { async fetchByNFT( contractAddress: string, tokenId: string, - status: ListingStatus = ListingStatus.OPEN + status?: ListingStatus | null, + sortBy?: BidSortBy, + first: string = FIRST, + skip: string = '0', + bidder?: string ) { - return this.fetch({ contractAddress, tokenId, status, first: FIRST }) + return this.fetch( + { + contractAddress, + tokenId, + status: status || ListingStatus.OPEN, + first, + skip + }, + sortBy, + bidder + ) } } diff --git a/webapp/src/modules/vendor/decentraland/catalog/api.ts b/webapp/src/modules/vendor/decentraland/catalog/api.ts new file mode 100644 index 000000000..a7c6731d5 --- /dev/null +++ b/webapp/src/modules/vendor/decentraland/catalog/api.ts @@ -0,0 +1,140 @@ +import { BaseClient } from 'decentraland-dapps/dist/lib/BaseClient' +import { Item, CatalogFilters } from '@dcl/schemas' + +export class CatalogAPI extends BaseClient { + async get(filters: CatalogFilters = {}): Promise<Item[]> { + const queryParams = this.buildItemsQueryString(filters) + return this.fetch(`/v1/catalog?${queryParams}`) + } + + private buildItemsQueryString(filters: CatalogFilters): string { + const queryParams = new URLSearchParams() + + if (filters.first) { + queryParams.append('first', filters.first.toString()) + } + + if (filters.skip) { + queryParams.append('skip', filters.skip.toString()) + } + + if (filters.category) { + queryParams.append('category', filters.category) + } + + if (filters.creator) { + let creators = Array.isArray(filters.creator) + ? filters.creator + : [filters.creator] + creators.forEach(creator => queryParams.append('creator', creator)) + } + + if (filters.isSoldOut) { + queryParams.append('isSoldOut', 'true') + } + + if (filters.isOnSale !== undefined) { + queryParams.append('isOnSale', filters.isOnSale.toString()) + } + + if (filters.search) { + queryParams.set('search', filters.search) + } + + if (filters.isWearableHead) { + queryParams.append('isWearableHead', 'true') + } + + if (filters.isWearableSmart) { + queryParams.append('isWearableSmart', 'true') + } + + if (filters.isWearableAccessory) { + queryParams.append('isWearableAccessory', 'true') + } + + if (filters.wearableCategory) { + queryParams.append('wearableCategory', filters.wearableCategory) + } + + if (filters.rarities) { + for (const rarity of filters.rarities) { + queryParams.append('rarity', rarity) + } + } + + if (filters.wearableGenders) { + for (const wearableGender of filters.wearableGenders) { + queryParams.append('wearableGender', wearableGender) + } + } + + if (filters.emoteCategory) { + queryParams.append('emoteCategory', filters.emoteCategory) + } + + if (filters.emotePlayMode) { + for (const emotePlayMode of filters.emotePlayMode) { + queryParams.append('emotePlayMode', emotePlayMode) + } + } + + // if (filters.emoteGenders) { + // filters.emoteGenders.forEach(emoteGender => + // queryParams.append('emoteGender', emoteGender) + // ) + // } + + if (filters.contractAddresses) { + filters.contractAddresses.forEach(contract => + queryParams.append('contractAddress', contract) + ) + } + + if (filters.itemId) { + queryParams.append('itemId', filters.itemId) + } + + if (filters.network) { + queryParams.append('network', filters.network) + } + + if (filters.minPrice) { + queryParams.append('minPrice', filters.minPrice) + } + + if (filters.maxPrice) { + queryParams.append('maxPrice', filters.maxPrice) + } + + if (filters.onlyMinting) { + queryParams.append('onlyMinting', 'true') + } + + if (filters.onlyListing) { + queryParams.append('onlyListing', 'true') + } + + if (filters.sortBy) { + queryParams.append('sortBy', filters.sortBy) + } + + if (filters.sortDirection) { + queryParams.append('sortDirection', filters.sortDirection) + } + + if (filters.limit) { + queryParams.append('limit', filters.limit.toString()) + } + + if (filters.offset) { + queryParams.append('offset', filters.offset.toString()) + } + + if (filters.ids) { + filters.ids.forEach(id => queryParams.append('id', id)) + } + + return queryParams.toString() + } +} diff --git a/webapp/src/modules/vendor/decentraland/item/api.ts b/webapp/src/modules/vendor/decentraland/item/api.ts index df8226e19..369225ff1 100644 --- a/webapp/src/modules/vendor/decentraland/item/api.ts +++ b/webapp/src/modules/vendor/decentraland/item/api.ts @@ -1,6 +1,7 @@ import { Item } from '@dcl/schemas' import { BaseClient } from 'decentraland-dapps/dist/lib/BaseClient' -import { ItemFilters, ItemResponse } from './types' +import { ItemFilters } from './types' +import { ItemResponse } from './types' export const DEFAULT_TRENDING_PAGE_SIZE = 20 @@ -16,7 +17,7 @@ export class ItemAPI extends BaseClient { async getOne(contractAddress: string, itemId: string): Promise<Item> { const queryParams = this.buildItemsQueryString({ - contracts: [contractAddress], + contractAddresses: [contractAddress], itemId }) const response: ItemResponse = await this.fetch( @@ -101,8 +102,8 @@ export class ItemAPI extends BaseClient { if (filters.ids) { filters.ids.forEach(id => queryParams.append('id', id)) } - if (filters.contracts) { - filters.contracts.forEach(contract => + if (filters.contractAddresses) { + filters.contractAddresses.forEach(contract => queryParams.append('contractAddress', contract) ) } diff --git a/webapp/src/modules/vendor/decentraland/item/types.ts b/webapp/src/modules/vendor/decentraland/item/types.ts index 80336051c..57753eeb5 100644 --- a/webapp/src/modules/vendor/decentraland/item/types.ts +++ b/webapp/src/modules/vendor/decentraland/item/types.ts @@ -1,40 +1,12 @@ import { - EmoteCategory, - EmotePlayMode, - GenderFilterOption, Item, ItemSortBy, - Network, - NFTCategory, - Rarity, - WearableCategory, - WearableGender + CatalogSortBy, + ItemFilters as ItemFiltersSchema } from '@dcl/schemas' -export type ItemFilters = { - first?: number - skip?: number - sortBy?: ItemSortBy - creator?: string | string[] - category?: NFTCategory - isSoldOut?: boolean - isOnSale?: boolean - isOnRent?: boolean - search?: string - isWearableHead?: boolean - isWearableAccessory?: boolean - isWearableSmart?: boolean - wearableCategory?: WearableCategory - emoteCategory?: EmoteCategory - emotePlayMode?: EmotePlayMode[] - rarities?: Rarity[] - wearableGenders?: (WearableGender | GenderFilterOption)[] - ids?: string[] - contracts?: string[] - itemId?: string - network?: Network - minPrice?: string - maxPrice?: string +export type ItemFilters = Omit<ItemFiltersSchema, 'sortBy'> & { + sortBy?: ItemSortBy | CatalogSortBy } export type ItemResponse = { diff --git a/webapp/src/modules/vendor/decentraland/nft/api.ts b/webapp/src/modules/vendor/decentraland/nft/api.ts index 21d8a3ff7..dc08dab69 100644 --- a/webapp/src/modules/vendor/decentraland/nft/api.ts +++ b/webapp/src/modules/vendor/decentraland/nft/api.ts @@ -9,6 +9,10 @@ import { getNFTSortBy } from '../../../routing/search' import { AssetType } from '../../../asset/types' import { config } from '../../../../config' import { retryParams } from '../utils' +import { + OwnersFilters, + OwnersResponse +} from './types' export const NFT_SERVER_URL = config.get('NFT_SERVER_URL')! @@ -241,6 +245,25 @@ class NFTAPI extends BaseAPI { return queryParams.toString() } + + async getOwners( + params: OwnersFilters + ): Promise<{ data: OwnersResponse[]; total: number }> { + const queryParams = this.buildGetOwnersParams(params) + return this.request('get', `/owners?${queryParams}`) + } + + private buildGetOwnersParams(filters: OwnersFilters): string { + const queryParams = new URLSearchParams() + + const entries = Object.entries(filters) + + for (let [key, value] of entries) { + queryParams.append(key, value.toString()) + } + + return queryParams.toString() + } } export const nftAPI = new NFTAPI(NFT_SERVER_URL, retryParams) diff --git a/webapp/src/modules/vendor/decentraland/nft/types.ts b/webapp/src/modules/vendor/decentraland/nft/types.ts index 45c22c8e4..df5ff490c 100644 --- a/webapp/src/modules/vendor/decentraland/nft/types.ts +++ b/webapp/src/modules/vendor/decentraland/nft/types.ts @@ -52,3 +52,25 @@ export type NFTResponse = { } export type NFTData = BaseNFT['data'] + + +export enum OwnersSortBy { + ISSUED_ID = 'issuedId' +} + +export type OwnersResponse = { + issuedId: number + ownerId: string + orderStatus: string | null + orderExpiresAt: string | null + tokenId: string +} + +export type OwnersFilters = { + contractAddress: string + itemId: string + first: number + skip: number + sortBy?: OwnersSortBy + orderDirection?: string +} diff --git a/webapp/src/modules/vendor/decentraland/order/api.ts b/webapp/src/modules/vendor/decentraland/order/api.ts index 9dddae03a..bc26177cc 100644 --- a/webapp/src/modules/vendor/decentraland/order/api.ts +++ b/webapp/src/modules/vendor/decentraland/order/api.ts @@ -1,20 +1,43 @@ -import { ListingStatus, Order } from '@dcl/schemas' +import { OrderFilters, OrderSortBy } from '@dcl/schemas' import { BaseAPI } from 'decentraland-dapps/dist/lib/api' import { NFT_SERVER_URL } from '../nft' -import { retryParams } from '../utils'; +import { retryParams } from '../utils' +import { OrderResponse } from './types' class OrderAPI extends BaseAPI { - async fetchByNFT( - contractAddress: string, - tokenId: string, - status?: ListingStatus - ): Promise<Order[]> { - const response: { data: Order[]; total: number } = await this.request( - 'get', - '/orders', - { contractAddress, tokenId, status } - ) - return response.data + private buildOrdersQueryString( + params: OrderFilters, + sortBy: OrderSortBy + ): string { + const queryParams = new URLSearchParams() + params.first && queryParams.append('first', params.first.toString()) + params.skip && queryParams.append('skip', params.skip.toString()) + params.marketplaceAddress && + queryParams.append( + 'marketplaceAddress', + params.marketplaceAddress.toString() + ) + params.owner && queryParams.append('owner', params.owner.toString()) + params.buyer && queryParams.append('buyer', params.buyer.toString()) + params.contractAddress && + queryParams.append('contractAddress', params.contractAddress.toString()) + params.tokenId && queryParams.append('tokenId', params.tokenId.toString()) + params.status && queryParams.append('status', params.status.toString()) + params.network && queryParams.append('network', params.network.toString()) + params.itemId && queryParams.append('itemId', params.itemId.toString()) + params.nftName && queryParams.append('nftName', params.nftName.toString()) + sortBy && queryParams.append('sortBy', sortBy.toString()) + + return queryParams.toString() + } + + async fetchOrders( + params: OrderFilters, + sortBy: OrderSortBy + ): Promise<OrderResponse> { + const queryParams = this.buildOrdersQueryString(params, sortBy) + + return this.request('get', `/orders?${queryParams}`) } } diff --git a/webapp/src/modules/vendor/decentraland/order/types.ts b/webapp/src/modules/vendor/decentraland/order/types.ts new file mode 100644 index 000000000..58dfa28d4 --- /dev/null +++ b/webapp/src/modules/vendor/decentraland/order/types.ts @@ -0,0 +1,6 @@ +import { Order } from '@dcl/schemas' + +export type OrderResponse = { + data: Order[] + total: number +} diff --git a/webapp/src/modules/vendor/services.ts b/webapp/src/modules/vendor/services.ts index c8dd5c415..3ebcc088b 100644 --- a/webapp/src/modules/vendor/services.ts +++ b/webapp/src/modules/vendor/services.ts @@ -4,12 +4,15 @@ import { ListingStatus, NFTCategory, Order, + OrderFilters, + OrderSortBy, RentalListing } from '@dcl/schemas' import { Wallet } from 'decentraland-dapps/dist/modules/wallet/types' import { NFT, NFTsFetchParams, NFTsCountParams } from '../nft/types' import { Account } from '../account/types' import { AnalyticsTimeframe, AnalyticsVolumeData } from '../analytics/types' +import { OrderResponse } from './decentraland/order/types' import { NFTsFetchFilters } from './nft/types' import { VendorName, TransferType, FetchOneOptions } from './types' @@ -49,7 +52,10 @@ export interface NFTService<V extends VendorName> { export class NFTService<V> {} export interface OrderService<V extends VendorName> { - fetchByNFT: (nft: NFT<V>, status?: ListingStatus) => Promise<Order[]> + fetchOrders: ( + params: OrderFilters, + sortBy: OrderSortBy + ) => Promise<OrderResponse> create: ( wallet: Wallet | null, nft: NFT<V>, diff --git a/webapp/src/setupTests.ts b/webapp/src/setupTests.ts index 8c41b5556..cd34962e0 100644 --- a/webapp/src/setupTests.ts +++ b/webapp/src/setupTests.ts @@ -24,6 +24,16 @@ jest.mock('decentraland-dapps/dist/modules/translation/utils', () => { } }) +jest.mock('decentraland-dapps/dist/modules/translation/utils', () => { + const module = jest.requireActual( + 'decentraland-dapps/dist/modules/translation/utils' + ) + return { + ...module, + T: ({ id, values }: typeof module['T']) => module.t(id, values) + } +}) + config({ path: path.resolve(process.cwd(), '.env.example') }) global.TextEncoder = TextEncoder global.TextDecoder = TextDecoder as any diff --git a/webapp/src/themes/components/Navigation.css b/webapp/src/themes/components/Navigation.css index 435d9938a..3f5b69b9a 100644 --- a/webapp/src/themes/components/Navigation.css +++ b/webapp/src/themes/components/Navigation.css @@ -1,3 +1,7 @@ +.dcl.tabs { + margin-bottom: 0; +} + .dcl.tabs a { color: var(--text); } diff --git a/webapp/src/utils/filters.tsx b/webapp/src/utils/filters.tsx index 2af7c02ad..3847ffee8 100644 --- a/webapp/src/utils/filters.tsx +++ b/webapp/src/utils/filters.tsx @@ -9,6 +9,13 @@ import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { Mana } from '../components/Mana' import { LANDFilters } from '../components/Vendor/decentraland/types' +export enum AssetStatusFilter { + ON_SALE = 'on_sale', + ONLY_MINTING = 'only_minting', + ONLY_LISTING = 'only_listing', + NOT_FOR_SALE = 'not_for_sale' +} + export const AVAILABLE_FOR_MALE = 'AVAILABLE_FOR_MALE' export const AVAILABLE_FOR_FEMALE = 'AVAILABLE_FOR_FEMALE' diff --git a/webapp/src/utils/tests.tsx b/webapp/src/utils/tests.tsx new file mode 100644 index 000000000..910877ff3 --- /dev/null +++ b/webapp/src/utils/tests.tsx @@ -0,0 +1,84 @@ +import { render } from '@testing-library/react' +import createSagasMiddleware from 'redux-saga' +import { createMemoryHistory } from 'history' +import { Provider } from 'react-redux' +import { applyMiddleware, compose, createStore, Store } from 'redux' +import { ConnectedRouter, routerMiddleware } from 'connected-react-router' +import { createStorageMiddleware } from 'decentraland-dapps/dist/modules/storage/middleware' +import { storageReducerWrapper } from 'decentraland-dapps/dist/modules/storage/reducer' +import { createTransactionMiddleware } from 'decentraland-dapps/dist/modules/transaction/middleware' +import { CLEAR_TRANSACTIONS } from 'decentraland-dapps/dist/modules/transaction/actions' +import TranslationProvider from 'decentraland-dapps/dist/providers/TranslationProvider' +import { createRootReducer, RootState } from '../modules/reducer' +import * as locales from '../modules/translation/locales' +import { ARCHIVE_BID, UNARCHIVE_BID } from '../modules/bid/actions' +import { GENERATE_IDENTITY_SUCCESS } from '../modules/identity/actions' +import { SET_IS_TRYING_ON } from '../modules/ui/preview/actions' +import { rootSaga } from '../modules/sagas' +import { fetchTilesRequest } from '../modules/tile/actions' + +export const history = require('history').createBrowserHistory() + +export function initTestStore(preloadedState = {}) { + const rootReducer = storageReducerWrapper(createRootReducer(history)) + const sagasMiddleware = createSagasMiddleware() + const transactionMiddleware = createTransactionMiddleware() + const { storageMiddleware, loadStorageMiddleware } = createStorageMiddleware({ + storageKey: 'marketplace-v2', // this is the key used to save the state in localStorage (required) + paths: [ + ['ui', 'archivedBidIds'], + ['ui', 'preview', 'isTryingOn'], + ['identity', 'data'] + ], // array of paths from state to be persisted (optional) + actions: [ + CLEAR_TRANSACTIONS, + ARCHIVE_BID, + UNARCHIVE_BID, + GENERATE_IDENTITY_SUCCESS, + SET_IS_TRYING_ON + ], // array of actions types that will trigger a SAVE (optional) + migrations: {} // migration object that will migrate your localstorage (optional) + }) + + const middleware = applyMiddleware( + sagasMiddleware, + routerMiddleware(history), + transactionMiddleware, + storageMiddleware + ) + const enhancer = compose(middleware) + const store = createStore(rootReducer, preloadedState, enhancer) + + sagasMiddleware.run(rootSaga, () => undefined) + loadStorageMiddleware(store) + store.dispatch(fetchTilesRequest()) + + return store +} + +export function renderWithProviders( + component: JSX.Element, + { preloadedState, store }: { preloadedState?: RootState; store?: Store } = {} +) { + const initializedStore = + store || + initTestStore({ + ...(preloadedState || {}), + storage: { loading: false }, + translation: { data: locales, locale: 'en' } + }) + + const history = createMemoryHistory() + + function AppProviders({ children }: { children: JSX.Element }) { + return ( + <Provider store={initializedStore}> + <TranslationProvider locales={Object.keys(locales)}> + <ConnectedRouter history={history}>{children}</ConnectedRouter> + </TranslationProvider> + </Provider> + ) + } + + return render(component, { wrapper: AppProviders }) +}