mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-27 19:42:11 +10:00
Merge pull request #1004 from sharetribe/refactor-transaction-ui
Refactor transaction handling
This commit is contained in:
commit
a270b9552a
36 changed files with 2035 additions and 12173 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
29
src/components/TransactionPanel/AddressLinkMaybe.js
Normal file
29
src/components/TransactionPanel/AddressLinkMaybe.js
Normal 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;
|
||||
33
src/components/TransactionPanel/BreakdownMaybe.js
Normal file
33
src/components/TransactionPanel/BreakdownMaybe.js
Normal 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;
|
||||
26
src/components/TransactionPanel/DetailCardHeadingsMaybe.js
Normal file
26
src/components/TransactionPanel/DetailCardHeadingsMaybe.js
Normal 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;
|
||||
40
src/components/TransactionPanel/DetailCardImage.js
Normal file
40
src/components/TransactionPanel/DetailCardImage.js
Normal 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;
|
||||
64
src/components/TransactionPanel/FeedSection.js
Normal file
64
src/components/TransactionPanel/FeedSection.js
Normal 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;
|
||||
226
src/components/TransactionPanel/PanelHeading.js
Normal file
226
src/components/TransactionPanel/PanelHeading.js
Normal 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;
|
||||
64
src/components/TransactionPanel/SaleActionButtonsMaybe.js
Normal file
64
src/components/TransactionPanel/SaleActionButtonsMaybe.js
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
314
src/util/transaction.js
Normal 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;
|
||||
96
src/util/transaction.test.js
Normal file
96
src/util/transaction.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue