diff --git a/CHANGELOG.md b/CHANGELOG.md index d926f69d..9333e3d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2019-XX-XX +- [change] Extracted and refactored utility functions related to transaction and refactored several + components that show transaction data (incl. InboxPage, TransactionPanel, ActivityFeed). Before + updating your customization project, you should read more about what has changed from the pull + request. [#1004](https://github.com/sharetribe/flex-template-web/pull/1004) - [change] Rest of the documentation moved to Flex Docs: https://www.sharetribe.com/docs/ [#1015](https://github.com/sharetribe/flex-template-web/pull/1015) diff --git a/src/components/ActivityFeed/ActivityFeed.example.js b/src/components/ActivityFeed/ActivityFeed.example.js index 8d94c2e2..af9dbe80 100644 --- a/src/components/ActivityFeed/ActivityFeed.example.js +++ b/src/components/ActivityFeed/ActivityFeed.example.js @@ -20,7 +20,7 @@ import { TRANSITION_REVIEW_2_BY_PROVIDER, TX_TRANSITION_ACTOR_CUSTOMER, TX_TRANSITION_ACTOR_PROVIDER, -} from '../../util/types'; +} from '../../util/transaction'; import ActivityFeed from './ActivityFeed'; const noop = () => null; diff --git a/src/components/ActivityFeed/ActivityFeed.js b/src/components/ActivityFeed/ActivityFeed.js index aec9f100..534e79ec 100644 --- a/src/components/ActivityFeed/ActivityFeed.js +++ b/src/components/ActivityFeed/ActivityFeed.js @@ -18,11 +18,17 @@ import { TRANSITION_REVIEW_1_BY_PROVIDER, TRANSITION_REVIEW_2_BY_CUSTOMER, TRANSITION_REVIEW_2_BY_PROVIDER, - TX_TRANSITION_ACTOR_CUSTOMER, - TX_TRANSITION_ACTOR_PROVIDER, - areReviewsCompleted, - propTypes, -} from '../../util/types'; + transitionIsReviewed, + txIsDelivered, + txIsInFirstReviewBy, + txIsReviewed, + isCustomerReview, + isProviderReview, + txRoleIsProvider, + getUserTxRole, + isRelevantPastTransition, +} from '../../util/transaction'; +import { propTypes } from '../../util/types'; import * as log from '../../util/log'; import css from './ActivityFeed.css'; @@ -87,48 +93,16 @@ Review.propTypes = { rating: number.isRequired, }; -// Check if a transition is the kind that -// should be rendered in he ActivityFeed -const shouldRenderTransition = transition => { - return [ - TRANSITION_ACCEPT, - TRANSITION_CANCEL, - TRANSITION_COMPLETE, - TRANSITION_DECLINE, - TRANSITION_EXPIRE, - TRANSITION_REQUEST, - TRANSITION_REQUEST_AFTER_ENQUIRY, - TRANSITION_REVIEW_1_BY_CUSTOMER, - TRANSITION_REVIEW_1_BY_PROVIDER, - TRANSITION_REVIEW_2_BY_CUSTOMER, - TRANSITION_REVIEW_2_BY_PROVIDER, - ].includes(transition); -}; - -// Check if a user giving a review is related to -// given tx transition. -const isReviewTransition = transition => { - return [ - TRANSITION_REVIEW_1_BY_CUSTOMER, - TRANSITION_REVIEW_1_BY_PROVIDER, - TRANSITION_REVIEW_2_BY_CUSTOMER, - TRANSITION_REVIEW_2_BY_PROVIDER, - ].includes(transition); -}; - -const hasUserLeftAReviewFirst = (userRole, lastTransition) => { - return ( - (lastTransition === TRANSITION_REVIEW_1_BY_CUSTOMER && - userRole === TX_TRANSITION_ACTOR_CUSTOMER) || - (lastTransition === TRANSITION_REVIEW_1_BY_PROVIDER && - userRole === TX_TRANSITION_ACTOR_PROVIDER) || - areReviewsCompleted(lastTransition) - ); +const hasUserLeftAReviewFirst = (userRole, transaction) => { + const isProvider = txRoleIsProvider(userRole); + return isProvider + ? txIsInFirstReviewBy(transaction, isProvider) + : txIsInFirstReviewBy(transaction, !isProvider); }; const resolveTransitionMessage = ( + transaction, transition, - lastTransition, listingTitle, ownRole, otherUsersName, @@ -138,7 +112,6 @@ const resolveTransitionMessage = ( const isOwnTransition = transition.by === ownRole; const currentTransition = transition.transition; const displayName = otherUsersName; - const deliveredState = lastTransition === TRANSITION_COMPLETE; switch (currentTransition) { case TRANSITION_REQUEST: @@ -164,7 +137,7 @@ const resolveTransitionMessage = ( ); case TRANSITION_EXPIRE: - return ownRole === TX_TRANSITION_ACTOR_PROVIDER ? ( + return txRoleIsProvider(ownRole) ? ( ) : ( @@ -174,14 +147,19 @@ const resolveTransitionMessage = ( case TRANSITION_COMPLETE: // Show the leave a review link if the state is delivered or // if current user is not the first to leave a review + const reviewPeriodJustStarted = txIsDelivered(transaction); + const reviewPeriodIsOver = txIsReviewed(transaction); + const userHasLeftAReview = hasUserLeftAReviewFirst(ownRole, transaction); + const reviewLink = - deliveredState || !hasUserLeftAReviewFirst(ownRole, lastTransition) ? ( + reviewPeriodJustStarted || !(reviewPeriodIsOver || userHasLeftAReview) ? ( ) : null; return ; + case TRANSITION_REVIEW_1_BY_PROVIDER: case TRANSITION_REVIEW_1_BY_CUSTOMER: if (isOwnTransition) { @@ -189,7 +167,9 @@ const resolveTransitionMessage = ( } else { // show the leave a review link if current user is not the first // one to leave a review - const reviewLink = !hasUserLeftAReviewFirst(ownRole, lastTransition) ? ( + const reviewPeriodIsOver = txIsReviewed(transaction); + const userHasLeftAReview = hasUserLeftAReviewFirst(ownRole, transaction); + const reviewLink = !(reviewPeriodIsOver || userHasLeftAReview) ? ( @@ -241,22 +221,18 @@ const Transition = props => { : currentTransaction.listing.attributes.title; const lastTransition = currentTransaction.attributes.lastTransition; - const ownRole = - currentUser.id.uuid === customer.id.uuid - ? TX_TRANSITION_ACTOR_CUSTOMER - : TX_TRANSITION_ACTOR_PROVIDER; + const ownRole = getUserTxRole(currentUser.id, currentTransaction); const bannedUserDisplayName = intl.formatMessage({ id: 'ActivityFeed.bannedUserDisplayName', }); - const otherUsersName = - ownRole === TX_TRANSITION_ACTOR_CUSTOMER - ? userDisplayName(provider, bannedUserDisplayName) - : userDisplayName(customer, bannedUserDisplayName); + const otherUsersName = txRoleIsProvider(ownRole) + ? userDisplayName(customer, bannedUserDisplayName) + : userDisplayName(provider, bannedUserDisplayName); const transitionMessage = resolveTransitionMessage( + transaction, transition, - lastTransition, listingTitle, ownRole, otherUsersName, @@ -267,19 +243,13 @@ const Transition = props => { let reviewComponent = null; - if (isReviewTransition(currentTransition) && areReviewsCompleted(lastTransition)) { - const customerReview = - currentTransition === TRANSITION_REVIEW_1_BY_CUSTOMER || - currentTransition === TRANSITION_REVIEW_2_BY_CUSTOMER; - const providerReview = - currentTransition === TRANSITION_REVIEW_1_BY_PROVIDER || - currentTransition === TRANSITION_REVIEW_2_BY_PROVIDER; - if (customerReview) { + if (transitionIsReviewed(lastTransition)) { + if (isCustomerReview(currentTransition)) { const review = reviewByAuthorId(currentTransaction, customer.id); reviewComponent = ( ); - } else if (providerReview) { + } else if (isProviderReview(currentTransition)) { const review = reviewByAuthorId(currentTransaction, provider.id); reviewComponent = ( @@ -417,7 +387,7 @@ export const ActivityFeedComponent = props => { }; const transitionListItem = transition => { - if (shouldRenderTransition(transition.transition)) { + if (isRelevantPastTransition(transition.transition)) { return (
  • {transitionComponent(transition)} diff --git a/src/components/BookingBreakdown/BookingBreakdown.css b/src/components/BookingBreakdown/BookingBreakdown.css index 7ff45a13..60801bbb 100644 --- a/src/components/BookingBreakdown/BookingBreakdown.css +++ b/src/components/BookingBreakdown/BookingBreakdown.css @@ -66,7 +66,7 @@ font-weight: var(--fontWeightSemiBold); /* Move so that baseline aligns with the total price */ - padding-top: 7px; + padding-top: 6px; @media (--viewportMedium) { padding-top: 8px; @@ -77,9 +77,11 @@ @apply --marketplaceBodyFontStyles; font-weight: var(--fontWeightSemiBold); margin: 0 0 0 10px; + padding-top: 4px; @media (--viewportMedium) { margin: 2px 0 0 10px; + padding-top: 0; } } diff --git a/src/components/BookingBreakdown/BookingBreakdown.example.js b/src/components/BookingBreakdown/BookingBreakdown.example.js index ef262952..a326183b 100644 --- a/src/components/BookingBreakdown/BookingBreakdown.example.js +++ b/src/components/BookingBreakdown/BookingBreakdown.example.js @@ -1,9 +1,6 @@ import Decimal from 'decimal.js'; import { types as sdkTypes } from '../../util/sdkLoader'; import { - LINE_ITEM_DAY, - LINE_ITEM_NIGHT, - LINE_ITEM_UNITS, TRANSITION_ACCEPT, TRANSITION_CANCEL, TRANSITION_COMPLETE, @@ -11,7 +8,8 @@ import { TRANSITION_EXPIRE, TRANSITION_REQUEST, TX_TRANSITION_ACTOR_CUSTOMER, -} from '../../util/types'; +} from '../../util/transaction'; +import { LINE_ITEM_DAY, LINE_ITEM_NIGHT, LINE_ITEM_UNITS } from '../../util/types'; import config from '../../config'; import BookingBreakdown from './BookingBreakdown'; diff --git a/src/components/BookingBreakdown/BookingBreakdown.test.js b/src/components/BookingBreakdown/BookingBreakdown.test.js index b4263d54..f6736293 100644 --- a/src/components/BookingBreakdown/BookingBreakdown.test.js +++ b/src/components/BookingBreakdown/BookingBreakdown.test.js @@ -4,11 +4,11 @@ import { fakeIntl, createBooking } from '../../util/test-data'; import { renderDeep } from '../../util/test-helpers'; import { types as sdkTypes } from '../../util/sdkLoader'; import { - LINE_ITEM_NIGHT, TRANSITION_CANCEL, TRANSITION_REQUEST, TX_TRANSITION_ACTOR_CUSTOMER, -} from '../../util/types'; +} from '../../util/transaction'; +import { LINE_ITEM_NIGHT } from '../../util/types'; import { BookingBreakdownComponent } from './BookingBreakdown'; const { UUID, Money } = sdkTypes; diff --git a/src/components/BookingBreakdown/LineItemTotalPrice.js b/src/components/BookingBreakdown/LineItemTotalPrice.js index 40c4b98b..c55a036d 100644 --- a/src/components/BookingBreakdown/LineItemTotalPrice.js +++ b/src/components/BookingBreakdown/LineItemTotalPrice.js @@ -2,7 +2,8 @@ import React from 'react'; import { bool } from 'prop-types'; import { FormattedMessage, intlShape } from 'react-intl'; import { formatMoney } from '../../util/currency'; -import { txIsCanceled, txIsCompleted, txIsDeclinedOrExpired, propTypes } from '../../util/types'; +import { txIsCanceled, txIsDelivered, txIsDeclined } from '../../util/transaction'; +import { propTypes } from '../../util/types'; import css from './BookingBreakdown.css'; @@ -10,9 +11,9 @@ const LineItemUnitPrice = props => { const { transaction, isProvider, intl } = props; let providerTotalMessageId = 'BookingBreakdown.providerTotalDefault'; - if (txIsCompleted(transaction)) { + if (txIsDelivered(transaction)) { providerTotalMessageId = 'BookingBreakdown.providerTotalDelivered'; - } else if (txIsDeclinedOrExpired(transaction)) { + } else if (txIsDeclined(transaction)) { providerTotalMessageId = 'BookingBreakdown.providerTotalDeclined'; } else if (txIsCanceled(transaction)) { providerTotalMessageId = 'BookingBreakdown.providerTotalCanceled'; diff --git a/src/components/TransactionPanel/AddressLinkMaybe.js b/src/components/TransactionPanel/AddressLinkMaybe.js new file mode 100644 index 00000000..eba5470c --- /dev/null +++ b/src/components/TransactionPanel/AddressLinkMaybe.js @@ -0,0 +1,29 @@ +import React from 'react'; +import classNames from 'classnames'; +import { ExternalLink } from '../../components'; + +import css from './TransactionPanel.css'; + +// Functional component as a helper to build AddressLinkMaybe +const AddressLinkMaybe = props => { + const { className, rootClassName, location, geolocation, showAddress } = props; + const { address, building } = location || {}; + const { lat, lng } = geolocation || {}; + const hrefToGoogleMaps = geolocation + ? `https://maps.google.com/?q=${lat},${lng}` + : address + ? `https://maps.google.com/?q=${encodeURIComponent(address)}` + : null; + + const fullAddress = + typeof building === 'string' && building.length > 0 ? `${building}, ${address}` : address; + + const classes = classNames(rootClassName || css.address, className); + return showAddress && hrefToGoogleMaps ? ( +

    + {fullAddress} +

    + ) : null; +}; + +export default AddressLinkMaybe; diff --git a/src/components/TransactionPanel/BreakdownMaybe.js b/src/components/TransactionPanel/BreakdownMaybe.js new file mode 100644 index 00000000..c2dc26d1 --- /dev/null +++ b/src/components/TransactionPanel/BreakdownMaybe.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import config from '../../config'; +import { BookingBreakdown } from '../../components'; + +import css from './TransactionPanel.css'; + +// Functional component as a helper to build BookingBreakdown +const BreakdownMaybe = props => { + const { className, rootClassName, breakdownClassName, transaction, transactionRole } = props; + const loaded = transaction && transaction.id && transaction.booking && transaction.booking.id; + + const classes = classNames(rootClassName || css.breakdownMaybe, className); + const breakdownClasses = classNames(breakdownClassName || css.breakdown); + + return loaded ? ( +
    +

    + +

    + +
    + ) : null; +}; + +export default BreakdownMaybe; diff --git a/src/components/TransactionPanel/DetailCardHeadingsMaybe.js b/src/components/TransactionPanel/DetailCardHeadingsMaybe.js new file mode 100644 index 00000000..a58e51fa --- /dev/null +++ b/src/components/TransactionPanel/DetailCardHeadingsMaybe.js @@ -0,0 +1,26 @@ +import React from 'react'; +import AddressLinkMaybe from './AddressLinkMaybe'; + +import css from './TransactionPanel.css'; + +// Functional component as a helper to build detail card headings +const DetailCardHeadingsMaybe = props => { + const { + showDetailCardHeadings, + listingTitle, + subTitle, + location, + geolocation, + showAddress, + } = props; + + return showDetailCardHeadings ? ( +
    +

    {listingTitle}

    +

    {subTitle}

    + +
    + ) : null; +}; + +export default DetailCardHeadingsMaybe; diff --git a/src/components/TransactionPanel/DetailCardImage.js b/src/components/TransactionPanel/DetailCardImage.js new file mode 100644 index 00000000..abe6607e --- /dev/null +++ b/src/components/TransactionPanel/DetailCardImage.js @@ -0,0 +1,40 @@ +import React from 'react'; +import classNames from 'classnames'; +import { AvatarMedium, ResponsiveImage } from '../../components'; + +import css from './TransactionPanel.css'; + +// Functional component as a helper to build AddressLinkMaybe +const DetailCardImage = props => { + const { + className, + rootClassName, + avatarWrapperClassName, + listingTitle, + image, + provider, + isCustomer, + } = props; + const classes = classNames(rootClassName || css.detailCardImageWrapper, className); + return ( + +
    +
    + +
    +
    + {isCustomer ? ( +
    + +
    + ) : null} +
    + ); +}; + +export default DetailCardImage; diff --git a/src/components/TransactionPanel/FeedSection.js b/src/components/TransactionPanel/FeedSection.js new file mode 100644 index 00000000..51d7ccfb --- /dev/null +++ b/src/components/TransactionPanel/FeedSection.js @@ -0,0 +1,64 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import { ActivityFeed } from '../../components'; + +import css from './TransactionPanel.css'; + +// Functional component as a helper to build ActivityFeed section +const FeedSection = props => { + const { + className, + rootClassName, + currentTransaction, + currentUser, + fetchMessagesError, + fetchMessagesInProgress, + initialMessageFailed, + messages, + oldestMessagePageFetched, + onShowMoreMessages, + onOpenReviewModal, + totalMessagePages, + } = props; + + const txTransitions = currentTransaction.attributes.transitions + ? currentTransaction.attributes.transitions + : []; + const hasOlderMessages = totalMessagePages > oldestMessagePageFetched; + + const showFeed = + messages.length > 0 || txTransitions.length > 0 || initialMessageFailed || fetchMessagesError; + + const classes = classNames(rootClassName || css.feedContainer, className); + + return showFeed ? ( +
    +

    + +

    + {initialMessageFailed ? ( +

    + +

    + ) : null} + {fetchMessagesError ? ( +

    + +

    + ) : null} + +
    + ) : null; +}; + +export default FeedSection; diff --git a/src/components/TransactionPanel/PanelHeading.js b/src/components/TransactionPanel/PanelHeading.js new file mode 100644 index 00000000..b41275f9 --- /dev/null +++ b/src/components/TransactionPanel/PanelHeading.js @@ -0,0 +1,226 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import { createSlug, stringify } from '../../util/urlHelpers'; +import { NamedLink } from '../../components'; + +import css from './TransactionPanel.css'; + +export const HEADING_ENQUIRED = 'enquired'; +export const HEADING_REQUESTED = 'requested'; +export const HEADING_ACCEPTED = 'accepted'; +export const HEADING_DECLINED = 'declined'; +export const HEADING_CANCELED = 'canceled'; +export const HEADING_DELIVERED = 'deliveded'; + +const createListingLink = (listingId, label, listingDeleted, searchParams = {}, className = '') => { + if (!listingDeleted) { + const params = { id: listingId, slug: createSlug(label) }; + const to = { search: stringify(searchParams) }; + return ( + + {label} + + ); + } else { + return ; + } +}; + +const ListingDeletedInfoMaybe = props => { + return props.listingDeleted ? ( +

    + +

    + ) : null; +}; + +const HeadingCustomer = props => { + const { className, id, values, listingDeleted } = props; + return ( + +

    + + + +

    + +
    + ); +}; + +const HeadingCustomerWithSubtitle = props => { + const { className, id, values, subtitleId, subtitleValues, children, listingDeleted } = props; + return ( + +

    + + + + +

    + {children} + +
    + ); +}; + +const CustomerBannedInfoMaybe = props => { + return props.isCustomerBanned ? ( +

    + +

    + ) : null; +}; + +const HeadingProvider = props => { + const { className, id, values, isCustomerBanned } = props; + return ( + +

    + + + +

    + +
    + ); +}; + +// Functional component as a helper to choose and show Order or Sale heading info: +// title, subtitle, and message +const PanelHeading = props => { + const { + className, + rootClassName, + panelHeadingState, + customerName, + providerName, + listingId, + listingTitle, + listingDeleted, + isCustomerBanned, + } = props; + + const isCustomer = props.transactionRole === 'customer'; + + const defaultRootClassName = isCustomer ? css.headingOrder : css.headingSale; + const titleClasses = classNames(rootClassName || defaultRootClassName, className); + const listingLink = createListingLink(listingId, listingTitle, listingDeleted); + + switch (panelHeadingState) { + case HEADING_ENQUIRED: + return isCustomer ? ( + + ) : ( + + ); + case HEADING_REQUESTED: + return isCustomer ? ( + + {!listingDeleted ? ( +

    + +

    + ) : null} +
    + ) : ( + + {!isCustomerBanned ? ( +

    + +

    + ) : null} +
    + ); + case HEADING_ACCEPTED: + return isCustomer ? ( + + ) : ( + + ); + case HEADING_DECLINED: + return isCustomer ? ( + + ) : ( + + ); + case HEADING_CANCELED: + return isCustomer ? ( + + ) : ( + + ); + case HEADING_DELIVERED: + return isCustomer ? ( + + ) : ( + + ); + default: + console.warning('Unknown state given to panel heading.'); + return null; + } +}; + +export default PanelHeading; diff --git a/src/components/TransactionPanel/SaleActionButtonsMaybe.js b/src/components/TransactionPanel/SaleActionButtonsMaybe.js new file mode 100644 index 00000000..6951afd9 --- /dev/null +++ b/src/components/TransactionPanel/SaleActionButtonsMaybe.js @@ -0,0 +1,64 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import classNames from 'classnames'; +import { PrimaryButton, SecondaryButton } from '../../components'; + +import css from './TransactionPanel.css'; + +// Functional component as a helper to build ActionButtons for +// provider when state is preauthorized +const SaleActionButtonsMaybe = props => { + const { + className, + rootClassName, + showButtons, + acceptInProgress, + declineInProgress, + acceptSaleError, + declineSaleError, + onAcceptSale, + onDeclineSale, + } = props; + + const buttonsDisabled = acceptInProgress || declineInProgress; + + const acceptErrorMessage = acceptSaleError ? ( +

    + +

    + ) : null; + const declineErrorMessage = declineSaleError ? ( +

    + +

    + ) : null; + + const classes = classNames(rootClassName || css.actionButtons, className); + + return showButtons ? ( +
    +
    + {acceptErrorMessage} + {declineErrorMessage} +
    +
    + + + + + + +
    +
    + ) : null; +}; + +export default SaleActionButtonsMaybe; diff --git a/src/components/TransactionPanel/TransactionPanel.css b/src/components/TransactionPanel/TransactionPanel.css index bd0bc237..6403385a 100644 --- a/src/components/TransactionPanel/TransactionPanel.css +++ b/src/components/TransactionPanel/TransactionPanel.css @@ -29,14 +29,17 @@ } } -.imageWrapperMobile { +/* DetailCardImage subcomponent */ +.detailCardImageWrapper { /* Layout */ - display: block; + display: none; width: 100%; position: relative; @media (--viewportLarge) { - display: none; + display: block; + max-height: 268px; + overflow-y: hidden; } } @@ -60,37 +63,51 @@ } } -.avatarWrapperMobile { +.avatarWrapper { /* Position (over the listing image)*/ margin-left: 24px; - margin-top: -31px; + margin-top: -30px; /* Rendering context to the same lavel as listing image */ position: relative; - - /* Layout */ - display: block; -} - -.avatarWrapperCustomerDesktop { - display: none; - composes: avatarWrapperMobile; - - @media (--viewportLarge) { - display: block; - margin-left: 48px; - } -} - -.avatarMobile { /* Flex would give too much width by default. */ width: 60px; + @media (--viewportMedium) { + margin-top: -32px; + padding: 2px 0; + } + @media (--viewportLarge) { + margin-left: 48px; + width: unset; + padding: 2px 0; + } +} + +/* Passed-in props for DetailCardImage subcomponent */ +.imageWrapperMobile { @media (--viewportLarge) { display: none; } } +.avatarWrapperMobile { + composes: avatarWrapper; + + @media (--viewportLarge) { + display: none; + } +} + +.avatarWrapperDesktop { + composes: avatarWrapper; + display: none; + + @media (--viewportLarge) { + display: block; + } +} + .avatarWrapperProviderDesktop { display: none; @@ -105,30 +122,37 @@ } } +/* PanelHeadings subcomponent */ .headingOrder { margin: 29px 24px 0 24px; @media (--viewportMedium) { - margin: 25px 24px 0 24px; max-width: 80%; + margin: 24px 24px 0 24px; + padding: 1px 0 7px 0; } @media (--viewportLarge) { max-width: 100%; margin: 152px 0 0 0; + padding: 0; } } .headingSale { - margin: 22px 24px 0 24px; + margin: 18px 24px 0 24px; + padding: 5px 0 1px 0; @media (--viewportMedium) { max-width: 80%; + margin: 24px 24px 0 24px; + padding: 1px 0 7px 0; } @media (--viewportLarge) { max-width: 100%; margin: 42px 0 0 0; + padding: 0; } } @@ -147,28 +171,30 @@ } } -.transitionDate { - white-space: nowrap; -} - +/* Container for booking details in mobile layout */ .bookingDetailsMobile { margin-top: 47px; @media (--viewportMedium) { - margin-top: 43px; + margin-top: 40px; + padding: 4px 0 0px 0; } @media (--viewportLarge) { display: none; } } +/* "aside" section in desktop layout */ .asideDesktop { margin: 1px 0 0 0; + /** + * Aside is visible on mobile layout too, since it includes BookingPanel component. + * It might get rendered as a Modal in mobile layout. + */ + @media (--viewportLarge) { - margin-top: 123px; - margin-left: 0; - margin-right: 0; + margin: 123px 0 0 0; } } @@ -184,26 +210,13 @@ } } -.detailCardImageWrapper { - display: none; - - /* Layout */ - width: 100%; - position: relative; - - @media (--viewportLarge) { - display: block; - } -} - +/* DetailCardHeadingsMaybe subcomponent */ .detailCardHeadings { display: none; @media (--viewportLarge) { display: block; - margin-left: 48px; - margin-right: 48px; - margin-bottom: 37px; + margin: 0 48px 33px 48px; } } @@ -228,16 +241,25 @@ } } +/* AddressLinkMaybe subcomponent */ .address { @apply --marketplaceH5FontStyles; color: var(--matterColorAnti); margin: 0; } -.addressMobileWrapper { +/* Passed-in props for AddressLinkMaybe subcomponent */ +.addressMobile { + @apply --marketplaceH5FontStyles; + color: var(--matterColorAnti); margin: 0 24px 24px 24px; } +/* BreakdownMaybe subcomponent */ +.breakdownMaybe { + /* default "root" class for breakdown container */ +} + .bookingBreakdownTitle { /* Font */ color: var(--matterColorAnti); @@ -246,6 +268,20 @@ @media (--viewportLarge) { margin: 37px 48px 26px 48px; + margin: 32px 48px 24px 48px; + padding: 4px 0 4px 0; + } +} + +.breakdown { + margin: 14px 24px 0 24px; + + @media (--viewportMedium) { + margin: 18px 24px 0 24px; + } + @media (--viewportLarge) { + margin: 24px 48px 47px 48px; + padding: 6px 0 2px 0; } } @@ -257,34 +293,7 @@ } } -.breakdown { - margin: 14px 24px 0 24px; - - @media (--viewportMedium) { - margin: 18px 24px 0 24px; - } - @media (--viewportLarge) { - margin: 14px 48px 47px 48px; - } -} - -.feedContainer { - margin: 46px 24px 0 24px; - - @media (--viewportMedium) { - margin: 46px 24px 0 24px; - } - @media (--viewportLarge) { - margin: 43px 0 0 0; - } -} - -.feedContainerWithInfoAbove { - @media (--viewportLarge) { - margin: 38px 0 0 0; - } -} - +/* FeedSection subcomponent */ .feedHeading { color: var(--matterColorAnti); margin: 0; @@ -298,6 +307,32 @@ margin-top: 20px; } +.messageError { + color: var(--failColor); + margin: 13px 0 22px 0; + + @media (--viewportMedium) { + margin: 13px 0 23px 0; + } + + @media (--viewportLarge) { + margin: 12px 0 23px 0; + } +} + +/* Passed-in props for FeedSection subcomponent */ +.feedContainer { + margin: 46px 24px 0 24px; + + @media (--viewportMedium) { + margin: 40px 24px 0 24px; + } + @media (--viewportLarge) { + margin: 43px 0 0 0; + } +} + +/* Prop to be passed to SendMessageForm component */ .sendMessageForm { position: relative; margin: 46px 24px 0 24px; @@ -311,12 +346,7 @@ } } -.requestToBookButton { - @apply --marketplaceButtonStylesPrimary; - - text-decoration: none; -} - +/* SaleActionButtonsMaybe subcomponent */ .actionButtons { /* Position action button row above the footer */ z-index: 9; @@ -342,23 +372,6 @@ } } -.mobileActionButtons { - display: block; - - @media (--viewportLarge) { - display: none; - } -} - -.desktopActionButtons { - display: none; - - @media (--viewportLarge) { - display: block; - margin-bottom: 48px; - } -} - .actionButtonWrapper { width: 100%; display: flex; @@ -380,19 +393,6 @@ } } -.messageError { - color: var(--failColor); - margin: 13px 0 22px 0; - - @media (--viewportMedium) { - margin: 13px 0 23px 0; - } - - @media (--viewportLarge) { - margin: 12px 0 23px 0; - } -} - .actionError { @apply --marketplaceH5FontStyles; color: var(--failColor); @@ -416,6 +416,25 @@ } } +/* Container for SaleActionButtonsMaybe subcomponent */ +.mobileActionButtons { + display: block; + + @media (--viewportLarge) { + display: none; + } +} + +.desktopActionButtons { + display: none; + + @media (--viewportLarge) { + display: block; + margin-bottom: 48px; + } +} + +/* BookingPanel subcompnent */ .bookingPanel { margin: 16px 48px 48px 48px; } diff --git a/src/components/TransactionPanel/TransactionPanel.helpers.js b/src/components/TransactionPanel/TransactionPanel.helpers.js deleted file mode 100644 index 022614d6..00000000 --- a/src/components/TransactionPanel/TransactionPanel.helpers.js +++ /dev/null @@ -1,550 +0,0 @@ -import React from 'react'; -import { FormattedMessage } from 'react-intl'; -import classNames from 'classnames'; -import { - txHasBeenAccepted, - txHasFirstReview, - txIsAccepted, - txIsCanceled, - txIsCompleted, - txIsDeclined, - txIsEnquired, - txIsExpired, - txIsRequested, - txIsReviewed, -} from '../../util/types'; -import { userDisplayName } from '../../util/data'; -import { createSlug, stringify } from '../../util/urlHelpers'; -import { - ActivityFeed, - BookingBreakdown, - BookingPanel, - ExternalLink, - NamedLink, - PrimaryButton, - SecondaryButton, -} from '../../components'; -import config from '../../config'; - -import css from './TransactionPanel.css'; - -// Functional component as a helper to build ActivityFeed section -export const FeedSection = props => { - const { - className, - rootClassName, - currentTransaction, - currentUser, - fetchMessagesError, - fetchMessagesInProgress, - initialMessageFailed, - messages, - oldestMessagePageFetched, - onShowMoreMessages, - onOpenReviewModal, - totalMessagePages, - } = props; - - const txTransitions = currentTransaction.attributes.transitions - ? currentTransaction.attributes.transitions - : []; - const hasOlderMessages = totalMessagePages > oldestMessagePageFetched; - - const showFeed = - messages.length > 0 || txTransitions.length > 0 || initialMessageFailed || fetchMessagesError; - - const classes = classNames(rootClassName || css.feedContainer, className); - - return showFeed ? ( -
    -

    - -

    - {initialMessageFailed ? ( -

    - -

    - ) : null} - {fetchMessagesError ? ( -

    - -

    - ) : null} - { - onShowMoreMessages(currentTransaction.id); - }} - fetchMessagesInProgress={fetchMessagesInProgress} - /> -
    - ) : null; -}; - -// Functional component as a helper to build AddressLinkMaybe -export const AddressLinkMaybe = props => { - const { transaction, transactionRole, currentListing } = props; - - const isCustomer = transactionRole === 'customer'; - const txIsAcceptedForCustomer = isCustomer && txHasBeenAccepted(transaction); - - const publicData = currentListing.attributes.publicData || {}; - const { address, building } = publicData.location || {}; - const geolocation = currentListing.attributes.geolocation; - - const { lat, lng } = geolocation || {}; - const hrefToGoogleMaps = geolocation - ? `https://maps.google.com/?q=${lat},${lng}` - : address - ? `https://maps.google.com/?q=${encodeURIComponent(address)}` - : null; - - const fullAddress = - typeof building === 'string' && building.length > 0 ? `${building}, ${address}` : address; - - return txIsAcceptedForCustomer && hrefToGoogleMaps ? ( -

    - {fullAddress} -

    - ) : null; -}; - -// Functional component as a helper to build BookingBreakdown -export const BreakdownMaybe = props => { - const { className, rootClassName, breakdownClassName, transaction, transactionRole } = props; - const loaded = transaction && transaction.id && transaction.booking && transaction.booking.id; - - const classes = classNames(rootClassName || className); - const breakdownClasses = classNames(css.breakdown, breakdownClassName); - - return loaded ? ( -
    -

    - -

    - -
    - ) : null; -}; - -const createListingLink = (listing, label, searchParams = {}, className = '') => { - const listingLoaded = !!listing.id; - - if (listingLoaded && !listing.attributes.deleted) { - const title = listing.attributes.title; - const params = { id: listing.id.uuid, slug: createSlug(title) }; - const to = { search: stringify(searchParams) }; - return ( - - {label} - - ); - } else { - return ; - } -}; - -// Functional component as a helper to build detail card headings -export const DetailCardHeadingsMaybe = props => { - const { transaction, transactionRole, listing, listingTitle, subTitle } = props; - - const isCustomer = transactionRole === 'customer'; - const canShowDetailCardHeadings = isCustomer && !txIsEnquired(transaction); - - return canShowDetailCardHeadings ? ( -
    -

    {listingTitle}

    -

    {subTitle}

    - -
    - ) : null; -}; - -// Functional component as a helper to build a BookingPanel -export const BookingPanelMaybe = props => { - const { - authorDisplayName, - transaction, - transactionRole, - listing, - listingTitle, - subTitle, - provider, - onSubmit, - onManageDisableScrolling, - timeSlots, - fetchTimeSlotsError, - } = props; - - const isProviderLoaded = !!provider.id; - const isProviderBanned = isProviderLoaded && provider.attributes.banned; - const isCustomer = transactionRole === 'customer'; - const canShowBookingPanel = isCustomer && txIsEnquired(transaction) && !isProviderBanned; - - return canShowBookingPanel ? ( - console.log('submit')} - title={listingTitle} - subTitle={subTitle} - authorDisplayName={authorDisplayName} - onSubmit={onSubmit} - onManageDisableScrolling={onManageDisableScrolling} - timeSlots={timeSlots} - fetchTimeSlotsError={fetchTimeSlotsError} - /> - ) : null; -}; - -// Functional component as a helper to build ActionButtons for -// provider when state is preauthorized -export const SaleActionButtonsMaybe = props => { - const { - className, - rootClassName, - canShowButtons, - transaction, - acceptInProgress, - declineInProgress, - acceptSaleError, - declineSaleError, - onAcceptSale, - onDeclineSale, - } = props; - - const buttonsDisabled = acceptInProgress || declineInProgress; - - const acceptErrorMessage = acceptSaleError ? ( -

    - -

    - ) : null; - const declineErrorMessage = declineSaleError ? ( -

    - -

    - ) : null; - - const classes = classNames(rootClassName || css.actionButtons, className); - - return canShowButtons ? ( -
    -
    - {acceptErrorMessage} - {declineErrorMessage} -
    -
    - onDeclineSale(transaction.id)} - > - - - onAcceptSale(transaction.id)} - > - - -
    -
    - ) : null; -}; - -// Functional component as a helper to build order title -export const OrderTitle = props => { - const { - className, - rootClassName, - transaction, - customerDisplayName: customerName, - currentListing, - listingTitle, - } = props; - const listingLink = createListingLink(currentListing, listingTitle); - - const classes = classNames(rootClassName || css.headingOrder, className); - - if (txIsEnquired(transaction)) { - return ( -

    - - - -

    - ); - } else if (txIsRequested(transaction)) { - return ( -

    - - - - -

    - ); - } else if (txIsAccepted(transaction)) { - return ( -

    - - - - -

    - ); - } else if (txIsDeclined(transaction)) { - return ( -

    - -

    - ); - } else if (txIsExpired(transaction)) { - return ( -

    - -

    - ); - } else if (txIsCanceled(transaction)) { - return ( -

    - -

    - ); - } else if ( - txIsCompleted(transaction) || - txHasFirstReview(transaction) || - txIsReviewed(transaction) - ) { - return ( -

    - -

    - ); - } else { - return null; - } -}; - -// Functional component as a helper to build order message below title -export const OrderMessage = props => { - const { - className, - rootClassName, - transaction, - authorDisplayName: providerName, - listingDeleted, - } = props; - const classes = classNames(rootClassName || css.transactionInfoMessage, className); - - if (!listingDeleted && txIsRequested(transaction)) { - return ( -

    - -

    - ); - } else if (listingDeleted) { - return ( -

    - -

    - ); - } - return null; -}; - -// Functional component as a helper to build sale title -export const SaleTitle = props => { - const { - className, - rootClassName, - transaction, - customerDisplayName: customerName, - currentListing, - listingTitle, - } = props; - const listingLink = createListingLink(currentListing, listingTitle); - - const classes = classNames(rootClassName || css.headingSale, className); - - if (txIsEnquired(transaction)) { - return ( -

    - -

    - ); - } else if (txIsRequested(transaction)) { - return ( -

    - -

    - ); - } else if (txIsAccepted(transaction)) { - return ( -

    - -

    - ); - } else if (txIsDeclined(transaction)) { - return ( -

    - -

    - ); - } else if (txIsExpired(transaction)) { - return ( -

    - -

    - ); - } else if (txIsCanceled(transaction)) { - return ( -

    - -

    - ); - } else if ( - txIsCompleted(transaction) || - txHasFirstReview(transaction) || - txIsReviewed(transaction) - ) { - return ( -

    - -

    - ); - } else { - return null; - } -}; - -// Functional component as a helper to build sale message below title -export const SaleMessage = props => { - const { - className, - rootClassName, - transaction, - customerDisplayName: customerName, - isCustomerBanned, - } = props; - const classes = classNames(rootClassName || css.transactionInfoMessage, className); - - if (!isCustomerBanned && txIsRequested(transaction)) { - return ( -

    - -

    - ); - } else if (isCustomerBanned) { - return ( -

    - -

    - ); - } - return null; -}; - -// Functional component as a helper to choose and show Order or Sale title -export const TransactionPageTitle = props => { - return props.transactionRole === 'customer' ? ( - - ) : ( - - ); -}; - -// Functional component as a helper to choose and show Order or Sale message -export const TransactionPageMessage = props => { - return props.transactionRole === 'customer' ? ( - - ) : ( - - ); -}; - -// Helper function to get display names for different roles -export const displayNames = ( - currentUser, - currentProvider, - currentCustomer, - bannedUserDisplayName -) => { - const authorDisplayName = userDisplayName(currentProvider, bannedUserDisplayName); - const customerDisplayName = userDisplayName(currentCustomer, bannedUserDisplayName); - - let otherUserDisplayName = ''; - const currentUserIsCustomer = - currentUser.id && currentCustomer.id && currentUser.id.uuid === currentCustomer.id.uuid; - const currentUserIsProvider = - currentUser.id && currentProvider.id && currentUser.id.uuid === currentProvider.id.uuid; - - if (currentUserIsCustomer) { - otherUserDisplayName = authorDisplayName; - } else if (currentUserIsProvider) { - otherUserDisplayName = customerDisplayName; - } - - return { - authorDisplayName, - customerDisplayName, - otherUserDisplayName, - }; -}; diff --git a/src/components/TransactionPanel/TransactionPanel.js b/src/components/TransactionPanel/TransactionPanel.js index c068bb2d..b59b0ad5 100644 --- a/src/components/TransactionPanel/TransactionPanel.js +++ b/src/components/TransactionPanel/TransactionPanel.js @@ -2,29 +2,64 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from 'react-intl'; import classNames from 'classnames'; -import { txIsRequested, LINE_ITEM_NIGHT, LINE_ITEM_DAY, propTypes } from '../../util/types'; -import { ensureListing, ensureTransaction, ensureUser } from '../../util/data'; +import { + txIsAccepted, + txIsCanceled, + txIsDeclined, + txIsEnquired, + txIsRequested, + txHasBeenDelivered, +} from '../../util/transaction'; +import { LINE_ITEM_NIGHT, LINE_ITEM_DAY, propTypes } from '../../util/types'; +import { ensureListing, ensureTransaction, ensureUser, userDisplayName } from '../../util/data'; import { isMobileSafari } from '../../util/userAgent'; import { formatMoney } from '../../util/currency'; -import { AvatarMedium, AvatarLarge, ResponsiveImage, ReviewModal } from '../../components'; +import { AvatarLarge, BookingPanel, ReviewModal } from '../../components'; import { SendMessageForm } from '../../forms'; import config from '../../config'; // These are internal components that make this file more readable. -import { - AddressLinkMaybe, - BookingPanelMaybe, - BreakdownMaybe, - DetailCardHeadingsMaybe, - FeedSection, - SaleActionButtonsMaybe, - TransactionPageTitle, - TransactionPageMessage, - displayNames, -} from './TransactionPanel.helpers'; +import AddressLinkMaybe from './AddressLinkMaybe'; +import BreakdownMaybe from './BreakdownMaybe'; +import DetailCardHeadingsMaybe from './DetailCardHeadingsMaybe'; +import DetailCardImage from './DetailCardImage'; +import FeedSection from './FeedSection'; +import SaleActionButtonsMaybe from './SaleActionButtonsMaybe'; +import PanelHeading, { + HEADING_ENQUIRED, + HEADING_REQUESTED, + HEADING_ACCEPTED, + HEADING_DECLINED, + HEADING_CANCELED, + HEADING_DELIVERED, +} from './PanelHeading'; import css from './TransactionPanel.css'; +// Helper function to get display names for different roles +const displayNames = (currentUser, currentProvider, currentCustomer, bannedUserDisplayName) => { + const authorDisplayName = userDisplayName(currentProvider, bannedUserDisplayName); + const customerDisplayName = userDisplayName(currentCustomer, bannedUserDisplayName); + + let otherUserDisplayName = ''; + const currentUserIsCustomer = + currentUser.id && currentCustomer.id && currentUser.id.uuid === currentCustomer.id.uuid; + const currentUserIsProvider = + currentUser.id && currentProvider.id && currentUser.id.uuid === currentProvider.id.uuid; + + if (currentUserIsCustomer) { + otherUserDisplayName = authorDisplayName; + } else if (currentUserIsProvider) { + otherUserDisplayName = customerDisplayName; + } + + return { + authorDisplayName, + customerDisplayName, + otherUserDisplayName, + }; +}; + export class TransactionPanelComponent extends Component { constructor(props) { super(props); @@ -147,7 +182,48 @@ export class TransactionPanelComponent extends Component { const listingDeleted = listingLoaded && currentListing.attributes.deleted; const customerLoaded = !!currentCustomer.id; const isCustomerBanned = customerLoaded && currentCustomer.attributes.banned; - const canShowSaleButtons = isProvider && txIsRequested(currentTransaction) && !isCustomerBanned; + const isProviderLoaded = !!currentProvider.id; + const isProviderBanned = isProviderLoaded && currentProvider.attributes.banned; + + const stateDataFn = tx => { + if (txIsEnquired(tx)) { + return { + headingState: HEADING_ENQUIRED, + showBookingPanel: isCustomer && !isProviderBanned, + }; + } else if (txIsRequested(tx)) { + return { + headingState: HEADING_REQUESTED, + showDetailCardHeadings: isCustomer, + showSaleButtons: isProvider && !isCustomerBanned, + }; + } else if (txIsAccepted(tx)) { + return { + headingState: HEADING_ACCEPTED, + showDetailCardHeadings: isCustomer, + showAddress: isCustomer, + }; + } else if (txIsDeclined(tx)) { + return { + headingState: HEADING_DECLINED, + showDetailCardHeadings: isCustomer, + }; + } else if (txIsCanceled(tx)) { + return { + headingState: HEADING_CANCELED, + showDetailCardHeadings: isCustomer, + }; + } else if (txHasBeenDelivered(tx)) { + return { + headingState: HEADING_DELIVERED, + showDetailCardHeadings: isCustomer, + showAddress: isCustomer, + }; + } else { + return { headingState: 'unknown' }; + } + }; + const stateData = stateDataFn(currentTransaction); const bannedUserDisplayName = intl.formatMessage({ id: 'TransactionPanel.bannedUserDisplayName', @@ -163,6 +239,8 @@ export class TransactionPanelComponent extends Component { bannedUserDisplayName ); + const { publicData = {}, geolocation } = currentListing.attributes; + const location = publicData.location || {}; const listingTitle = currentListing.attributes.deleted ? deletedListingTitle : currentListing.attributes.title; @@ -185,19 +263,15 @@ export class TransactionPanelComponent extends Component { const firstImage = currentListing.images && currentListing.images.length > 0 ? currentListing.images[0] : null; - const actionButtonClasses = classNames(css.actionButtons); - const saleButtons = ( onAcceptSale(currentTransaction.id)} + onDeclineSale={() => onDeclineSale(currentTransaction.id)} /> ); @@ -206,68 +280,49 @@ export class TransactionPanelComponent extends Component { { name: otherUserDisplayName } ); - const sendMessageFormClasses = classNames(css.sendMessageForm); - - const showInfoMessage = listingDeleted || (!listingDeleted && txIsRequested(transaction)); // !!orderInfoMessage; - - const feedContainerClasses = classNames(css.feedContainer, { - [css.feedContainerWithInfoAbove]: showInfoMessage, - }); - const classes = classNames(rootClassName || css.root, className); return (
    -
    -
    - -
    -
    -
    - -
    + {isProvider ? (
    ) : null} - -
    -
    - -
    +
    onShowMoreMessages(currentTransaction.id)} totalMessagePages={totalMessagePages} /> - {canShowSaleButtons ? ( + {stateData.showSaleButtons ? (
    {saleButtons}
    ) : null}
    -
    -
    - -
    -
    - {isCustomer ? ( -
    - -
    - ) : null} + - + {stateData.showBookingPanel ? ( + + ) : null} - {canShowSaleButtons ? ( + {stateData.showSaleButtons ? (
    {saleButtons}
    ) : null}
    diff --git a/src/components/TransactionPanel/TransactionPanel.test.js b/src/components/TransactionPanel/TransactionPanel.test.js index b3443d0a..8f6b299a 100644 --- a/src/components/TransactionPanel/TransactionPanel.test.js +++ b/src/components/TransactionPanel/TransactionPanel.test.js @@ -2,6 +2,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { types as sdkTypes } from '../../util/sdkLoader'; import { + createTxTransition, createTransaction, createBooking, createListing, @@ -19,8 +20,8 @@ import { TRANSITION_ENQUIRE, TRANSITION_EXPIRE, TRANSITION_REQUEST, -} from '../../util/types'; -import { BreakdownMaybe } from './TransactionPanel.helpers'; +} from '../../util/transaction'; +import BreakdownMaybe from './BreakdownMaybe'; import { TransactionPanelComponent } from './TransactionPanel'; const noop = () => null; @@ -82,6 +83,23 @@ describe('TransactionPanel - Sale', () => { const txDelivered = createTransaction({ id: 'sale-delivered', lastTransition: TRANSITION_COMPLETE, + transitions: [ + createTxTransition({ + createdAt: new Date(Date.UTC(2017, 4, 1)), + by: 'customer', + transition: TRANSITION_REQUEST, + }), + createTxTransition({ + createdAt: new Date(Date.UTC(2017, 5, 1)), + by: 'provider', + transition: TRANSITION_ACCEPT, + }), + createTxTransition({ + createdAt: new Date(Date.UTC(2017, 6, 1)), + by: 'system', + transition: TRANSITION_COMPLETE, + }), + ], ...baseTxAttrs, }); @@ -250,6 +268,23 @@ describe('TransactionPanel - Order', () => { const txDelivered = createTransaction({ id: 'order-delivered', lastTransition: TRANSITION_COMPLETE, + transitions: [ + createTxTransition({ + createdAt: new Date(Date.UTC(2017, 4, 1)), + by: 'customer', + transition: TRANSITION_REQUEST, + }), + createTxTransition({ + createdAt: new Date(Date.UTC(2017, 5, 1)), + by: 'provider', + transition: TRANSITION_ACCEPT, + }), + createTxTransition({ + createdAt: new Date(Date.UTC(2017, 6, 1)), + by: 'system', + transition: TRANSITION_COMPLETE, + }), + ], ...baseTxAttrs, }); diff --git a/src/components/TransactionPanel/__snapshots__/TransactionPanel.test.js.snap b/src/components/TransactionPanel/__snapshots__/TransactionPanel.test.js.snap index cac213d5..677dc392 100644 --- a/src/components/TransactionPanel/__snapshots__/TransactionPanel.test.js.snap +++ b/src/components/TransactionPanel/__snapshots__/TransactionPanel.test.js.snap @@ -6,483 +6,47 @@ exports[`TransactionPanel - Order accepted matches snapshot 1`] = ` >
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + -
    -
    -
    - -
    -
    -
    - -
    - -
    -
    - -
    - +
    -
    -
    -
    - -
    -
    - - + - @@ -91,6 +92,7 @@ export const loadData = (params, search) => (dispatch, getState, sdk) => { const apiQueryParams = { only: onlyFilter, + lastTransitions: TRANSITIONS, include: ['provider', 'provider.profileImage', 'customer', 'customer.profileImage', 'booking'], 'fields.image': ['variants.square-small', 'variants.square-small2x'], page, diff --git a/src/containers/InboxPage/InboxPage.js b/src/containers/InboxPage/InboxPage.js index c4c94a32..6d5544ab 100644 --- a/src/containers/InboxPage/InboxPage.js +++ b/src/containers/InboxPage/InboxPage.js @@ -6,18 +6,14 @@ import { FormattedMessage, injectIntl, intlShape } from 'react-intl'; import moment from 'moment'; import classNames from 'classnames'; import { - LINE_ITEM_DAY, - LINE_ITEM_UNITS, - txHasFirstReview, txIsAccepted, txIsCanceled, - txIsCompleted, - txIsDeclinedOrExpired, + txIsDeclined, txIsEnquired, txIsRequested, - txIsReviewed, - propTypes, -} from '../../util/types'; + txHasBeenDelivered, +} from '../../util/transaction'; +import { LINE_ITEM_DAY, LINE_ITEM_UNITS, propTypes } from '../../util/types'; import { formatMoney } from '../../util/currency'; import { ensureCurrentUser, userDisplayName } from '../../util/data'; import { dateFromAPIToLocalNoon, daysBetween } from '../../util/dates'; @@ -57,73 +53,85 @@ const formatDate = (intl, date) => { }; // Translated name of the state of the given transaction -const txState = (intl, tx, isOrder) => { - if (txIsAccepted(tx)) { +export const txState = (intl, tx, type) => { + const isOrder = type === 'order'; + + if (txIsEnquired(tx)) { return { - nameClassName: css.nameAccepted, - bookingClassName: css.bookingAccepted, - lastTransitionedAtClassName: css.lastTransitionedAtAccepted, - stateClassName: css.stateAccepted, - state: intl.formatMessage({ - id: 'InboxPage.stateAccepted', - }), - }; - } else if (txIsDeclinedOrExpired(tx)) { - return { - nameClassName: css.nameDeclined, - bookingClassName: css.bookingDeclined, - lastTransitionedAtClassName: css.lastTransitionedAtDeclined, - stateClassName: css.stateDeclined, - state: intl.formatMessage({ - id: 'InboxPage.stateDeclined', - }), - }; - } else if (txIsCanceled(tx)) { - return { - nameClassName: css.nameCanceled, - bookingClassName: css.bookingCanceled, - lastTransitionedAtClassName: css.lastTransitionedAtCanceled, - stateClassName: css.stateCanceled, - state: intl.formatMessage({ - id: 'InboxPage.stateCanceled', - }), - }; - } else if (txIsCompleted(tx) || txHasFirstReview(tx) || txIsReviewed(tx)) { - return { - nameClassName: css.nameDelivered, - bookingClassName: css.bookingDelivered, - lastTransitionedAtClassName: css.lastTransitionedAtDelivered, - stateClassName: css.stateDelivered, - state: intl.formatMessage({ - id: 'InboxPage.stateDelivered', - }), - }; - } else if (txIsEnquired(tx)) { - return { - nameClassName: isOrder ? css.nameEnquiredOrder : css.nameEnquired, - bookingClassName: css.bookingEnquired, - lastTransitionedAtClassName: css.lastTransitionedAtEnquired, - stateClassName: css.stateEnquired, + nameClassName: isOrder ? css.nameNotEmphasized : css.nameEmphasized, + bookingClassName: css.bookingActionNeeded, + lastTransitionedAtClassName: css.lastTransitionedAtEmphasized, + stateClassName: css.stateActionNeeded, state: intl.formatMessage({ id: 'InboxPage.stateEnquiry', }), }; + } else if (txIsRequested(tx)) { + const requested = isOrder + ? { + nameClassName: css.nameNotEmphasized, + bookingClassName: css.bookingNoActionNeeded, + lastTransitionedAtClassName: css.lastTransitionedAtEmphasized, + stateClassName: css.stateActionNeeded, + state: intl.formatMessage({ + id: 'InboxPage.stateRequested', + }), + } + : { + nameClassName: css.nameEmphasized, + bookingClassName: css.bookingActionNeeded, + lastTransitionedAtClassName: css.lastTransitionedAtEmphasized, + stateClassName: css.stateActionNeeded, + state: intl.formatMessage({ + id: 'InboxPage.statePending', + }), + }; + + return requested; + } else if (txIsDeclined(tx)) { + return { + nameClassName: css.nameNotEmphasized, + bookingClassName: css.bookingNoActionNeeded, + lastTransitionedAtClassName: css.lastTransitionedAtNotEmphasized, + stateClassName: css.stateNoActionNeeded, + state: intl.formatMessage({ + id: 'InboxPage.stateDeclined', + }), + }; + } else if (txIsAccepted(tx)) { + return { + nameClassName: css.nameNotEmphasized, + bookingClassName: css.bookingNoActionNeeded, + lastTransitionedAtClassName: css.lastTransitionedAtNotEmphasized, + stateClassName: css.stateSucces, + state: intl.formatMessage({ + id: 'InboxPage.stateAccepted', + }), + }; + } else if (txIsCanceled(tx)) { + return { + nameClassName: css.nameNotEmphasized, + bookingClassName: css.bookingNoActionNeeded, + lastTransitionedAtClassName: css.lastTransitionedAtNotEmphasized, + stateClassName: css.stateNoActionNeeded, + state: intl.formatMessage({ + id: 'InboxPage.stateCanceled', + }), + }; + } else if (txHasBeenDelivered(tx)) { + return { + nameClassName: css.nameNotEmphasized, + bookingClassName: css.bookingNoActionNeeded, + lastTransitionedAtClassName: css.lastTransitionedAtNotEmphasized, + stateClassName: css.stateNoActionNeeded, + state: intl.formatMessage({ + id: 'InboxPage.stateDelivered', + }), + }; + } else { + console.warn('This transition is unknown:', tx.attributes.lastTransition); + return null; } - return { - nameClassName: isOrder ? css.nameRequested : css.namePending, - bookingClassName: isOrder ? css.bookingRequested : css.bookingPending, - lastTransitionedAtClassName: isOrder - ? css.lastTransitionedAtRequested - : css.lastTransitionedAtPending, - stateClassName: isOrder ? css.stateRequested : css.statePending, - state: isOrder - ? intl.formatMessage({ - id: 'InboxPage.stateRequested', - }) - : intl.formatMessage({ - id: 'InboxPage.statePending', - }), - }; }; const bookingData = (unitType, tx, isOrder, intl) => { @@ -176,7 +184,7 @@ BookingInfoMaybe.propTypes = { }; export const InboxItem = props => { - const { unitType, type, tx, intl } = props; + const { unitType, type, tx, intl, stateData } = props; const { customer, provider } = tx; const isOrder = type === 'order'; @@ -187,7 +195,6 @@ export const InboxItem = props => { const isOtherUserBanned = otherUser.attributes.banned; const otherUserDisplayName = userDisplayName(otherUser, bannedUserDisplayName); - const stateData = txState(intl, tx, isOrder); const isSaleNotification = !isOrder && txIsRequested(tx); const rowNotificationDot = isSaleNotification ?
    : null; const lastTransitionedAt = formatDate(intl, tx.attributes.lastTransitionedAt); @@ -270,11 +277,15 @@ export const InboxPageComponent = props => { const title = isOrders ? ordersTitle : salesTitle; const toTxItem = tx => { - return ( + const type = isOrders ? 'order' : 'sale'; + const stateData = txState(intl, tx, type); + + // Render InboxItem only if the latest transition of the transaction is handled in the `txState` function. + return stateData ? (
  • - +
  • - ); + ) : null; }; const error = fetchOrdersOrSalesError ? ( diff --git a/src/containers/InboxPage/InboxPage.test.js b/src/containers/InboxPage/InboxPage.test.js index 484beb8a..f3eef444 100644 --- a/src/containers/InboxPage/InboxPage.test.js +++ b/src/containers/InboxPage/InboxPage.test.js @@ -8,9 +8,10 @@ import { createTransaction, createBooking, } from '../../util/test-data'; -import { InboxPageComponent, InboxItem } from './InboxPage'; +import { InboxPageComponent, InboxItem, txState } from './InboxPage'; import routeConfiguration from '../../routeConfiguration'; -import { LINE_ITEM_NIGHT, TRANSITION_REQUEST } from '../../util/types'; +import { TRANSITION_REQUEST } from '../../util/transaction'; +import { LINE_ITEM_NIGHT } from '../../util/types'; const noop = () => null; @@ -73,6 +74,8 @@ describe('InboxPage', () => { const ordersTree = renderShallow(); expect(ordersTree).toMatchSnapshot(); + const stateDataOrder = txState(fakeIntl, ordersProps.transactions[0], 'order'); + // Deeply render one InboxItem const orderItem = renderDeep( { type="order" tx={ordersProps.transactions[0]} intl={fakeIntl} + stateData={stateDataOrder} /> ); expect(orderItem).toMatchSnapshot(); @@ -127,6 +131,8 @@ describe('InboxPage', () => { const salesTree = renderShallow(); expect(salesTree).toMatchSnapshot(); + const stateDataSale = txState(fakeIntl, salesProps.transactions[0], 'sale'); + // Deeply render one InboxItem const saleItem = renderDeep( { type="sale" tx={salesProps.transactions[0]} intl={fakeIntl} + stateData={stateDataSale} /> ); expect(saleItem).toMatchSnapshot(); diff --git a/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap b/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap index 914c89a7..c1b3ae9c 100644 --- a/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap +++ b/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap @@ -91,6 +91,15 @@ exports[`InboxPage matches snapshot 1`] = ` "now": [Function], } } + stateData={ + Object { + "bookingClassName": undefined, + "lastTransitionedAtClassName": undefined, + "nameClassName": undefined, + "state": "InboxPage.stateRequested", + "stateClassName": undefined, + } + } tx={ Object { "attributes": Object { @@ -216,6 +225,15 @@ exports[`InboxPage matches snapshot 1`] = ` "now": [Function], } } + stateData={ + Object { + "bookingClassName": undefined, + "lastTransitionedAtClassName": undefined, + "nameClassName": undefined, + "state": "InboxPage.stateRequested", + "stateClassName": undefined, + } + } tx={ Object { "attributes": Object { @@ -483,6 +501,15 @@ exports[`InboxPage matches snapshot 3`] = ` "now": [Function], } } + stateData={ + Object { + "bookingClassName": undefined, + "lastTransitionedAtClassName": undefined, + "nameClassName": undefined, + "state": "InboxPage.statePending", + "stateClassName": undefined, + } + } tx={ Object { "attributes": Object { @@ -608,6 +635,15 @@ exports[`InboxPage matches snapshot 3`] = ` "now": [Function], } } + stateData={ + Object { + "bookingClassName": undefined, + "lastTransitionedAtClassName": undefined, + "nameClassName": undefined, + "state": "InboxPage.statePending", + "stateClassName": undefined, + } + } tx={ Object { "attributes": Object { diff --git a/src/containers/ListingPage/ListingPage.duck.js b/src/containers/ListingPage/ListingPage.duck.js index c64ee5f4..54c83262 100644 --- a/src/containers/ListingPage/ListingPage.duck.js +++ b/src/containers/ListingPage/ListingPage.duck.js @@ -5,7 +5,7 @@ import { types as sdkTypes } from '../../util/sdkLoader'; import { storableError } from '../../util/errors'; import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck'; import { denormalisedResponseEntities } from '../../util/data'; -import { TRANSITION_ENQUIRE } from '../../util/types'; +import { TRANSITION_ENQUIRE } from '../../util/transaction'; import { LISTING_PAGE_DRAFT_VARIANT, LISTING_PAGE_PENDING_APPROVAL_VARIANT, diff --git a/src/containers/TransactionPage/TransactionPage.duck.js b/src/containers/TransactionPage/TransactionPage.duck.js index f102e14f..903777bf 100644 --- a/src/containers/TransactionPage/TransactionPage.duck.js +++ b/src/containers/TransactionPage/TransactionPage.duck.js @@ -7,13 +7,12 @@ import { types as sdkTypes } from '../../util/sdkLoader'; import { isTransactionsTransitionInvalidTransition, storableError } from '../../util/errors'; import { txIsEnquired, + getReview1Transition, + getReview2Transition, + txIsInFirstReviewBy, TRANSITION_ACCEPT, TRANSITION_DECLINE, - TRANSITION_REVIEW_1_BY_CUSTOMER, - TRANSITION_REVIEW_1_BY_PROVIDER, - TRANSITION_REVIEW_2_BY_CUSTOMER, - TRANSITION_REVIEW_2_BY_PROVIDER, -} from '../../util/types'; +} from '../../util/transaction'; import * as log from '../../util/log'; import { updatedEntities, @@ -443,8 +442,7 @@ const IMAGE_VARIANTS = { // If other party has already sent a review, we need to make transition to // TRANSITION_REVIEW_2_BY_ const sendReviewAsSecond = (id, params, role, dispatch, sdk) => { - const transition = - role === CUSTOMER ? TRANSITION_REVIEW_2_BY_CUSTOMER : TRANSITION_REVIEW_2_BY_PROVIDER; + const transition = getReview2Transition(role === CUSTOMER); const include = REVIEW_TX_INCLUDES; @@ -470,8 +468,7 @@ const sendReviewAsSecond = (id, params, role, dispatch, sdk) => { // So, error is likely to happen and then we must try another state transition // by calling sendReviewAsSecond(). const sendReviewAsFirst = (id, params, role, dispatch, sdk) => { - const transition = - role === CUSTOMER ? TRANSITION_REVIEW_1_BY_CUSTOMER : TRANSITION_REVIEW_1_BY_PROVIDER; + const transition = getReview1Transition(role === CUSTOMER); const include = REVIEW_TX_INCLUDES; return sdk.transactions @@ -498,10 +495,7 @@ const sendReviewAsFirst = (id, params, role, dispatch, sdk) => { export const sendReview = (role, tx, reviewRating, reviewContent) => (dispatch, getState, sdk) => { const params = { reviewRating, reviewContent }; - const txStateOtherPartyFirst = - role === CUSTOMER - ? tx.attributes.lastTransition === TRANSITION_REVIEW_1_BY_PROVIDER - : tx.attributes.lastTransition === TRANSITION_REVIEW_1_BY_CUSTOMER; + const txStateOtherPartyFirst = txIsInFirstReviewBy(tx, role !== CUSTOMER); dispatch(sendReviewRequest()); diff --git a/src/containers/TransactionPage/TransactionPage.test.js b/src/containers/TransactionPage/TransactionPage.test.js index ccec9181..c8a7cd8e 100644 --- a/src/containers/TransactionPage/TransactionPage.test.js +++ b/src/containers/TransactionPage/TransactionPage.test.js @@ -8,7 +8,7 @@ import { fakeIntl, } from '../../util/test-data'; import { renderShallow } from '../../util/test-helpers'; -import { TRANSITION_REQUEST } from '../../util/types'; +import { TRANSITION_REQUEST } from '../../util/transaction'; import { TransactionPageComponent } from './TransactionPage'; const noop = () => null; diff --git a/src/ducks/user.duck.js b/src/ducks/user.duck.js index 0f40fbd2..c37ddebc 100644 --- a/src/ducks/user.duck.js +++ b/src/ducks/user.duck.js @@ -3,11 +3,8 @@ import isUndefined from 'lodash/isUndefined'; import config from '../config'; import { denormalisedResponseEntities, ensureOwnListing } from '../util/data'; import { storableError } from '../util/errors'; -import { - LISTING_STATE_DRAFT, - TRANSITION_REQUEST, - TRANSITION_REQUEST_AFTER_ENQUIRY, -} from '../util/types'; +import { transitionsToRequested } from '../util/transaction'; +import { LISTING_STATE_DRAFT } from '../util/types'; import * as log from '../util/log'; import { authInfo } from './Auth.duck'; @@ -319,7 +316,7 @@ export const fetchCurrentUserNotifications = () => (dispatch, getState, sdk) => const apiQueryParams = { only: 'sale', - last_transitions: [TRANSITION_REQUEST, TRANSITION_REQUEST_AFTER_ENQUIRY], + last_transitions: transitionsToRequested, page: 1, per_page: NOTIFICATION_PAGE_SIZE, }; diff --git a/src/forms/BookingDatesForm/BookingDatesForm.test.js b/src/forms/BookingDatesForm/BookingDatesForm.test.js index 0ff5fbf4..bd875e77 100644 --- a/src/forms/BookingDatesForm/BookingDatesForm.test.js +++ b/src/forms/BookingDatesForm/BookingDatesForm.test.js @@ -4,7 +4,7 @@ import Decimal from 'decimal.js'; import { types as sdkTypes } from '../../util/sdkLoader'; import { renderShallow, renderDeep } from '../../util/test-helpers'; import { fakeIntl } from '../../util/test-data'; -import { LINE_ITEM_NIGHT, TRANSITION_REQUEST } from '../../util/types'; +import { LINE_ITEM_NIGHT } from '../../util/types'; import { dateFromAPIToLocalNoon } from '../../util/dates'; import { BookingBreakdown } from '../../components'; import { BookingDatesFormComponent } from './BookingDatesForm'; @@ -70,7 +70,6 @@ describe('EstimatedBreakdownMaybe', () => { expect(dateFromAPIToLocalNoon(booking.attributes.start)).toEqual(startDate); expect(dateFromAPIToLocalNoon(booking.attributes.end)).toEqual(endDate); - expect(transaction.attributes.lastTransition).toEqual(TRANSITION_REQUEST); expect(transaction.attributes.payinTotal).toEqual(new Money(2198, 'USD')); expect(transaction.attributes.payoutTotal).toEqual(new Money(2198, 'USD')); expect(transaction.attributes.lineItems).toEqual([ diff --git a/src/forms/BookingDatesForm/EstimatedBreakdownMaybe.js b/src/forms/BookingDatesForm/EstimatedBreakdownMaybe.js index c4507e2f..8289d423 100644 --- a/src/forms/BookingDatesForm/EstimatedBreakdownMaybe.js +++ b/src/forms/BookingDatesForm/EstimatedBreakdownMaybe.js @@ -30,13 +30,8 @@ import moment from 'moment'; import Decimal from 'decimal.js'; import { types as sdkTypes } from '../../util/sdkLoader'; import { dateFromLocalToAPI, nightsBetween, daysBetween } from '../../util/dates'; -import { - LINE_ITEM_DAY, - LINE_ITEM_NIGHT, - LINE_ITEM_UNITS, - TRANSITION_REQUEST, - TX_TRANSITION_ACTOR_CUSTOMER, -} from '../../util/types'; +import { TRANSITION_REQUEST, TX_TRANSITION_ACTOR_CUSTOMER } from '../../util/transaction'; +import { LINE_ITEM_DAY, LINE_ITEM_NIGHT, LINE_ITEM_UNITS } from '../../util/types'; import { unitDivisor, convertMoneyToNumber, convertUnitToSubUnit } from '../../util/currency'; import { BookingBreakdown } from '../../components'; diff --git a/src/util/test-data.js b/src/util/test-data.js index b47347ee..e6f20f41 100644 --- a/src/util/test-data.js +++ b/src/util/test-data.js @@ -7,9 +7,8 @@ import { TRANSITION_REQUEST, TX_TRANSITION_ACTOR_CUSTOMER, TX_TRANSITION_ACTOR_PROVIDER, - LISTING_STATE_PUBLISHED, - TIME_SLOT_DAY, -} from '../util/types'; +} from '../util/transaction'; +import { LISTING_STATE_PUBLISHED, TIME_SLOT_DAY } from '../util/types'; const { UUID, LatLng, Money } = sdkTypes; diff --git a/src/util/transaction.js b/src/util/transaction.js new file mode 100644 index 00000000..bf9207b0 --- /dev/null +++ b/src/util/transaction.js @@ -0,0 +1,314 @@ +import { ensureTransaction } from './data'; + +/** + * Transitions + * + * These strings must sync with values defined in Flex API, + * since transaction objects given by API contain info about last transitions. + * All the actions in API side happen in transitions, + * so we need to understand what those strings mean. + */ + +// When a customer makes a booking to a listing, a transaction is +// created with the initial request transition. +export const TRANSITION_REQUEST = 'transition/request'; + +// A customer can also initiate a transaction with an enquiry, and +// then transition that with a request. +export const TRANSITION_ENQUIRE = 'transition/enquire'; +export const TRANSITION_REQUEST_AFTER_ENQUIRY = 'transition/request-after-enquiry'; + +// When the provider accepts or declines a transaction from the +// SalePage, it is transitioned with the accept or decline transition. +export const TRANSITION_ACCEPT = 'transition/accept'; +export const TRANSITION_DECLINE = 'transition/decline'; + +// The backend automatically expire the transaction. +export const TRANSITION_EXPIRE = 'transition/expire'; + +// Admin can also cancel the transition. +export const TRANSITION_CANCEL = 'transition/cancel'; + +// The backend will mark the transaction completed. +export const TRANSITION_COMPLETE = 'transition/complete'; + +// Reviews are given through transaction transitions. Review 1 can be +// by provider or customer, and review 2 will be the other party of +// the transaction. +export const TRANSITION_REVIEW_1_BY_PROVIDER = 'transition/review-1-by-provider'; +export const TRANSITION_REVIEW_2_BY_PROVIDER = 'transition/review-2-by-provider'; +export const TRANSITION_REVIEW_1_BY_CUSTOMER = 'transition/review-1-by-customer'; +export const TRANSITION_REVIEW_2_BY_CUSTOMER = 'transition/review-2-by-customer'; +export const TRANSITION_EXPIRE_CUSTOMER_REVIEW_PERIOD = 'transition/expire-customer-review-period'; +export const TRANSITION_EXPIRE_PROVIDER_REVIEW_PERIOD = 'transition/expire-provider-review-period'; +export const TRANSITION_EXPIRE_REVIEW_PERIOD = 'transition/expire-review-period'; + +/** + * Actors + * + * There are 4 different actors that might initiate transitions: + */ + +// Roles of actors that perform transaction transitions +export const TX_TRANSITION_ACTOR_CUSTOMER = 'customer'; +export const TX_TRANSITION_ACTOR_PROVIDER = 'provider'; +export const TX_TRANSITION_ACTOR_SYSTEM = 'system'; +export const TX_TRANSITION_ACTOR_OPERATOR = 'operator'; + +export const TX_TRANSITION_ACTORS = [ + TX_TRANSITION_ACTOR_CUSTOMER, + TX_TRANSITION_ACTOR_PROVIDER, + TX_TRANSITION_ACTOR_SYSTEM, + TX_TRANSITION_ACTOR_OPERATOR, +]; + +/** + * States + * + * These constants are only for making it clear how transitions work together. + * You should not use these constants outside of this file. + * + * Note: these states are not in sync with states used transaction process definitions + * in Marketplace API. Only last transitions are passed along transaction object. + */ +const STATE_INITIAL = 'initial'; +const STATE_ENQUIRY = 'enquiry'; +const STATE_PREAUTHORIZED = 'preauthorized'; +const STATE_DECLINED = 'declined'; +const STATE_ACCEPTED = 'accepted'; +const STATE_CANCELED = 'canceled'; +const STATE_DELIVERED = 'delivered'; +const STATE_REVIEWED = 'reviewed'; +const STATE_REVIEWED_BY_CUSTOMER = 'reviewed-by-customer'; +const STATE_REVIEWED_BY_PROVIDER = 'reviewed-by-provider'; + +/** + * Description of transaction process + * + * You should keep this in sync with transaction process defined in Marketplace API + * + * Note: we don't use yet any state machine library, + * but this description format is following Xstate (FSM library) + * https://xstate.js.org/docs/ + */ +const stateDescription = { + // id is defined only to support Xstate format. + // However if you have multiple transaction processes defined, + // it is best to keep them in sync with transaction process aliases. + id: 'preauth-with-nightly-booking/release-1', + + // This 'initial' state is a starting point for new transaction + initial: STATE_INITIAL, + + // States + states: { + [STATE_INITIAL]: { + on: { + [TRANSITION_ENQUIRE]: STATE_ENQUIRY, + [TRANSITION_REQUEST]: STATE_PREAUTHORIZED, + }, + }, + [STATE_ENQUIRY]: { + on: { + [TRANSITION_REQUEST_AFTER_ENQUIRY]: STATE_PREAUTHORIZED, + }, + }, + + [STATE_PREAUTHORIZED]: { + on: { + [TRANSITION_DECLINE]: STATE_DECLINED, + [TRANSITION_EXPIRE]: STATE_DECLINED, + [TRANSITION_ACCEPT]: STATE_ACCEPTED, + }, + }, + + [STATE_DECLINED]: {}, + [STATE_ACCEPTED]: { + on: { + [TRANSITION_CANCEL]: STATE_CANCELED, + [TRANSITION_COMPLETE]: STATE_DELIVERED, + }, + }, + + [STATE_CANCELED]: {}, + [STATE_DELIVERED]: { + on: { + [TRANSITION_EXPIRE_REVIEW_PERIOD]: STATE_REVIEWED, + [TRANSITION_REVIEW_1_BY_CUSTOMER]: STATE_REVIEWED_BY_CUSTOMER, + [TRANSITION_REVIEW_1_BY_PROVIDER]: STATE_REVIEWED_BY_PROVIDER, + }, + }, + + [STATE_REVIEWED_BY_CUSTOMER]: { + on: { + [TRANSITION_REVIEW_2_BY_PROVIDER]: STATE_REVIEWED, + [TRANSITION_EXPIRE_PROVIDER_REVIEW_PERIOD]: STATE_REVIEWED, + }, + }, + [STATE_REVIEWED_BY_PROVIDER]: { + on: { + [TRANSITION_REVIEW_2_BY_CUSTOMER]: STATE_REVIEWED, + [TRANSITION_EXPIRE_CUSTOMER_REVIEW_PERIOD]: STATE_REVIEWED, + }, + }, + [STATE_REVIEWED]: { type: 'final' }, + }, +}; + +// Note: currently we assume that state description doesn't contain nested states. +const statesFromStateDescription = description => description.states || {}; + +// Get all the transitions from states object in an array +const getTransitions = states => { + const stateNames = Object.keys(states); + + const transitionsReducer = (transitionArray, name) => { + const stateTransitions = states[name] && states[name].on; + const transitionKeys = stateTransitions ? Object.keys(stateTransitions) : []; + return [ + ...transitionArray, + ...transitionKeys.map(key => ({ key, value: stateTransitions[key] })), + ]; + }; + + return stateNames.reduce(transitionsReducer, []); +}; + +// This is a list of all the transitions that this app should be able to handle. +export const TRANSITIONS = getTransitions(statesFromStateDescription(stateDescription)).map( + t => t.key +); + +// This function returns a function that has given stateDesc in scope chain. +const getTransitionsToStateFn = stateDesc => state => + getTransitions(statesFromStateDescription(stateDesc)) + .filter(t => t.value === state) + .map(t => t.key); + +// Get all the transitions that lead to specified state. +const getTransitionsToState = getTransitionsToStateFn(stateDescription); + +// This is needed to fetch transactions that need response from provider. +// I.e. transactions which provider needs to accept or decline +export const transitionsToRequested = getTransitionsToState(STATE_PREAUTHORIZED); + +/** + * Helper functions to figure out if transaction is in a specific state. + * State is based on lastTransition given by transaction object and state description. + */ + +const txLastTransition = tx => ensureTransaction(tx).attributes.lastTransition; + +// DEPRECATED: use txIsDelivered instead +export const txIsCompleted = tx => txLastTransition(tx) === TRANSITION_COMPLETE; + +export const txIsEnquired = tx => + getTransitionsToState(STATE_ENQUIRY).includes(txLastTransition(tx)); + +// Note: state name used in Marketplace API docs (and here) is actually preauthorized +// However, word "requested" is used in many places so that we decided to keep it. +export const txIsRequested = tx => + getTransitionsToState(STATE_PREAUTHORIZED).includes(txLastTransition(tx)); + +export const txIsAccepted = tx => + getTransitionsToState(STATE_ACCEPTED).includes(txLastTransition(tx)); + +export const txIsDeclined = tx => + getTransitionsToState(STATE_DECLINED).includes(txLastTransition(tx)); + +export const txIsCanceled = tx => + getTransitionsToState(STATE_CANCELED).includes(txLastTransition(tx)); + +export const txIsDelivered = tx => + getTransitionsToState(STATE_DELIVERED).includes(txLastTransition(tx)); + +const firstReviewTransitions = [ + ...getTransitionsToState(STATE_REVIEWED_BY_CUSTOMER), + ...getTransitionsToState(STATE_REVIEWED_BY_PROVIDER), +]; +export const txIsInFirstReview = tx => firstReviewTransitions.includes(txLastTransition(tx)); + +export const txIsInFirstReviewBy = (tx, isCustomer) => + isCustomer + ? getTransitionsToState(STATE_REVIEWED_BY_CUSTOMER).includes(txLastTransition(tx)) + : getTransitionsToState(STATE_REVIEWED_BY_PROVIDER).includes(txLastTransition(tx)); + +export const txIsReviewed = tx => + getTransitionsToState(STATE_REVIEWED).includes(txLastTransition(tx)); + +/** + * Helper functions to figure out if transaction has passed a given state. + * This is based on transitions history given by transaction object. + */ + +const txTransitions = tx => ensureTransaction(tx).attributes.transitions || []; +const hasPassedTransition = (transitionName, tx) => + !!txTransitions(tx).find(t => t.transition === transitionName); + +const hasPassedStateFn = state => tx => + getTransitionsToState(state).filter(t => hasPassedTransition(t, tx)).length > 0; + +export const txHasBeenAccepted = hasPassedStateFn(STATE_ACCEPTED); +export const txHasBeenDelivered = hasPassedStateFn(STATE_DELIVERED); + +/** + * Other transaction related utility functions + */ + +export const transitionIsReviewed = transition => + getTransitionsToState(STATE_REVIEWED).includes(transition); + +export const transitionIsFirstReviewedBy = (transition, isCustomer) => + isCustomer + ? getTransitionsToState(STATE_REVIEWED_BY_CUSTOMER).includes(transition) + : getTransitionsToState(STATE_REVIEWED_BY_PROVIDER).includes(transition); + +export const getReview1Transition = isCustomer => + isCustomer ? TRANSITION_REVIEW_1_BY_CUSTOMER : TRANSITION_REVIEW_1_BY_PROVIDER; + +export const getReview2Transition = isCustomer => + isCustomer ? TRANSITION_REVIEW_2_BY_CUSTOMER : TRANSITION_REVIEW_2_BY_PROVIDER; + +// Check if a transition is the kind that should be rendered +// when showing transition history (e.g. ActivityFeed) +// The first transition and most of the expiration transitions made by system are not relevant +export const isRelevantPastTransition = transition => { + return [ + TRANSITION_ACCEPT, + TRANSITION_CANCEL, + TRANSITION_COMPLETE, + TRANSITION_DECLINE, + TRANSITION_EXPIRE, + TRANSITION_REQUEST, + TRANSITION_REQUEST_AFTER_ENQUIRY, + TRANSITION_REVIEW_1_BY_CUSTOMER, + TRANSITION_REVIEW_1_BY_PROVIDER, + TRANSITION_REVIEW_2_BY_CUSTOMER, + TRANSITION_REVIEW_2_BY_PROVIDER, + ].includes(transition); +}; + +export const isCustomerReview = transition => { + return [TRANSITION_REVIEW_1_BY_CUSTOMER, TRANSITION_REVIEW_2_BY_CUSTOMER].includes(transition); +}; + +export const isProviderReview = transition => { + return [TRANSITION_REVIEW_1_BY_PROVIDER, TRANSITION_REVIEW_2_BY_PROVIDER].includes(transition); +}; + +export const getUserTxRole = (currentUserId, transaction) => { + const tx = ensureTransaction(transaction); + const customer = tx.customer; + if (currentUserId && currentUserId.uuid && tx.id && customer.id) { + // user can be either customer or provider + return currentUserId.uuid === customer.id.uuid + ? TX_TRANSITION_ACTOR_CUSTOMER + : TX_TRANSITION_ACTOR_PROVIDER; + } else { + throw new Error(`Parameters for "userIsCustomer" function were wrong. + currentUserId: ${currentUserId}, transaction: ${transaction}`); + } +}; + +export const txRoleIsProvider = userRole => userRole === TX_TRANSITION_ACTOR_PROVIDER; +export const txRoleIsCustomer = userRole => userRole === TX_TRANSITION_ACTOR_CUSTOMER; diff --git a/src/util/transaction.test.js b/src/util/transaction.test.js new file mode 100644 index 00000000..a48519c0 --- /dev/null +++ b/src/util/transaction.test.js @@ -0,0 +1,96 @@ +import { createUser, createTransaction, createListing, createTxTransition } from './test-data'; + +import { + TX_TRANSITION_ACTOR_CUSTOMER, + TX_TRANSITION_ACTOR_PROVIDER, + TX_TRANSITION_ACTOR_SYSTEM, + TRANSITION_REQUEST, + TRANSITION_ACCEPT, + TRANSITION_COMPLETE, + TRANSITION_EXPIRE_REVIEW_PERIOD, + txIsAccepted, + txIsReviewed, + txHasBeenAccepted, + txHasBeenDelivered, +} from './transaction'; + +const transitionRequest = createTxTransition({ + createdAt: new Date(Date.UTC(2017, 10, 9, 8, 10)), + by: TX_TRANSITION_ACTOR_CUSTOMER, + transition: TRANSITION_REQUEST, +}); + +const transitionAccept = createTxTransition({ + createdAt: new Date(Date.UTC(2017, 10, 9, 8, 12)), + by: TX_TRANSITION_ACTOR_PROVIDER, + transition: TRANSITION_ACCEPT, +}); + +const transitionDelivered = createTxTransition({ + createdAt: new Date(Date.UTC(2017, 10, 16, 8, 12)), + by: TX_TRANSITION_ACTOR_SYSTEM, + transition: TRANSITION_COMPLETE, +}); +const transitionReviewed = createTxTransition({ + createdAt: new Date(Date.UTC(2017, 10, 16, 8, 12)), + by: TX_TRANSITION_ACTOR_SYSTEM, + transition: TRANSITION_EXPIRE_REVIEW_PERIOD, +}); + +const txRequested = createTransaction({ + lastTransition: TRANSITION_REQUEST, + customer: createUser('user1'), + provider: createUser('user2'), + listing: createListing('Listing'), + transitions: [transitionRequest], +}); + +const txAccepted = createTransaction({ + lastTransition: TRANSITION_ACCEPT, + customer: createUser('user1'), + provider: createUser('user2'), + listing: createListing('Listing'), + transitions: [transitionRequest, transitionAccept], +}); + +const txReviewed = createTransaction({ + lastTransition: TRANSITION_EXPIRE_REVIEW_PERIOD, + customer: createUser('user1'), + provider: createUser('user2'), + listing: createListing('Listing'), + transitions: [transitionRequest, transitionAccept, transitionDelivered, transitionReviewed], +}); + +describe('transaction utils', () => { + describe('tx is in correct state', () => { + it(`txIsReviewed(txReviewed) succeeds with last transaction: ${TRANSITION_EXPIRE_REVIEW_PERIOD}`, () => { + expect(txIsReviewed(txReviewed)).toEqual(true); + }); + it(`txIsAccepted(txReviewed) fails with last transaction: ${TRANSITION_EXPIRE_REVIEW_PERIOD}`, () => { + expect(txIsAccepted(txReviewed)).toEqual(false); + }); + }); + + describe('tx has passed a state X', () => { + it('txHasBeenAccepted(txRequested) fails', () => { + expect(txHasBeenAccepted(txRequested)).toEqual(false); + }); + it('txHasBeenDelivered(txRequest) fails', () => { + expect(txHasBeenDelivered(txRequested)).toEqual(false); + }); + + it('txHasBeenAccepted(txAccepted) succeeds', () => { + expect(txHasBeenAccepted(txAccepted)).toEqual(true); + }); + it('txHasBeenDelivered(txAccepted) fails', () => { + expect(txHasBeenDelivered(txAccepted)).toEqual(false); + }); + + it('txHasBeenAccepted(txReviewed) succeeds', () => { + expect(txHasBeenAccepted(txReviewed)).toEqual(true); + }); + it('txHasBeenDelivered(txReviewed) succeeds', () => { + expect(txHasBeenDelivered(txReviewed)).toEqual(true); + }); + }); +}); diff --git a/src/util/types.js b/src/util/types.js index 88932196..5ab9070e 100644 --- a/src/util/types.js +++ b/src/util/types.js @@ -32,7 +32,7 @@ import { } from 'prop-types'; import Decimal from 'decimal.js'; import { types as sdkTypes } from './sdkLoader'; -import { ensureTransaction } from './data'; +import { TRANSITIONS, TX_TRANSITION_ACTORS } from './transaction'; const { UUID, LatLng, LatLngBounds, Money } = sdkTypes; @@ -231,131 +231,12 @@ propTypes.availabilityException = shape({ }), }); -// When a customer makes a booking to a listing, a transaction is -// created with the initial request transition. -export const TRANSITION_REQUEST = 'transition/request'; - -// A customer can also initiate a transaction with an enquiry, and -// then transition that with a request. -export const TRANSITION_ENQUIRE = 'transition/enquire'; -export const TRANSITION_REQUEST_AFTER_ENQUIRY = 'transition/request-after-enquiry'; - -// When the provider accepts or declines a transaction from the -// SalePage, it is transitioned with the accept or decline transition. -export const TRANSITION_ACCEPT = 'transition/accept'; -export const TRANSITION_DECLINE = 'transition/decline'; - -// The backend automatically expire the transaction. -export const TRANSITION_EXPIRE = 'transition/expire'; - -// Admin can also cancel the transition. -export const TRANSITION_CANCEL = 'transition/cancel'; - -// The backend will mark the transaction completed. -export const TRANSITION_COMPLETE = 'transition/complete'; - -// Reviews are given through transaction transitions. Review 1 can be -// by provider or customer, and review 2 will be the other party of -// the transaction. -export const TRANSITION_REVIEW_1_BY_PROVIDER = 'transition/review-1-by-provider'; -export const TRANSITION_REVIEW_2_BY_PROVIDER = 'transition/review-2-by-provider'; -export const TRANSITION_REVIEW_1_BY_CUSTOMER = 'transition/review-1-by-customer'; -export const TRANSITION_REVIEW_2_BY_CUSTOMER = 'transition/review-2-by-customer'; -export const TRANSITION_EXPIRE_CUSTOMER_REVIEW_PERIOD = 'transition/expire-customer-review-period'; -export const TRANSITION_EXPIRE_PROVIDER_REVIEW_PERIOD = 'transition/expire-provider-review-period'; -export const TRANSITION_EXPIRE_REVIEW_PERIOD = 'transition/expire-review-period'; - -export const TRANSITIONS = [ - TRANSITION_ACCEPT, - TRANSITION_CANCEL, - TRANSITION_COMPLETE, - TRANSITION_DECLINE, - TRANSITION_ENQUIRE, - TRANSITION_EXPIRE, - TRANSITION_EXPIRE_CUSTOMER_REVIEW_PERIOD, - TRANSITION_EXPIRE_PROVIDER_REVIEW_PERIOD, - TRANSITION_EXPIRE_REVIEW_PERIOD, - TRANSITION_REQUEST, - TRANSITION_REQUEST_AFTER_ENQUIRY, - TRANSITION_REVIEW_1_BY_CUSTOMER, - TRANSITION_REVIEW_1_BY_PROVIDER, - TRANSITION_REVIEW_2_BY_CUSTOMER, - TRANSITION_REVIEW_2_BY_PROVIDER, -]; - -// Roles of actors that perform transaction transitions -export const TX_TRANSITION_ACTOR_CUSTOMER = 'customer'; -export const TX_TRANSITION_ACTOR_PROVIDER = 'provider'; -export const TX_TRANSITION_ACTOR_SYSTEM = 'system'; -export const TX_TRANSITION_ACTOR_OPERATOR = 'operator'; - -export const TX_TRANSITION_ACTORS = [ - TX_TRANSITION_ACTOR_CUSTOMER, - TX_TRANSITION_ACTOR_PROVIDER, - TX_TRANSITION_ACTOR_SYSTEM, - TX_TRANSITION_ACTOR_OPERATOR, -]; - -const txLastTransition = tx => ensureTransaction(tx).attributes.lastTransition; - -export const txIsEnquired = tx => txLastTransition(tx) === TRANSITION_ENQUIRE; - -export const txIsRequested = tx => { - const transition = txLastTransition(tx); - return transition === TRANSITION_REQUEST || transition === TRANSITION_REQUEST_AFTER_ENQUIRY; -}; - -export const txIsAccepted = tx => txLastTransition(tx) === TRANSITION_ACCEPT; - -export const txIsDeclined = tx => txLastTransition(tx) === TRANSITION_DECLINE; - -export const txIsExpired = tx => txLastTransition(tx) === TRANSITION_EXPIRE; - -export const txIsDeclinedOrExpired = tx => txIsDeclined(tx) || txIsExpired(tx); - -export const txIsCanceled = tx => txLastTransition(tx) === TRANSITION_CANCEL; - -export const txIsCompleted = tx => txLastTransition(tx) === TRANSITION_COMPLETE; - -export const txHasFirstReview = tx => firstReviewTransitions.includes(txLastTransition(tx)); - -export const txIsReviewed = tx => areReviewsCompleted(txLastTransition(tx)); - propTypes.transition = shape({ createdAt: instanceOf(Date).isRequired, by: oneOf(TX_TRANSITION_ACTORS).isRequired, transition: oneOf(TRANSITIONS).isRequired, }); -const firstReviewTransitions = [TRANSITION_REVIEW_1_BY_CUSTOMER, TRANSITION_REVIEW_1_BY_PROVIDER]; - -// Check if tx transition is followed by a state where -// reviews are completed -export const areReviewsCompleted = transition => { - return [ - TRANSITION_EXPIRE_CUSTOMER_REVIEW_PERIOD, - TRANSITION_EXPIRE_PROVIDER_REVIEW_PERIOD, - TRANSITION_EXPIRE_REVIEW_PERIOD, - TRANSITION_REVIEW_2_BY_CUSTOMER, - TRANSITION_REVIEW_2_BY_PROVIDER, - ].includes(transition); -}; - -export const txHasBeenAccepted = tx => { - const transition = txLastTransition(tx); - return [ - TRANSITION_ACCEPT, - TRANSITION_REVIEW_1_BY_CUSTOMER, - TRANSITION_REVIEW_1_BY_PROVIDER, - TRANSITION_REVIEW_2_BY_CUSTOMER, - TRANSITION_REVIEW_2_BY_PROVIDER, - TRANSITION_EXPIRE_CUSTOMER_REVIEW_PERIOD, - TRANSITION_EXPIRE_PROVIDER_REVIEW_PERIOD, - TRANSITION_EXPIRE_REVIEW_PERIOD, - TRANSITION_COMPLETE, - ].includes(transition); -}; - // Possible amount of stars in a review export const REVIEW_RATINGS = [1, 2, 3, 4, 5];