Merge pull request #1004 from sharetribe/refactor-transaction-ui

Refactor transaction handling
This commit is contained in:
Vesa Luusua 2019-01-31 13:58:05 +02:00 committed by GitHub
commit a270b9552a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 2035 additions and 12173 deletions

View file

@ -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)

View file

@ -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;

View file

@ -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 = (
<FormattedMessage id="ActivityFeed.transitionDecline" values={{ displayName }} />
);
case TRANSITION_EXPIRE:
return ownRole === TX_TRANSITION_ACTOR_PROVIDER ? (
return txRoleIsProvider(ownRole) ? (
<FormattedMessage id="ActivityFeed.ownTransitionExpire" />
) : (
<FormattedMessage id="ActivityFeed.transitionExpire" values={{ displayName }} />
@ -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) ? (
<InlineTextButton onClick={onOpenReviewModal}>
<FormattedMessage id="ActivityFeed.leaveAReview" values={{ displayName }} />
</InlineTextButton>
) : null;
return <FormattedMessage id="ActivityFeed.transitionComplete" values={{ reviewLink }} />;
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) ? (
<InlineTextButton onClick={onOpenReviewModal}>
<FormattedMessage id="ActivityFeed.leaveAReviewSecond" values={{ displayName }} />
</InlineTextButton>
@ -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 = (
<Review content={review.attributes.content} rating={review.attributes.rating} />
);
} else if (providerReview) {
} else if (isProviderReview(currentTransition)) {
const review = reviewByAuthorId(currentTransaction, provider.id);
reviewComponent = (
<Review content={review.attributes.content} rating={review.attributes.rating} />
@ -417,7 +387,7 @@ export const ActivityFeedComponent = props => {
};
const transitionListItem = transition => {
if (shouldRenderTransition(transition.transition)) {
if (isRelevantPastTransition(transition.transition)) {
return (
<li key={transition.transition} className={css.transitionItem}>
{transitionComponent(transition)}

View file

@ -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;
}
}

View file

@ -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';

View file

@ -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;

View file

@ -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';

View file

@ -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 ? (
<p className={classes}>
<ExternalLink href={hrefToGoogleMaps}>{fullAddress}</ExternalLink>
</p>
) : null;
};
export default AddressLinkMaybe;

View file

@ -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 ? (
<div className={classes}>
<h3 className={css.bookingBreakdownTitle}>
<FormattedMessage id="TransactionPanel.bookingBreakdownTitle" />
</h3>
<BookingBreakdown
className={breakdownClasses}
userRole={transactionRole}
unitType={config.bookingUnitType}
transaction={transaction}
booking={transaction.booking}
/>
</div>
) : null;
};
export default BreakdownMaybe;

View file

@ -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 ? (
<div className={css.detailCardHeadings}>
<h2 className={css.detailCardTitle}>{listingTitle}</h2>
<p className={css.detailCardSubtitle}>{subTitle}</p>
<AddressLinkMaybe location={location} geolocation={geolocation} showAddress={showAddress} />
</div>
) : null;
};
export default DetailCardHeadingsMaybe;

View file

@ -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 (
<React.Fragment>
<div className={classes}>
<div className={css.aspectWrapper}>
<ResponsiveImage
rootClassName={css.rootForImage}
alt={listingTitle}
image={image}
variants={['landscape-crop', 'landscape-crop2x']}
/>
</div>
</div>
{isCustomer ? (
<div className={avatarWrapperClassName || css.avatarWrapper}>
<AvatarMedium user={provider} />
</div>
) : null}
</React.Fragment>
);
};
export default DetailCardImage;

View file

@ -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 ? (
<div className={classes}>
<h3 className={css.feedHeading}>
<FormattedMessage id="TransactionPanel.activityHeading" />
</h3>
{initialMessageFailed ? (
<p className={css.messageError}>
<FormattedMessage id="TransactionPanel.initialMessageFailed" />
</p>
) : null}
{fetchMessagesError ? (
<p className={css.messageError}>
<FormattedMessage id="TransactionPanel.messageLoadingFailed" />
</p>
) : null}
<ActivityFeed
className={css.feed}
messages={messages}
transaction={currentTransaction}
currentUser={currentUser}
hasOlderMessages={hasOlderMessages && !fetchMessagesInProgress}
onOpenReviewModal={onOpenReviewModal}
onShowOlderMessages={onShowMoreMessages}
fetchMessagesInProgress={fetchMessagesInProgress}
/>
</div>
) : null;
};
export default FeedSection;

View file

@ -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 (
<NamedLink className={className} name="ListingPage" params={params} to={to}>
{label}
</NamedLink>
);
} else {
return <FormattedMessage id="TransactionPanel.deletedListingOrderTitle" />;
}
};
const ListingDeletedInfoMaybe = props => {
return props.listingDeleted ? (
<p className={css.transactionInfoMessage}>
<FormattedMessage id="TransactionPanel.messageDeletedListing" />
</p>
) : null;
};
const HeadingCustomer = props => {
const { className, id, values, listingDeleted } = props;
return (
<React.Fragment>
<h1 className={className}>
<span className={css.mainTitle}>
<FormattedMessage id={id} values={values} />
</span>
</h1>
<ListingDeletedInfoMaybe listingDeleted={listingDeleted} />
</React.Fragment>
);
};
const HeadingCustomerWithSubtitle = props => {
const { className, id, values, subtitleId, subtitleValues, children, listingDeleted } = props;
return (
<React.Fragment>
<h1 className={className}>
<span className={css.mainTitle}>
<FormattedMessage id={id} values={values} />
</span>
<FormattedMessage id={subtitleId} values={subtitleValues} />
</h1>
{children}
<ListingDeletedInfoMaybe listingDeleted={listingDeleted} />
</React.Fragment>
);
};
const CustomerBannedInfoMaybe = props => {
return props.isCustomerBanned ? (
<p className={css.transactionInfoMessage}>
<FormattedMessage id="TransactionPanel.customerBannedStatus" />
</p>
) : null;
};
const HeadingProvider = props => {
const { className, id, values, isCustomerBanned } = props;
return (
<React.Fragment>
<h1 className={className}>
<span className={css.mainTitle}>
<FormattedMessage id={id} values={values} />
</span>
</h1>
<CustomerBannedInfoMaybe isCustomerBanned={isCustomerBanned} />
</React.Fragment>
);
};
// 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 ? (
<HeadingCustomer
className={titleClasses}
id="TransactionPanel.orderEnquiredTitle"
values={{ listingLink }}
listingDeleted={listingDeleted}
/>
) : (
<HeadingProvider
className={titleClasses}
id="TransactionPanel.saleEnquiredTitle"
values={{ customerName, listingLink }}
isCustomerBanned={isCustomerBanned}
/>
);
case HEADING_REQUESTED:
return isCustomer ? (
<HeadingCustomerWithSubtitle
className={titleClasses}
id="TransactionPanel.orderPreauthorizedTitle"
values={{ customerName }}
subtitleId="TransactionPanel.orderPreauthorizedSubtitle"
subtitleValues={{ listingLink }}
>
{!listingDeleted ? (
<p className={css.transactionInfoMessage}>
<FormattedMessage
id="TransactionPanel.orderPreauthorizedInfo"
values={{ providerName }}
/>
</p>
) : null}
</HeadingCustomerWithSubtitle>
) : (
<HeadingProvider
className={titleClasses}
id="TransactionPanel.saleRequestedTitle"
values={{ customerName, listingLink }}
>
{!isCustomerBanned ? (
<p className={titleClasses}>
<FormattedMessage id="TransactionPanel.saleRequestedInfo" values={{ customerName }} />
</p>
) : null}
</HeadingProvider>
);
case HEADING_ACCEPTED:
return isCustomer ? (
<HeadingCustomerWithSubtitle
className={titleClasses}
id="TransactionPanel.orderPreauthorizedTitle"
values={{ customerName }}
subtitleId="TransactionPanel.orderAcceptedSubtitle"
subtitleValues={{ listingLink }}
/>
) : (
<HeadingProvider
className={titleClasses}
id="TransactionPanel.saleAcceptedTitle"
values={{ customerName, listingLink }}
/>
);
case HEADING_DECLINED:
return isCustomer ? (
<HeadingCustomer
className={titleClasses}
id="TransactionPanel.orderDeclinedTitle"
values={{ customerName, listingLink }}
/>
) : (
<HeadingProvider
className={titleClasses}
id="TransactionPanel.saleDeclinedTitle"
values={{ customerName, listingLink }}
isCustomerBanned={isCustomerBanned}
/>
);
case HEADING_CANCELED:
return isCustomer ? (
<HeadingCustomer
className={titleClasses}
id="TransactionPanel.orderCancelledTitle"
values={{ customerName, listingLink }}
/>
) : (
<HeadingProvider
className={titleClasses}
id="TransactionPanel.saleCancelledTitle"
values={{ customerName, listingLink }}
/>
);
case HEADING_DELIVERED:
return isCustomer ? (
<HeadingCustomer
className={titleClasses}
id="TransactionPanel.orderDeliveredTitle"
values={{ customerName, listingLink }}
isCustomerBanned={isCustomerBanned}
/>
) : (
<HeadingProvider
className={titleClasses}
id="TransactionPanel.saleDeliveredTitle"
values={{ customerName, listingLink }}
isCustomerBanned={isCustomerBanned}
/>
);
default:
console.warning('Unknown state given to panel heading.');
return null;
}
};
export default PanelHeading;

View file

@ -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 ? (
<p className={css.actionError}>
<FormattedMessage id="TransactionPanel.acceptSaleFailed" />
</p>
) : null;
const declineErrorMessage = declineSaleError ? (
<p className={css.actionError}>
<FormattedMessage id="TransactionPanel.declineSaleFailed" />
</p>
) : null;
const classes = classNames(rootClassName || css.actionButtons, className);
return showButtons ? (
<div className={classes}>
<div className={css.actionErrors}>
{acceptErrorMessage}
{declineErrorMessage}
</div>
<div className={css.actionButtonWrapper}>
<SecondaryButton
inProgress={declineInProgress}
disabled={buttonsDisabled}
onClick={onDeclineSale}
>
<FormattedMessage id="TransactionPanel.declineButton" />
</SecondaryButton>
<PrimaryButton
inProgress={acceptInProgress}
disabled={buttonsDisabled}
onClick={onAcceptSale}
>
<FormattedMessage id="TransactionPanel.acceptButton" />
</PrimaryButton>
</div>
</div>
) : null;
};
export default SaleActionButtonsMaybe;

View file

@ -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;
}

View file

@ -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 ? (
<div className={classes}>
<h3 className={css.feedHeading}>
<FormattedMessage id="TransactionPanel.activityHeading" />
</h3>
{initialMessageFailed ? (
<p className={css.messageError}>
<FormattedMessage id="TransactionPanel.initialMessageFailed" />
</p>
) : null}
{fetchMessagesError ? (
<p className={css.messageError}>
<FormattedMessage id="TransactionPanel.messageLoadingFailed" />
</p>
) : null}
<ActivityFeed
className={css.feed}
messages={messages}
transaction={currentTransaction}
currentUser={currentUser}
hasOlderMessages={hasOlderMessages && !fetchMessagesInProgress}
onOpenReviewModal={onOpenReviewModal}
onShowOlderMessages={() => {
onShowMoreMessages(currentTransaction.id);
}}
fetchMessagesInProgress={fetchMessagesInProgress}
/>
</div>
) : 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 ? (
<p className={css.address}>
<ExternalLink href={hrefToGoogleMaps}>{fullAddress}</ExternalLink>
</p>
) : 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 ? (
<div className={classes}>
<h3 className={css.bookingBreakdownTitle}>
<FormattedMessage id="TransactionPanel.bookingBreakdownTitle" />
</h3>
<BookingBreakdown
className={breakdownClasses}
userRole={transactionRole}
unitType={config.bookingUnitType}
transaction={transaction}
booking={transaction.booking}
/>
</div>
) : 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 (
<NamedLink className={className} name="ListingPage" params={params} to={to}>
{label}
</NamedLink>
);
} else {
return <FormattedMessage id="TransactionPanel.deletedListingOrderTitle" />;
}
};
// 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 ? (
<div className={css.detailCardHeadings}>
<h2 className={css.detailCardTitle}>{listingTitle}</h2>
<p className={css.detailCardSubtitle}>{subTitle}</p>
<AddressLinkMaybe
transaction={transaction}
transactionRole={transactionRole}
currentListing={listing}
/>
</div>
) : 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 ? (
<BookingPanel
className={css.bookingPanel}
titleClassName={css.bookingTitle}
isOwnListing={false}
listing={listing}
handleBookingSubmit={() => 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 ? (
<p className={css.actionError}>
<FormattedMessage id="TransactionPanel.acceptSaleFailed" />
</p>
) : null;
const declineErrorMessage = declineSaleError ? (
<p className={css.actionError}>
<FormattedMessage id="TransactionPanel.declineSaleFailed" />
</p>
) : null;
const classes = classNames(rootClassName || css.actionButtons, className);
return canShowButtons ? (
<div className={classes}>
<div className={css.actionErrors}>
{acceptErrorMessage}
{declineErrorMessage}
</div>
<div className={css.actionButtonWrapper}>
<SecondaryButton
inProgress={declineInProgress}
disabled={buttonsDisabled}
onClick={() => onDeclineSale(transaction.id)}
>
<FormattedMessage id="TransactionPanel.declineButton" />
</SecondaryButton>
<PrimaryButton
inProgress={acceptInProgress}
disabled={buttonsDisabled}
onClick={() => onAcceptSale(transaction.id)}
>
<FormattedMessage id="TransactionPanel.acceptButton" />
</PrimaryButton>
</div>
</div>
) : 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 (
<h1 className={classes}>
<span className={css.mainTitle}>
<FormattedMessage id="TransactionPanel.orderEnquiredTitle" values={{ listingLink }} />
</span>
</h1>
);
} else if (txIsRequested(transaction)) {
return (
<h1 className={classes}>
<span className={css.mainTitle}>
<FormattedMessage
id="TransactionPanel.orderPreauthorizedTitle"
values={{ customerName }}
/>
</span>
<FormattedMessage
id="TransactionPanel.orderPreauthorizedSubtitle"
values={{ listingLink }}
/>
</h1>
);
} else if (txIsAccepted(transaction)) {
return (
<h1 className={classes}>
<span className={css.mainTitle}>
<FormattedMessage id="TransactionPanel.orderAcceptedTitle" values={{ customerName }} />
</span>
<FormattedMessage id="TransactionPanel.orderAcceptedSubtitle" values={{ listingLink }} />
</h1>
);
} else if (txIsDeclined(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.orderDeclinedTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (txIsExpired(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.orderDeclinedTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (txIsCanceled(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.orderCancelledTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (
txIsCompleted(transaction) ||
txHasFirstReview(transaction) ||
txIsReviewed(transaction)
) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.orderDeliveredTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} 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 (
<p className={classes}>
<FormattedMessage id="TransactionPanel.orderPreauthorizedInfo" values={{ providerName }} />
</p>
);
} else if (listingDeleted) {
return (
<p className={classes}>
<FormattedMessage id="TransactionPanel.messageDeletedListing" />
</p>
);
}
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 (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.saleEnquiredTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (txIsRequested(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.saleRequestedTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (txIsAccepted(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.saleAcceptedTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (txIsDeclined(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.saleDeclinedTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (txIsExpired(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.saleDeclinedTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (txIsCanceled(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.saleCancelledTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (
txIsCompleted(transaction) ||
txHasFirstReview(transaction) ||
txIsReviewed(transaction)
) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.saleDeliveredTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} 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 (
<p className={classes}>
<FormattedMessage id="TransactionPanel.saleRequestedInfo" values={{ customerName }} />
</p>
);
} else if (isCustomerBanned) {
return (
<p className={classes}>
<FormattedMessage id="TransactionPanel.customerBannedStatus" />
</p>
);
}
return null;
};
// Functional component as a helper to choose and show Order or Sale title
export const TransactionPageTitle = props => {
return props.transactionRole === 'customer' ? (
<OrderTitle {...props} />
) : (
<SaleTitle {...props} />
);
};
// Functional component as a helper to choose and show Order or Sale message
export const TransactionPageMessage = props => {
return props.transactionRole === 'customer' ? (
<OrderMessage {...props} />
) : (
<SaleMessage {...props} />
);
};
// 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,
};
};

View file

@ -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 = (
<SaleActionButtonsMaybe
rootClassName={actionButtonClasses}
canShowButtons={canShowSaleButtons}
transaction={currentTransaction}
showButtons={stateData.showSaleButtons}
acceptInProgress={acceptInProgress}
declineInProgress={declineInProgress}
acceptSaleError={acceptSaleError}
declineSaleError={declineSaleError}
onAcceptSale={onAcceptSale}
onDeclineSale={onDeclineSale}
onAcceptSale={() => 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 (
<div className={classes}>
<div className={css.container}>
<div className={css.txInfo}>
<div className={css.imageWrapperMobile}>
<div className={css.aspectWrapper}>
<ResponsiveImage
rootClassName={css.rootForImage}
alt={listingTitle}
image={firstImage}
variants={['landscape-crop', 'landscape-crop2x']}
/>
</div>
</div>
<div className={classNames(css.avatarWrapperMobile, css.avatarMobile)}>
<AvatarMedium user={currentProvider} />
</div>
<DetailCardImage
rootClassName={css.imageWrapperMobile}
avatarWrapperClassName={css.avatarWrapperMobile}
listingTitle={listingTitle}
image={firstImage}
provider={currentProvider}
isCustomer={isCustomer}
/>
{isProvider ? (
<div className={css.avatarWrapperProviderDesktop}>
<AvatarLarge user={currentCustomer} className={css.avatarDesktop} />
</div>
) : null}
<TransactionPageTitle
transaction={currentTransaction}
customerDisplayName={customerDisplayName}
currentListing={currentListing}
listingTitle={listingTitle}
<PanelHeading
panelHeadingState={stateData.headingState}
transactionRole={transactionRole}
/>
<TransactionPageMessage
transaction={currentTransaction}
authorDisplayName={authorDisplayName}
customerDisplayName={customerDisplayName}
listingDeleted={listingDeleted}
providerName={authorDisplayName}
customerName={customerDisplayName}
isCustomerBanned={isCustomerBanned}
transactionRole={transactionRole}
listingId={currentListing.id && currentListing.id.uuid}
listingTitle={listingTitle}
listingDeleted={listingDeleted}
/>
<div className={css.bookingDetailsMobile}>
<div className={css.addressMobileWrapper}>
<AddressLinkMaybe
transaction={currentTransaction}
transactionRole={transactionRole}
currentListing={currentListing}
/>
</div>
<AddressLinkMaybe
rootClassName={css.addressMobile}
location={location}
geolocation={geolocation}
showAddress={stateData.showAddress}
/>
<BreakdownMaybe transaction={currentTransaction} transactionRole={transactionRole} />
</div>
<FeedSection
rootClassName={feedContainerClasses}
rootClassName={css.feedContainer}
currentTransaction={currentTransaction}
currentUser={currentUser}
fetchMessagesError={fetchMessagesError}
@ -276,13 +331,13 @@ export class TransactionPanelComponent extends Component {
messages={messages}
oldestMessagePageFetched={oldestMessagePageFetched}
onOpenReviewModal={this.onOpenReviewModal}
onShowMoreMessages={onShowMoreMessages}
onShowMoreMessages={() => onShowMoreMessages(currentTransaction.id)}
totalMessagePages={totalMessagePages}
/>
<SendMessageForm
form={this.sendMessageFormName}
rootClassName={sendMessageFormClasses}
rootClassName={css.sendMessageForm}
messagePlaceholder={sendMessagePlaceholder}
inProgress={sendMessageInProgress}
sendMessageError={sendMessageError}
@ -290,56 +345,51 @@ export class TransactionPanelComponent extends Component {
onBlur={this.onSendMessageFormBlur}
onSubmit={this.onMessageSubmit}
/>
{canShowSaleButtons ? (
{stateData.showSaleButtons ? (
<div className={css.mobileActionButtons}>{saleButtons}</div>
) : null}
</div>
<div className={css.asideDesktop}>
<div className={css.detailCard}>
<div className={css.detailCardImageWrapper}>
<div className={css.aspectWrapper}>
<ResponsiveImage
rootClassName={css.rootForImage}
alt={listingTitle}
image={firstImage}
variants={['landscape-crop', 'landscape-crop2x']}
/>
</div>
</div>
{isCustomer ? (
<div className={css.avatarWrapperCustomerDesktop}>
<AvatarMedium user={currentProvider} />
</div>
) : null}
<DetailCardImage
avatarWrapperClassName={css.avatarWrapperDesktop}
listingTitle={listingTitle}
image={firstImage}
provider={currentProvider}
isCustomer={isCustomer}
/>
<DetailCardHeadingsMaybe
transaction={currentTransaction}
transactionRole={transactionRole}
listing={currentListing}
showDetailCardHeadings={stateData.showDetailCardHeadings}
listingTitle={listingTitle}
subTitle={bookingSubTitle}
location={location}
geolocation={geolocation}
showAddress={stateData.showAddress}
/>
<BookingPanelMaybe
authorDisplayName={authorDisplayName}
transaction={currentTransaction}
transactionRole={transactionRole}
listing={currentListing}
listingTitle={listingTitle}
subTitle={bookingSubTitle}
provider={currentProvider}
onSubmit={onSubmitBookingRequest}
onManageDisableScrolling={onManageDisableScrolling}
timeSlots={timeSlots}
fetchTimeSlotsError={fetchTimeSlotsError}
/>
{stateData.showBookingPanel ? (
<BookingPanel
className={css.bookingPanel}
titleClassName={css.bookingTitle}
isOwnListing={false}
listing={currentListing}
title={listingTitle}
subTitle={bookingSubTitle}
authorDisplayName={authorDisplayName}
onSubmit={onSubmitBookingRequest}
onManageDisableScrolling={onManageDisableScrolling}
timeSlots={timeSlots}
fetchTimeSlotsError={fetchTimeSlotsError}
/>
) : null}
<BreakdownMaybe
className={css.breakdownContainer}
transaction={currentTransaction}
transactionRole={transactionRole}
/>
{canShowSaleButtons ? (
{stateData.showSaleButtons ? (
<div className={css.desktopActionButtons}>{saleButtons}</div>
) : null}
</div>

View file

@ -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,
});

View file

@ -2,7 +2,7 @@ import pick from 'lodash/pick';
import config from '../../config';
import { denormalisedResponseEntities } from '../../util/data';
import { storableError } from '../../util/errors';
import { TRANSITION_REQUEST, TRANSITION_REQUEST_AFTER_ENQUIRY } from '../../util/types';
import { TRANSITION_REQUEST, TRANSITION_REQUEST_AFTER_ENQUIRY } from '../../util/transaction';
import * as log from '../../util/log';
import { fetchCurrentUserHasOrdersSuccess } from '../../ducks/user.duck';

View file

@ -7,7 +7,7 @@
import moment from 'moment';
import reduce from 'lodash/reduce';
import { types as sdkTypes } from '../../util/sdkLoader';
import { TRANSITION_ENQUIRE } from '../../util/types';
import { TRANSITION_ENQUIRE } from '../../util/transaction';
const { UUID, Money } = sdkTypes;

View file

@ -331,8 +331,25 @@
/* Transaction status affects to certain font colors and weights */
.nameEnquired,
.namePending {
.stateName {
/* This class is empty on purpose, it is used below for banned users */
}
.stateActionNeeded {
font-weight: var(--fontWeightSemiBold);
color: var(--attentionColor);
}
.stateNoActionNeeded {
color: var(--matterColorAnti);
}
.stateSucces {
font-weight: var(--fontWeightSemiBold);
color: var(--successColor);
}
.nameEmphasized {
font-weight: var(--fontWeightBold);
/* baseline alignment fixes */
@ -340,39 +357,11 @@
margin-bottom: 1px;
}
.stateName {
/* This class is empty on purpose, it is used below for banned users */
}
.stateEnquired,
.stateRequested,
.statePending {
font-weight: var(--fontWeightSemiBold);
color: var(--attentionColor);
}
.stateAccepted {
font-weight: var(--fontWeightSemiBold);
color: var(--successColor);
}
.stateCanceled,
.stateDeclined,
.stateDelivered {
color: var(--matterColorAnti);
}
.nameEnquiredOrder,
.nameRequested,
.nameDeclined,
.nameCanceled,
.nameAccepted,
.nameDelivered {
.nameNotEmphasized {
font-weight: var(--fontWeightMedium);
}
.bookingEnquired,
.bookingPending {
.bookingActionNeeded {
color: var(--matterColor);
margin-top: 4px;
@ -381,25 +370,16 @@
}
}
.bookingRequested,
.bookingCanceled,
.bookingDeclined,
.bookingAccepted,
.bookingDelivered {
.bookingNoActionNeeded {
color: var(--matterColorAnti);
}
.lastTransitionedAtEnquired,
.lastTransitionedAtPending,
.lastTransitionedAtRequested {
.lastTransitionedAtEmphasized {
color: var(--matterColor);
font-weight: var(--fontWeightMedium);
}
.lastTransitionedAtCanceled,
.lastTransitionedAtAccepted,
.lastTransitionedAtDeclined,
.lastTransitionedAtDelivered {
.lastTransitionedAtNotEmphasized {
color: var(--matterColorAnti);
}

View file

@ -2,6 +2,7 @@ import reverse from 'lodash/reverse';
import sortBy from 'lodash/sortBy';
import { storableError } from '../../util/errors';
import { parse } from '../../util/urlHelpers';
import { TRANSITIONS } from '../../util/transaction';
import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck';
const sortedTransactions = txs =>
@ -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,

View file

@ -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 ? <div className={css.notificationDot} /> : 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 ? (
<li key={tx.id.uuid} className={css.listItem}>
<InboxItem unitType={unitType} type={isOrders ? 'order' : 'sale'} tx={tx} intl={intl} />
<InboxItem unitType={unitType} type={type} tx={tx} intl={intl} stateData={stateData} />
</li>
);
) : null;
};
const error = fetchOrdersOrSalesError ? (

View file

@ -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(<InboxPageComponent {...ordersProps} />);
expect(ordersTree).toMatchSnapshot();
const stateDataOrder = txState(fakeIntl, ordersProps.transactions[0], 'order');
// Deeply render one InboxItem
const orderItem = renderDeep(
<InboxItem
@ -80,6 +83,7 @@ describe('InboxPage', () => {
type="order"
tx={ordersProps.transactions[0]}
intl={fakeIntl}
stateData={stateDataOrder}
/>
);
expect(orderItem).toMatchSnapshot();
@ -127,6 +131,8 @@ describe('InboxPage', () => {
const salesTree = renderShallow(<InboxPageComponent {...salesProps} />);
expect(salesTree).toMatchSnapshot();
const stateDataSale = txState(fakeIntl, salesProps.transactions[0], 'sale');
// Deeply render one InboxItem
const saleItem = renderDeep(
<InboxItem
@ -134,6 +140,7 @@ describe('InboxPage', () => {
type="sale"
tx={salesProps.transactions[0]}
intl={fakeIntl}
stateData={stateDataSale}
/>
);
expect(saleItem).toMatchSnapshot();

View file

@ -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 {

View file

@ -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,

View file

@ -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_<CUSTOMER/PROVIDER>
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());

View file

@ -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;

View file

@ -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,
};

View file

@ -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([

View file

@ -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';

View file

@ -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;

314
src/util/transaction.js Normal file
View file

@ -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;

View file

@ -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);
});
});
});

View file

@ -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];