diff --git a/package.json b/package.json index ab3d5469..cb9ae9e7 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "redux-thunk": "^2.2.0", "sanitize.css": "^5.0.0", "sharetribe-scripts": "0.9.2", - "sharetribe-sdk": "git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#ec9fd243307ce21b95052535d2e358f681ed82b7", + "sharetribe-sdk": "git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#75c5b1a9df5142a1066cd055c2157b410f4c4aee", "source-map-support": "^0.4.15", "url": "^0.11.0" }, diff --git a/src/components/ManageListingCard/ManageListingCard.css b/src/components/ManageListingCard/ManageListingCard.css index 5f33283c..28fc309a 100644 --- a/src/components/ManageListingCard/ManageListingCard.css +++ b/src/components/ManageListingCard/ManageListingCard.css @@ -1,9 +1,20 @@ @import '../../marketplace.css'; +:root { + --coverEverything: { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + } +} + .root { /* Layout */ display: flex; flex-direction: column; + position: relative; /* Remove link's hover effect */ &:hover { @@ -11,37 +22,176 @@ } } +.menuOverlayWrapper { + /* Positioning */ + @apply --coverEverything; +} + +.menuOverlay { + /* Positioning */ + @apply --coverEverything; + + /* Overlay */ + background-color: var(--matterColorAnti); + mix-blend-mode: multiply; + opacity: 0; + transition: var(--transitionStyleButton); +} + +.menuOverlayContent { + /* Positioning */ + @apply --coverEverything; + /* Fonts */ + @apply --marketplaceH4FontStyles; + font-weight: var(--fontWeightSemiBold); + color: var(--matterColorLight); + + /* Layout */ + display: flex; + justify-content: center; + align-items: center; + margin-top: 0; + margin-bottom: 0; + opacity: 0; + + @media (--viewportMedium) { + font-weight: var(--fontWeightSemiBold); + margin-top: 0; + margin-bottom: 0; + } +} + .threeToTwoWrapper { /* Layout */ display: block; width: 100%; position: relative; + + &:hover .menuOverlay { + opacity: 0.8; + } + + &:hover .menuOverlayContent { + opacity: 1.0; + } +} + +.menuOverlayOpen { + & .menuOverlay { + opacity: 0.8; + } + + & .menuOverlayContent { + opacity: 0.8; + } } /* Firefox doesn't support image aspect ratio inside flexbox */ .aspectWrapper { padding-bottom: 66.6667%; /* 3:2 Aspect Ratio */ background: var(--matterColorNegative); /* Loading BG color */ - transition: var(--transitionStyleButton); - - &:hover { - transform: scale(1.02); - box-shadow: var(--boxShadowListingCard); - transition: var(--transitionStyleButton); - } } .rootForImage { /* Layout - image will take space defined by aspect ratio wrapper */ - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; + @apply --coverEverything; width: 100%; border-radius: 2px; } +.menubarWrapper { + /* Position */ + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 42px; +} + +.menubarGradient { + /* Position */ + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + /* Gradient */ + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAqCAYAAACUV/o4AAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAAGhJREFUKBVjZGBgKANibiAGga8sQAKEf4N4QMAC4vwH4r8gHgiABOAcmMAfEAMGsKrA0DIqAAsvHGFKkwACRRQjzGJQRP2DcUA0LOZAEQ5S9R8mAJIEA6wCyIYSqYVSz4FcxwR1E5gCAD7HLabS/HFUAAAAAElFTkSuQmCC'); + background-repeat: repeat; + opacity: 0.5; +} + +.menubar { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: flex-end; +} + +.menuLabel { + padding: 8px 8px 0 8px; +} + +.iconWrapper { + padding: 2px 10px 8px 10px; + color: var(--matterColorLight); + border-radius: 2px 2px 0 0; +} + +.listingMenuIsOpen { + & .iconWrapper { + background-color: var(--matterColorLight); + color: var(--matterColor); + } +} + +.menuContent { + visibility:hidden; + opacity: 0; + pointer-events: none; + + position: absolute; + z-index: var(--zIndexPopup); + + background-color: var(--matterColorLight); + border-radius: 2px 0 2px 2px; +} + +.menuItem { + @apply --marketplaceH4FontStyles; + color: var(--matterColor); + white-space: nowrap; + text-align: left; + min-width: 175px; + padding: 8px 14px; + + /* Remove default margins from font */ + margin-top: 0; + margin-bottom: 0; + + &:hover { + color: var(--failColor); + text-decoration: none; + } + + @media (--viewportMedium) { + margin-top: 0; + margin-bottom: 0; + } +} + +.menuItemDisabled { + color: var(--matterColorNegative); + + &:hover { + color: var(--matterColorNegative); + } +} + .info { /* Layout */ display: flex; @@ -125,3 +275,57 @@ background-repeat: no-repeat; background-position: 15px center; } + +.loadingOverlayWrapper, +.closedOverlayWrapper { + /* Positioning */ + @apply --coverEverything; +} + +.loadingOverlay, +.closedOverlay { + /* Positioning */ + @apply --coverEverything; + + /* Overlay background style */ + background-color: var(--matterColorLight); + opacity: 0.9; +} + +.loadingOverlayContent, +.closedOverlayContent { + @apply --marketplaceTinyFontStyles; + color: var(--matterColor); + + /* Positioning */ + @apply --coverEverything; + + /* Layout */ + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + cursor: default; + + /* Remove default margins from font */ + margin-top: 0; + margin-bottom: 0; + + @media (--viewportMedium) { + margin-top: 0; + margin-bottom: 0; + } +} + +.closedMessage { + max-width: 180px; + text-align: center; + margin-bottom: 16px; +} + +.openListingButton { + @apply --marketplaceH5FontStyles; + width: 114px; + height: 41px; + padding: 8px 0; +} diff --git a/src/components/ManageListingCard/ManageListingCard.example.js b/src/components/ManageListingCard/ManageListingCard.example.js index 895ffa3e..409a6432 100644 --- a/src/components/ManageListingCard/ManageListingCard.example.js +++ b/src/components/ManageListingCard/ManageListingCard.example.js @@ -3,6 +3,7 @@ import React from 'react'; import ManageListingCard from './ManageListingCard'; import { createOwnListing, fakeIntl } from '../../util/test-data'; +const noop = () => null; const listing = { ...createOwnListing('listing1') }; const ManageListingCardWrapper = props => ( @@ -16,5 +17,11 @@ export const ManageListingCardWrapped = { props: { intl: fakeIntl, listing, + isMenuOpen: false, + onCloseListing: noop, + onOpenListing: noop, + onToggleMenu: noop, + flattenedRoutes: [], + history: { push: noop }, }, }; diff --git a/src/components/ManageListingCard/ManageListingCard.js b/src/components/ManageListingCard/ManageListingCard.js index 9bda59d2..cb49883c 100644 --- a/src/components/ManageListingCard/ManageListingCard.js +++ b/src/components/ManageListingCard/ManageListingCard.js @@ -9,11 +9,26 @@ import { ensureListing } from '../../util/data'; import { createSlug } from '../../util/urlHelpers'; import { withFlattenedRoutes } from '../../util/contextHelpers'; import { createResourceLocatorString } from '../../util/routes'; -import { NamedLink, SecondaryButton, ResponsiveImage } from '../../components'; +import { + InlineTextButton, + Menu, + MenuLabel, + MenuContent, + MenuItem, + NamedLink, + PrimaryButton, + SecondaryButton, + SpinnerIcon, + ResponsiveImage, +} from '../../components'; import config from '../../config'; +import MenuIcon from './MenuIcon'; import css from './ManageListingCard.css'; +// Menu content needs the same padding +const MENU_CONTENT_OFFSET = 8; + const priceData = (price, intl) => { if (price && price.currency === config.currency) { const formattedPrice = formatMoney(intl, price); @@ -48,20 +63,77 @@ export const ManageListingCardComponent = props => { flattenedRoutes, history, intl, + isMenuOpen, + actionsInProgressListingId, listing, + onCloseListing, + onOpenListing, + onToggleMenu, } = props; const classes = classNames(rootClassName || css.root, className); const currentListing = ensureListing(listing); const id = currentListing.id.uuid; - const { title = '', price } = currentListing.attributes; + const { title = '', price, open } = currentListing.attributes; const slug = createSlug(title); const firstImage = currentListing.images && currentListing.images.length > 0 ? currentListing.images[0] : null; + const menuItemClasses = classNames(css.menuItem, { + [css.menuItemDisabled]: !!actionsInProgressListingId, + }); + // TODO: Currently, API can return currencies that are not supported by starter app. const { formattedPrice, priceTitle } = priceData(price, intl); + /* eslint-disable jsx-a11y/no-static-element-interactions */ + const closedOverlay = open + ? null + :
{ + event.preventDefault(); + event.stopPropagation(); + }} + > +
+
+
+ +
+ { + event.preventDefault(); + event.stopPropagation(); + if (!actionsInProgressListingId) { + onOpenListing(currentListing.id); + } + }} + > + + +
+
; + + const thisInProgress = actionsInProgressListingId && actionsInProgressListingId.uuid === id; + const loadingOrClosedOverlay = thisInProgress + ?
{ + event.preventDefault(); + event.stopPropagation(); + }} + > +
+
+ +
+
+ : closedOverlay; + /* eslint-enable jsx-a11y/no-static-element-interactions */ + return (
@@ -76,6 +148,50 @@ export const ManageListingCardComponent = props => { ]} />
+
+
+
+ +
+
+
+
+
+ { + const listingOpen = isOpen ? currentListing : null; + onToggleMenu(listingOpen); + }} + isOpen={isMenuOpen} + > + +
+ +
+
+ + + { + event.preventDefault(); + event.stopPropagation(); + if (!actionsInProgressListingId) { + onToggleMenu(null); + onCloseListing(currentListing.id); + } + }} + > + + + + +
+
+
@@ -102,6 +218,7 @@ export const ManageListingCardComponent = props => {
+ {loadingOrClosedOverlay} ); }; @@ -109,15 +226,21 @@ export const ManageListingCardComponent = props => { ManageListingCardComponent.defaultProps = { className: null, rootClassName: null, + actionsInProgressListingId: null, }; -const { arrayOf, func, shape, string } = PropTypes; +const { arrayOf, bool, func, shape, string } = PropTypes; ManageListingCardComponent.propTypes = { className: string, rootClassName: string, intl: intlShape.isRequired, listing: propTypes.listing.isRequired, + isMenuOpen: bool.isRequired, + actionsInProgressListingId: shape({ uuid: string.isRequired }), + onCloseListing: func.isRequired, + onOpenListing: func.isRequired, + onToggleMenu: func.isRequired, // from withFlattenedRoutes flattenedRoutes: arrayOf(propTypes.route).isRequired, diff --git a/src/components/ManageListingCard/ManageListingCard.test.js b/src/components/ManageListingCard/ManageListingCard.test.js index b15f7766..6003167a 100644 --- a/src/components/ManageListingCard/ManageListingCard.test.js +++ b/src/components/ManageListingCard/ManageListingCard.test.js @@ -14,6 +14,10 @@ describe('ManageListingCard', () => { history={{ push: noop }} listing={listing} intl={fakeIntl} + isMenuOpen={false} + onCloseListing={noop} + onOpenListing={noop} + onToggleMenu={noop} /> ); expect(tree).toMatchSnapshot(); diff --git a/src/components/ManageListingCard/MenuIcon.js b/src/components/ManageListingCard/MenuIcon.js new file mode 100644 index 00000000..00c1b3ca --- /dev/null +++ b/src/components/ManageListingCard/MenuIcon.js @@ -0,0 +1,54 @@ +import React, { PropTypes } from 'react'; +import classNames from 'classnames'; + +import css from './ManageListingCard.css'; + +const MenuIcon = props => { + const { className, isActive } = props; + const classes = classNames(css.menuIcon, className); + const filter = isActive ? '' : 'url(#a)'; + return ( + + + + + + + + + + + + + + ); +}; + +MenuIcon.defaultProps = { + className: null, + isActive: false, +}; + +const { bool, string } = PropTypes; + +MenuIcon.propTypes = { + className: string, + isActive: bool, +}; + +export default MenuIcon; diff --git a/src/components/ManageListingCard/__snapshots__/ManageListingCard.test.js.snap b/src/components/ManageListingCard/__snapshots__/ManageListingCard.test.js.snap index 9a1dac2e..b8ce0d45 100644 --- a/src/components/ManageListingCard/__snapshots__/ManageListingCard.test.js.snap +++ b/src/components/ManageListingCard/__snapshots__/ManageListingCard.test.js.snap @@ -30,6 +30,62 @@ exports[`ManageListingCard matches snapshot 1`] = ` rootClassName={null} sizes={null} />
+
+
+
+ +
+
+
+
+
+ + +
+ +
+
+ + + + + + + +
+
+
diff --git a/src/components/Menu/Menu.js b/src/components/Menu/Menu.js index 17b85707..6f5abbff 100644 --- a/src/components/Menu/Menu.js +++ b/src/components/Menu/Menu.js @@ -24,6 +24,12 @@ import css from './Menu.css'; const KEY_CODE_ESCAPE = 27; const CONTENT_PLACEMENT_OFFSET = 0; +const CONTENT_TO_LEFT = 'left'; +const CONTENT_TO_RIGHT = 'right'; + +const isControlledMenu = (isOpenProp, onToggleActiveProp) => { + return isOpenProp !== null && onToggleActiveProp !== null; +}; // This should work, but it doesn't
{}} role="button" /> /* eslint-disable jsx-a11y/no-static-element-interactions */ @@ -33,6 +39,16 @@ class Menu extends Component { this.state = { isOpen: false }; + const { isOpen, onToggleActive } = props; + const isIndependentMenu = isOpen === null && onToggleActive === null; + if (!(isIndependentMenu || isControlledMenu(isOpen, onToggleActive))) { + throw new Error( + `Menu has invalid props: + Both isOpen and onToggleActive need to be defined (controlled menu), + or neither of them (menu uses its own state management).` + ); + } + this.onBlur = this.onBlur.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.toggleOpen = this.toggleOpen.bind(this); @@ -60,20 +76,30 @@ class Menu extends Component { } toggleOpen(enforcedState) { - this.setState(prevState => { - const isOpen = enforcedState != null ? enforcedState : !prevState.isOpen; - return { isOpen }; - }); + // If state is handled outside of Menu component, we call a passed in onToggleActive func + const { isOpen, onToggleActive } = this.props; + if (isControlledMenu(isOpen, onToggleActive)) { + const isMenuOpen = enforcedState != null ? enforcedState : !isOpen; + onToggleActive(isMenuOpen); + } else { + // If state is handled inside of Menu component, set state + this.setState(prevState => { + const isMenuOpen = enforcedState != null ? enforcedState : !prevState.isOpen; + return { isOpen: isMenuOpen }; + }); + } } - positionStyleForMenuContent() { + positionStyleForMenuContent(contentPosition) { if (this.menu && this.menuContent) { // Calculate wether we should show the menu to the left of the component or right const distanceToRight = window.innerWidth - this.menu.getBoundingClientRect().right; const menuWidth = this.menu.offsetWidth; const contentWidthBiggerThanLabel = this.menuContent.offsetWidth - menuWidth; - return distanceToRight < contentWidthBiggerThanLabel - ? { right: -1 * CONTENT_PLACEMENT_OFFSET, minWidth: menuWidth } + const usePositionRightFromLabel = contentPosition === CONTENT_TO_LEFT; + const contentPlacementOffset = this.props.contentPlacementOffset; + return usePositionRightFromLabel || distanceToRight < contentWidthBiggerThanLabel + ? { right: contentPlacementOffset, minWidth: menuWidth } : { left: 0, minWidth: menuWidth }; } return {}; @@ -82,8 +108,9 @@ class Menu extends Component { positionStyleForArrow(isPositionedRight) { if (this.menu) { const menuWidth = this.menu.offsetWidth; + const contentPlacementOffset = this.props.contentPlacementOffset; return isPositionedRight - ? Math.floor(menuWidth / 2) + CONTENT_PLACEMENT_OFFSET + ? Math.floor(menuWidth / 2) - contentPlacementOffset : Math.floor(menuWidth / 2); } return 0; @@ -95,23 +122,30 @@ class Menu extends Component { } return React.Children.map(this.props.children, child => { + const { isOpen: isOpenProp, onToggleActive } = this.props; + const isOpen = isControlledMenu(isOpenProp, onToggleActive) ? isOpenProp : this.state.isOpen; + if (child.type === MenuLabel) { // MenuLabel needs toggleOpen function // We pass that directly so that component user doesn't need to worry about that return React.cloneElement(child, { - isOpen: this.state.isOpen, + isOpen, onToggleActive: this.toggleOpen, }); } else if (child.type === MenuContent) { // MenuContent needs some styling data (width, arrowPosition, and isOpen info) // We pass those directly so that component user doesn't need to worry about those. - const positionStyles = this.positionStyleForMenuContent(); + const { contentPosition, useArrow } = this.props; + const positionStyles = this.positionStyleForMenuContent(contentPosition); + const arrowPosition = useArrow + ? this.positionStyleForArrow(positionStyles.right != null) + : null; return React.cloneElement(child, { - arrowPosition: this.positionStyleForArrow(positionStyles.right != null), + arrowPosition, contentRef: node => { this.menuContent = node; }, - isOpen: this.state.isOpen, + isOpen, style: { ...child.props.style, ...positionStyles }, }); } else { @@ -142,14 +176,27 @@ class Menu extends Component { } /* eslint-enable jsx-a11y/no-static-element-interactions */ -Menu.defaultProps = { className: null, rootClassName: '' }; +Menu.defaultProps = { + className: null, + rootClassName: '', + contentPlacementOffset: CONTENT_PLACEMENT_OFFSET, + contentPosition: CONTENT_TO_RIGHT, + isOpen: null, + onToggleActive: null, + useArrow: true, +}; -const { node, string } = PropTypes; +const { bool, func, node, number, string } = PropTypes; Menu.propTypes = { children: node.isRequired, className: string, rootClassName: string, + contentPosition: string, + contentPlacementOffset: number, + useArrow: bool, + isOpen: bool, + onToggleActive: func, }; export default Menu; diff --git a/src/components/MenuLabel/MenuLabel.js b/src/components/MenuLabel/MenuLabel.js index 38fb045e..db85ca89 100644 --- a/src/components/MenuLabel/MenuLabel.js +++ b/src/components/MenuLabel/MenuLabel.js @@ -17,6 +17,8 @@ class MenuLabel extends Component { } onClick(e) { + e.stopPropagation(); + e.preventDefault(); this.props.onToggleActive(); // Don't show focus outline if user just clicked the element with mouse diff --git a/src/components/SpinnerIcon/SpinnerIcon.css b/src/components/SpinnerIcon/SpinnerIcon.css new file mode 100644 index 00000000..c3a3cc1f --- /dev/null +++ b/src/components/SpinnerIcon/SpinnerIcon.css @@ -0,0 +1,7 @@ +@import '../../marketplace.css'; + +.root { + width: 100px; + height: 100px; + stroke: var(--marketplaceColor); +} diff --git a/src/components/SpinnerIcon/SpinnerIcon.js b/src/components/SpinnerIcon/SpinnerIcon.js new file mode 100644 index 00000000..f5b40e95 --- /dev/null +++ b/src/components/SpinnerIcon/SpinnerIcon.js @@ -0,0 +1,55 @@ +import React, { PropTypes } from 'react'; +import classNames from 'classnames'; + +import css from './SpinnerIcon.css'; + +// TODO: SVG needs to be changed so that it doesn't take 100x100 area, +// but just a little bit more than what actual spinner takes (~26px) +const SpinnerIcon = props => { + const { rootClassName, className } = props; + const classes = classNames(rootClassName || css.root, className); + return ( + + + + + + + ); +}; + +SpinnerIcon.defaultProps = { + rootClassName: null, + className: null, +}; + +const { string } = PropTypes; + +SpinnerIcon.propTypes = { + rootClassName: string, + className: string, +}; + +export default SpinnerIcon; diff --git a/src/components/index.js b/src/components/index.js index f48a623c..b547b49a 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -49,6 +49,7 @@ import SearchMapInfoCard from './SearchMapInfoCard/SearchMapInfoCard'; import SearchMapPriceLabel from './SearchMapPriceLabel/SearchMapPriceLabel'; import SearchResultsPanel from './SearchResultsPanel/SearchResultsPanel'; import SelectField from './SelectField/SelectField'; +import SpinnerIcon from './SpinnerIcon/SpinnerIcon'; import StripeBankAccountTokenInputField from './StripeBankAccountTokenInputField/StripeBankAccountTokenInputField'; import TabNav from './TabNav/TabNav'; @@ -117,6 +118,7 @@ export { SearchResultsPanel, SecondaryButton, SelectField, + SpinnerIcon, StripeBankAccountTokenInputField, TabNav, TabNavHorizontal, diff --git a/src/containers/ManageListingsPage/ManageListingsPage.duck.js b/src/containers/ManageListingsPage/ManageListingsPage.duck.js index a2712f3d..4591280b 100644 --- a/src/containers/ManageListingsPage/ManageListingsPage.duck.js +++ b/src/containers/ManageListingsPage/ManageListingsPage.duck.js @@ -6,6 +6,14 @@ export const FETCH_LISTINGS_REQUEST = 'app/ManageListingsPage/FETCH_LISTINGS_REQ export const FETCH_LISTINGS_SUCCESS = 'app/ManageListingsPage/FETCH_LISTINGS_SUCCESS'; export const FETCH_LISTINGS_ERROR = 'app/ManageListingsPage/FETCH_LISTINGS_ERROR'; +export const OPEN_LISTING_REQUEST = 'app/ManageListingsPage/OPEN_LISTING_REQUEST'; +export const OPEN_LISTING_SUCCESS = 'app/ManageListingsPage/OPEN_LISTING_SUCCESS'; +export const OPEN_LISTING_ERROR = 'app/ManageListingsPage/OPEN_LISTING_ERROR'; + +export const CLOSE_LISTING_REQUEST = 'app/ManageListingsPage/CLOSE_LISTING_REQUEST'; +export const CLOSE_LISTING_SUCCESS = 'app/ManageListingsPage/CLOSE_LISTING_SUCCESS'; +export const CLOSE_LISTING_ERROR = 'app/ManageListingsPage/CLOSE_LISTING_ERROR'; + export const ADD_OWN_ENTITIES = 'app/ManageListingsPage/ADD_OWN_ENTITIES'; // ================ Reducer ================ // @@ -17,6 +25,10 @@ const initialState = { queryListingsError: null, currentPageResultIds: [], ownEntities: {}, + openingListing: null, + openingListingError: null, + closingListing: null, + closingListingError: null, }; const resultIds = data => data.data.map(l => l.id); @@ -28,6 +40,19 @@ const merge = (state, apiResponse) => { }; }; +const updateListingAttributes = (state, listingEntity) => { + const oldListing = state.ownEntities.listing[listingEntity.id.uuid]; + const updatedListing = { ...oldListing, attributes: listingEntity.attributes }; + const ownListingEntities = { + ...state.ownEntities.listing, + [listingEntity.id.uuid]: updatedListing, + }; + return { + ...state, + ownEntities: { ...state.ownEntities, listing: ownListingEntities }, + }; +}; + const manageListingsPageReducer = (state = initialState, action = {}) => { const { type, payload } = action; switch (type) { @@ -51,6 +76,54 @@ const manageListingsPageReducer = (state = initialState, action = {}) => { console.error(payload); return { ...state, queryInProgress: false, queryListingsError: payload }; + case OPEN_LISTING_REQUEST: + return { + ...state, + openingListing: payload.listingId, + openingListingError: null, + }; + case OPEN_LISTING_SUCCESS: + return { + ...updateListingAttributes(state, payload.data), + openingListing: null, + }; + case OPEN_LISTING_ERROR: { + // eslint-disable-next-line no-console + console.error(payload); + return { + ...state, + openingListing: null, + openingListingError: { + listingId: state.openingListing, + error: payload, + }, + }; + } + + case CLOSE_LISTING_REQUEST: + return { + ...state, + closingListing: payload.listingId, + closingListingError: null, + }; + case CLOSE_LISTING_SUCCESS: + return { + ...updateListingAttributes(state, payload.data), + closingListing: null, + }; + case CLOSE_LISTING_ERROR: { + // eslint-disable-next-line no-console + console.error(payload); + return { + ...state, + closingListing: null, + closingListingError: { + listingId: state.closingListing, + error: payload, + }, + }; + } + case ADD_OWN_ENTITIES: return merge(state, payload); @@ -110,6 +183,38 @@ export const addOwnEntities = apiResponse => ({ payload: apiResponse, }); +export const openListingRequest = listingId => ({ + type: OPEN_LISTING_REQUEST, + payload: { listingId }, +}); + +export const openListingSuccess = response => ({ + type: OPEN_LISTING_SUCCESS, + payload: response.data, +}); + +export const openListingError = e => ({ + type: OPEN_LISTING_ERROR, + error: true, + payload: e, +}); + +export const closeListingRequest = listingId => ({ + type: CLOSE_LISTING_REQUEST, + payload: { listingId }, +}); + +export const closeListingSuccess = response => ({ + type: CLOSE_LISTING_SUCCESS, + payload: response.data, +}); + +export const closeListingError = e => ({ + type: CLOSE_LISTING_ERROR, + error: true, + payload: e, +}); + export const queryListingsRequest = queryParams => ({ type: FETCH_LISTINGS_REQUEST, payload: { queryParams }, @@ -126,6 +231,7 @@ export const queryListingsError = e => ({ payload: e, }); +// Throwing error for new (loadData may need that info) export const queryOwnListings = queryParams => (dispatch, getState, sdk) => { dispatch(queryListingsRequest(queryParams)); @@ -145,3 +251,33 @@ export const queryOwnListings = queryParams => throw e; }); }; + +export const closeListing = listingId => + (dispatch, getState, sdk) => { + dispatch(closeListingRequest(listingId)); + + return sdk.listings + .close({ id: listingId }, { expand: true }) + .then(response => { + dispatch(closeListingSuccess(response)); + return response; + }) + .catch(e => { + dispatch(closeListingError(e)); + }); + }; + +export const openListing = listingId => + (dispatch, getState, sdk) => { + dispatch(openListingRequest(listingId)); + + return sdk.listings + .open({ id: listingId }, { expand: true }) + .then(response => { + dispatch(openListingSuccess(response)); + return response; + }) + .catch(e => { + dispatch(openListingError(e)); + }); + }; diff --git a/src/containers/ManageListingsPage/ManageListingsPage.js b/src/containers/ManageListingsPage/ManageListingsPage.js index 2727b27b..6b1e2858 100644 --- a/src/containers/ManageListingsPage/ManageListingsPage.js +++ b/src/containers/ManageListingsPage/ManageListingsPage.js @@ -1,4 +1,4 @@ -import React, { PropTypes } from 'react'; +import React, { Component, PropTypes } from 'react'; import { compose } from 'redux'; import { connect } from 'react-redux'; import { FormattedMessage } from 'react-intl'; @@ -9,104 +9,139 @@ import { logout, authenticationInProgress } from '../../ducks/Auth.duck'; import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck'; import { ManageListingCard, PageLayout, PaginationLinks, Topbar, UserNav } from '../../components'; -import { getListingsById, queryOwnListings } from './ManageListingsPage.duck'; +import { + closeListing, + openListing, + getListingsById, + queryOwnListings, +} from './ManageListingsPage.duck'; import css from './ManageListingsPage.css'; // Pagination page size might need to be dynamic on responsive page layouts -// Current design has max 3 columns 96 is divisible by 2 and 3 +// Current design has max 3 columns 42 is divisible by 2 and 3 // So, there's enough cards to fill all columns on full pagination pages -const RESULT_PAGE_SIZE = 96; +const RESULT_PAGE_SIZE = 42; -export const ManageListingsPageComponent = props => { - const { - authInfoError, - authInProgress, - currentUser, - currentUserHasListings, - history, - isAuthenticated, - listings, - location, - logoutError, - notificationCount, - onLogout, - onManageDisableScrolling, - pagination, - queryInProgress, - queryListingsError, - queryParams, - } = props; +export class ManageListingsPageComponent extends Component { + constructor(props) { + super(props); - const hasPaginationInfo = !!pagination && pagination.totalItems != null; - const listingsAreLoaded = !queryInProgress && hasPaginationInfo; + this.state = { listingMenuOpen: null }; + this.onToggleMenu = this.onToggleMenu.bind(this); + } - const loadingResults = ( -

- -

- ); + onToggleMenu(listing) { + this.setState({ listingMenuOpen: listing }); + } - const noResults = ( -

- -

- ); + render() { + const { + authInfoError, + authInProgress, + currentUser, + currentUserHasListings, + history, + isAuthenticated, + listings, + location, + logoutError, + notificationCount, + onCloseListing, + onOpenListing, + onLogout, + onManageDisableScrolling, + pagination, + queryInProgress, + queryListingsError, + queryParams, + openingListing, + closingListing, + } = this.props; - const queryError = ( -

- -

- ); + // TODO Handle openingListingError, closingListingError, - const title = listingsAreLoaded - ?

- + +

+ ); + + const queryError = ( +

+ +

+ ); + + const noResults = listingsAreLoaded && pagination.totalItems === 0 + ?

+ +

+ : null; + + const title = listingsAreLoaded && pagination.totalItems > 0 + ?

+ +

+ : noResults; + + const page = queryParams ? queryParams.page : 1; + const paginationLinks = listingsAreLoaded && pagination && pagination.totalPages > 1 + ? - - : null; - const page = queryParams ? queryParams.page : 1; - const paginationLinks = listingsAreLoaded && pagination && pagination.totalPages > 1 - ? - : null; + : null; - return ( - - - - {queryInProgress ? loadingResults : null} - {queryListingsError ? queryError : null} - {listingsAreLoaded && pagination.totalItems === 0 ? noResults : null} -
- {title} -
- {listings.map(l => ( - - ))} + const listingMenuOpen = this.state.listingMenuOpen; + + return ( + + + + {queryInProgress ? loadingResults : null} + {queryListingsError ? queryError : null} +
+ {title} +
+ {listings.map(l => ( + + ))} +
+ {paginationLinks}
- {paginationLinks} -
- - ); -}; + + ); + } +} ManageListingsPageComponent.defaultProps = { authInfoError: null, @@ -117,9 +152,11 @@ ManageListingsPageComponent.defaultProps = { pagination: null, queryListingsError: null, queryParams: null, + closingListing: null, + openingListing: null, }; -const { arrayOf, bool, func, instanceOf, number, object, shape } = PropTypes; +const { arrayOf, bool, func, instanceOf, number, object, shape, string } = PropTypes; ManageListingsPageComponent.propTypes = { authInfoError: instanceOf(Error), @@ -130,12 +167,16 @@ ManageListingsPageComponent.propTypes = { listings: arrayOf(propTypes.ownListing), logoutError: instanceOf(Error), notificationCount: number, + onCloseListing: func.isRequired, + onOpenListing: func.isRequired, onLogout: func.isRequired, onManageDisableScrolling: func.isRequired, pagination: propTypes.pagination, queryInProgress: bool.isRequired, queryListingsError: instanceOf(Error), queryParams: object, + closingListing: shape({ uuid: string.isRequired }), + openingListing: shape({ uuid: string.isRequired }), // from withRouter history: shape({ @@ -151,6 +192,10 @@ const mapStateToProps = state => { queryInProgress, queryListingsError, queryParams, + openingListing, + openingListingError, + closingListing, + closingListingError, } = state.ManageListingsPage; const listings = getListingsById(state, currentPageResultIds); // PageLayout needs authInfoError and logoutError, Topbar needs isAuthenticated @@ -176,10 +221,16 @@ const mapStateToProps = state => { queryListingsError, queryParams, scrollingDisabled: isScrollingDisabled(state), + openingListing, + openingListingError, + closingListing, + closingListingError, }; }; const mapDispatchToProps = dispatch => ({ + onCloseListing: listingId => dispatch(closeListing(listingId)), + onOpenListing: listingId => dispatch(openListing(listingId)), onLogout: historyPush => dispatch(logout(historyPush)), onManageDisableScrolling: (componentId, disableScrolling) => dispatch(manageDisableScrolling(componentId, disableScrolling)), diff --git a/src/containers/ManageListingsPage/ManageListingsPage.test.js b/src/containers/ManageListingsPage/ManageListingsPage.test.js index df95b218..9fe88dc8 100644 --- a/src/containers/ManageListingsPage/ManageListingsPage.test.js +++ b/src/containers/ManageListingsPage/ManageListingsPage.test.js @@ -18,6 +18,8 @@ describe('ContactDetailsPage', () => { isAuthenticated={false} onLogout={noop} onManageDisableScrolling={noop} + onCloseListing={noop} + onOpenListing={noop} /> ); expect(tree).toMatchSnapshot(); diff --git a/src/translations/en.json b/src/translations/en.json index 183c1ba8..5bef0c89 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -158,10 +158,14 @@ "LoginForm.passwordLabel": "Password", "LoginForm.passwordPlaceholder": "Enter your password…", "LoginForm.passwordRequired": "This field is required", + "ManageListingCard.closedListing": "This listing is closed and hidden from the marketplace.", + "ManageListingCard.closeListing": "Close listing", "ManageListingCard.edit": "Edit", + "ManageListingCard.openListing": "Open listing", "ManageListingCard.perNight": "per night", "ManageListingCard.unsupportedPrice": "({currency})", "ManageListingCard.unsupportedPriceTitle": "Unsupported currency ({currency})", + "ManageListingCard.viewListing": "View listing", "ManageListingsPage.accountSettings": "Account settings", "ManageListingsPage.loadingOwnListings": "Loading listings…", "ManageListingsPage.noResults": "You don't have any listings.", diff --git a/yarn.lock b/yarn.lock index 03845199..bf14069c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6242,9 +6242,9 @@ sharetribe-scripts@0.9.2: optionalDependencies: fsevents "1.0.17" -"sharetribe-sdk@git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#ec9fd243307ce21b95052535d2e358f681ed82b7": +"sharetribe-sdk@git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#75c5b1a9df5142a1066cd055c2157b410f4c4aee": version "0.0.1" - resolved "git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#ec9fd243307ce21b95052535d2e358f681ed82b7" + resolved "git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#75c5b1a9df5142a1066cd055c2157b410f4c4aee" dependencies: axios "^0.15.3" js-cookie "^2.1.3"