Merge pull request #598 from sharetribe/transactionpanel

Transactionpanel
This commit is contained in:
Vesa Luusua 2017-12-18 16:04:39 +02:00 committed by GitHub
commit 241fca0f1e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 10897 additions and 9277 deletions

View file

@ -5,7 +5,7 @@ import { dropWhile } from 'lodash';
import classNames from 'classnames';
import { Avatar, InlineTextButton, ReviewRating } from '../../components';
import { formatDate } from '../../util/dates';
import { ensureTransaction, ensureUser, ensureListing } from '../../util/data';
import { ensureTransaction, ensureUser, ensureListing, userDisplayName } from '../../util/data';
import * as propTypes from '../../util/propTypes';
import * as log from '../../util/log';
@ -212,7 +212,13 @@ const Transition = props => {
const currentTransaction = ensureTransaction(transaction);
const customer = currentTransaction.customer;
const provider = currentTransaction.provider;
const listingTitle = currentTransaction.listing.attributes.title;
const deletedListing = intl.formatMessage({
id: 'ActivityFeed.deletedListing',
});
const listingTitle = currentTransaction.listing.attributes.deleted
? deletedListing
: currentTransaction.listing.attributes.title;
const lastTransition = currentTransaction.attributes.lastTransition;
const ownRole =
@ -220,10 +226,13 @@ const Transition = props => {
? propTypes.TX_TRANSITION_ACTOR_CUSTOMER
: propTypes.TX_TRANSITION_ACTOR_PROVIDER;
const bannedUserDisplayName = intl.formatMessage({
id: 'ActivityFeed.bannedUserDisplayName',
});
const otherUsersName =
ownRole === propTypes.TX_TRANSITION_ACTOR_CUSTOMER
? provider.attributes.profile.displayName
: customer.attributes.profile.displayName;
? userDisplayName(provider, bannedUserDisplayName)
: userDisplayName(customer, bannedUserDisplayName);
const transitionMessage = resolveTransitionMessage(
transition,

View file

@ -1,288 +0,0 @@
@import '../../marketplace.css';
.root {
position: relative;
}
.error {
color: var(--failColor);
margin: 13px 0 22px 0;
@media (--viewportMedium) {
margin: 13px 0 23px 0;
}
@media (--viewportLarge) {
margin: 12px 0 23px 0;
}
}
.container {
display: flex;
flex-direction: column;
@media (--viewportLarge) {
flex-direction: row;
justify-content: center;
max-width: 1156px; /* 1060 + (paddingLeft + paddingRight) */
margin: 0 auto 57px auto;
padding: 0 48px;
}
}
.imageWrapperMobile {
/* Layout */
display: block;
width: 100%;
position: relative;
@media (--viewportLarge) {
display: none;
}
}
/* Firefox doesn't support image aspect ratio inside flexbox */
.aspectWrapper {
padding-bottom: 66.6667%; /* 3:2 Aspect Ratio */
background-color: var(--matterColorNegative); /* Loading BG color */
}
.rootForImage {
/* Layout - image will take space defined by aspect ratio wrapper */
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: 100%;
@media (--viewportLarge) {
border-radius: 2px 2px 0 0;
}
}
.avatarWrapper {
/* Position (over the listing image)*/
margin-left: 24px;
margin-top: -31px;
/* Rendering context to the same lavel as listing image */
position: relative;
/* Layout */
display: block;
@media (--viewportLarge) {
margin-left: 48px;
}
}
.avatarMobile {
@media (--viewportLarge) {
display: none;
}
}
.orderInfo {
margin-bottom: 210px;
@media (--viewportLarge) {
max-width: 538px;
margin-right: 108px;
margin-bottom: 0;
}
}
.heading {
margin: 29px 24px 0 24px;
@media (--viewportMedium) {
margin: 25px 24px 0 24px;
max-width: 80%;
}
@media (--viewportLarge) {
max-width: 100%;
margin: 177px 0 0 0;
}
}
.mainTitle {
display: block;
}
.orderInfoText {
margin: 18px 24px 0 24px;
@media (--viewportMedium) {
margin: 23px 24px 0 24px;
}
@media (--viewportLarge) {
margin: 23px 0 0 0;
}
}
.infoTextDivider {
display: none;
height: 1px;
background-color: var(--matterColorNegative);
border: none;
margin: 37px 0 0 0;
@media (--viewportLarge) {
display: block;
}
}
.transitionDate {
white-space: nowrap;
}
.breakdownMobile {
margin-top: 47px;
@media (--viewportMedium) {
margin-top: 43px;
}
@media (--viewportLarge) {
display: none;
}
}
.breakdownDesktop {
display: none;
position: sticky;
top: 24px;
width: 409px;
padding-bottom: 55px;
background-color: var(--matterColorLight);
border: 1px solid var(--matterColorNegative);
border-radius: 2px;
@media (--viewportLarge) {
display: block;
}
}
.breakdownImageWrapper {
/* Layout */
display: block;
width: 100%;
position: relative;
}
.bookingBreakdownContainer {
margin: 1px 0 0 0;
@media (--viewportLarge) {
margin-top: 123px;
margin-left: 0;
margin-right: 0;
}
}
.breakdownHeadings {
display: none;
@media (--viewportLarge) {
display: block;
margin-left: 48px;
margin-right: 48px;
}
}
.breakdownTitle {
margin-bottom: 10px;
@media (--viewportLarge) {
margin-top: 14px;
margin-bottom: 0;
}
}
.breakdownSubtitle {
@apply --marketplaceH5FontStyles;
color: var(--matterColorAnti);
margin-top: 0;
margin-bottom: 0;
@media (--viewportLarge) {
margin-top: 1px;
margin-bottom: 0;
}
}
.bookingBreakdownTitle {
/* Font */
color: var(--matterColorAnti);
margin: 0 24px 0 24px;
@media (--viewportLarge) {
margin: 37px 48px 26px 48px;
}
}
.breakdown {
margin: 14px 24px 0 24px;
@media (--viewportMedium) {
margin: 18px 24px 0 24px;
}
@media (--viewportLarge) {
margin: 14px 48px 0 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;
}
}
.feedHeading {
color: var(--matterColorAnti);
margin: 0;
@media (--viewportMedium) {
margin: 0;
}
}
.feed {
margin-top: 20px;
}
.sendMessageForm {
position: fixed;
bottom: 0;
width: 100%;
box-shadow: var(--boxShadowBottomForm);
@media (--viewportLarge) {
margin-top: 46px;
position: relative;
box-shadow: none;
}
}
.sendMessageFormFocusedInMobileSafari {
position: absolute;
@media (--viewportLarge) {
position: relative;
}
}

View file

@ -1,439 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import * as propTypes from '../../util/propTypes';
import { createSlug } from '../../util/urlHelpers';
import { ensureListing, ensureTransaction, ensureUser, userDisplayName } from '../../util/data';
import { isMobileSafari } from '../../util/userAgent';
import {
ActivityFeed,
AvatarMedium,
BookingBreakdown,
NamedLink,
ResponsiveImage,
ReviewModal,
} from '../../components';
import { SendMessageForm } from '../../containers';
import css from './OrderDetailsPanel.css';
const breakdown = transaction => {
const loaded = transaction && transaction.id && transaction.booking && transaction.booking.id;
return loaded ? (
<BookingBreakdown
className={css.breakdown}
userRole="customer"
transaction={transaction}
booking={transaction.booking}
/>
) : null;
};
const orderTitle = (transaction, listingLink, customerName) => {
if (propTypes.txIsPreauthorized(transaction)) {
return (
<span>
<span className={css.mainTitle}>
<FormattedMessage
id="OrderDetailsPanel.orderPreauthorizedTitle"
values={{ customerName }}
/>
</span>
<FormattedMessage
id="OrderDetailsPanel.orderPreauthorizedSubtitle"
values={{ listingLink }}
/>
</span>
);
} else if (propTypes.txIsAccepted(transaction)) {
return (
<span>
<span className={css.mainTitle}>
<FormattedMessage id="OrderDetailsPanel.orderAcceptedTitle" values={{ customerName }} />
</span>
<FormattedMessage id="OrderDetailsPanel.orderAcceptedSubtitle" values={{ listingLink }} />
</span>
);
} else if (propTypes.txIsDeclined(transaction)) {
return (
<FormattedMessage
id="OrderDetailsPanel.orderDeclinedTitle"
values={{ customerName, listingLink }}
/>
);
} else if (propTypes.txIsAutodeclined(transaction)) {
return (
<FormattedMessage
id="OrderDetailsPanel.orderDeclinedTitle"
values={{ customerName, listingLink }}
/>
);
} else if (propTypes.txIsCanceled(transaction)) {
return (
<FormattedMessage
id="OrderDetailsPanel.orderCancelledTitle"
values={{ customerName, listingLink }}
/>
);
} else if (
propTypes.txIsDelivered(transaction) ||
propTypes.txHasFirstReview(transaction) ||
propTypes.txIsReviewed(transaction)
) {
return (
<FormattedMessage
id="OrderDetailsPanel.orderDeliveredTitle"
values={{ customerName, listingLink }}
/>
);
} else {
return null;
}
};
const orderMessage = (transaction, providerName) => {
if (propTypes.txIsPreauthorized(transaction)) {
return (
<FormattedMessage id="OrderDetailsPanel.orderPreauthorizedInfo" values={{ providerName }} />
);
}
return null;
};
export class OrderDetailsPanelComponent extends Component {
constructor(props) {
super(props);
this.state = {
sendMessageFormFocused: false,
isReviewModalOpen: false,
reviewSubmitted: false,
};
this.onOpenReviewModal = this.onOpenReviewModal.bind(this);
this.onSubmitReview = this.onSubmitReview.bind(this);
}
onOpenReviewModal() {
this.setState({ isReviewModalOpen: true });
}
onSubmitReview(values) {
const { onSendReview, transaction } = this.props;
const currentTransaction = ensureTransaction(transaction);
const { reviewRating, reviewContent } = values;
const rating = Number.parseInt(reviewRating, 10);
onSendReview(currentTransaction, rating, reviewContent)
.then(r => this.setState({ isReviewModalOpen: false, reviewSubmitted: true }))
.catch(e => {
// Do nothing.
});
}
render() {
const {
rootClassName,
className,
currentUser,
transaction,
totalMessagePages,
oldestMessagePageFetched,
messages,
initialMessageFailed,
fetchMessagesInProgress,
fetchMessagesError,
sendMessageInProgress,
sendMessageError,
sendReviewInProgress,
sendReviewError,
onManageDisableScrolling,
onShowMoreMessages,
onSendMessage,
onResetForm,
intl,
} = this.props;
const currentTransaction = ensureTransaction(transaction);
const currentListing = ensureListing(currentTransaction.listing);
const currentProvider = ensureUser(currentTransaction.provider);
const currentCustomer = ensureUser(currentTransaction.customer);
const listingLoaded = !!currentListing.id;
const listingDeleted = listingLoaded && currentListing.attributes.deleted;
const bannedUserDisplayName = intl.formatMessage({
id: 'OrderDetailsPanel.bannedUserDisplayName',
});
const deletedListingTitle = intl.formatMessage({
id: 'OrderDetailsPanel.deletedListingTitle',
});
const deletedListingOrderTitle = intl.formatMessage({
id: 'OrderDetailsPanel.deletedListingOrderTitle',
});
const orderMessageDeletedListing = intl.formatMessage({
id: 'OrderDetailsPanel.messageDeletedListing',
});
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;
}
let listingLink = null;
if (listingLoaded && currentListing.attributes.title) {
const title = currentListing.attributes.title;
const params = { id: currentListing.id.uuid, slug: createSlug(title) };
listingLink = (
<NamedLink name="ListingPage" params={params}>
{title}
</NamedLink>
);
} else {
listingLink = deletedListingOrderTitle;
}
const listingTitle = currentListing.attributes.deleted
? deletedListingTitle
: currentListing.attributes.title;
const bookingInfo = breakdown(currentTransaction);
const orderHeading = orderTitle(currentTransaction, listingLink, customerDisplayName);
const orderInfoText = listingDeleted
? orderMessageDeletedListing
: orderMessage(currentTransaction, authorDisplayName);
const showInfoMessage = !!orderInfoText;
const firstImage =
currentListing.images && currentListing.images.length > 0 ? currentListing.images[0] : null;
const feedContainerClasses = classNames(css.feedContainer, {
[css.feedContainerWithInfoAbove]: showInfoMessage,
});
const txTransitions = currentTransaction.attributes.transitions
? currentTransaction.attributes.transitions
: [];
const hasOlderMessages = totalMessagePages > oldestMessagePageFetched;
const handleShowOlderMessages = () => {
onShowMoreMessages(currentTransaction.id);
};
const showFeed =
messages.length > 0 || txTransitions.length > 0 || initialMessageFailed || fetchMessagesError;
const feedContainer = showFeed ? (
<div className={feedContainerClasses}>
<h3 className={css.feedHeading}>
<FormattedMessage id="OrderDetailsPanel.activityHeading" />
</h3>
{initialMessageFailed ? (
<p className={css.error}>
<FormattedMessage id="OrderDetailsPanel.initialMessageFailed" />
</p>
) : null}
{fetchMessagesError ? (
<p className={css.error}>
<FormattedMessage id="OrderDetailsPanel.messageLoadingFailed" />
</p>
) : null}
<ActivityFeed
className={css.feed}
messages={messages}
transaction={currentTransaction}
currentUser={currentUser}
hasOlderMessages={hasOlderMessages && !fetchMessagesInProgress}
onOpenReviewModal={this.onOpenReviewModal}
onShowOlderMessages={handleShowOlderMessages}
fetchMessagesInProgress={fetchMessagesInProgress}
/>
</div>
) : null;
const sendMessagePlaceholder = intl.formatMessage(
{ id: 'OrderDetailsPanel.sendMessagePlaceholder' },
{ name: otherUserDisplayName }
);
const sendMessageFormName = 'OrderDetailsPanel.SendMessageForm';
const scrollToMessage = messageId => {
const selector = `#msg-${messageId.uuid}`;
const el = document.querySelector(selector);
if (el) {
el.scrollIntoView({
block: 'start',
behavior: 'smooth',
});
}
};
const isMobSaf = isMobileSafari();
const handleSendMessageFormFocus = () => {
this.setState({ sendMessageFormFocused: true });
if (isMobSaf) {
// Scroll to bottom
window.scroll({ top: document.body.scrollHeight, left: 0, behavior: 'smooth' });
}
};
const handleSendMessageFormBlur = () => {
this.setState({ sendMessageFormFocused: false });
};
const handleMessageSubmit = values => {
const message = values.message ? values.message.trim() : null;
if (!message) {
return;
}
onSendMessage(currentTransaction.id, message)
.then(messageId => {
onResetForm(sendMessageFormName);
scrollToMessage(messageId);
})
.catch(e => {
// Ignore, Redux handles the error
});
};
const sendMessageFormClasses = classNames(css.sendMessageForm, {
[css.sendMessageFormFocusedInMobileSafari]: isMobSaf && this.state.sendMessageFormFocused,
});
const classes = classNames(rootClassName || css.root, className);
return (
<div className={classes}>
<div className={css.container}>
<div className={css.orderInfo}>
<div className={css.imageWrapperMobile}>
<div className={css.aspectWrapper}>
<ResponsiveImage
rootClassName={css.rootForImage}
alt={listingTitle}
image={firstImage}
nameSet={[
{ name: 'landscape-crop', size: '400w' },
{ name: 'landscape-crop2x', size: '800w' },
]}
sizes="100vw"
/>
</div>
</div>
<div className={classNames(css.avatarWrapper, css.avatarMobile)}>
<AvatarMedium user={currentProvider} />
</div>
<h1 className={css.heading}>{orderHeading}</h1>
{showInfoMessage ? <p className={css.orderInfoText}>{orderInfoText}</p> : null}
{showInfoMessage ? <hr className={css.infoTextDivider} /> : null}
<div className={css.breakdownMobile}>
<h3 className={css.bookingBreakdownTitle}>
<FormattedMessage id="OrderDetailsPanel.bookingBreakdownTitle" />
</h3>
{bookingInfo}
</div>
{feedContainer}
<SendMessageForm
form={sendMessageFormName}
rootClassName={sendMessageFormClasses}
messagePlaceholder={sendMessagePlaceholder}
inProgress={sendMessageInProgress}
sendMessageError={sendMessageError}
onFocus={handleSendMessageFormFocus}
onBlur={handleSendMessageFormBlur}
onSubmit={handleMessageSubmit}
/>
</div>
<div className={css.bookingBreakdownContainer}>
<div className={css.breakdownDesktop}>
<div className={css.breakdownImageWrapper}>
<div className={css.aspectWrapper}>
<ResponsiveImage
rootClassName={css.rootForImage}
alt={listingTitle}
image={firstImage}
nameSet={[
{ name: 'landscape-crop', size: '400w' },
{ name: 'landscape-crop2x', size: '800w' },
]}
sizes="100%"
/>
</div>
</div>
<div className={css.avatarWrapper}>
<AvatarMedium user={currentProvider} />
</div>
<div className={css.breakdownHeadings}>
<h2 className={css.breakdownTitle}>{listingTitle}</h2>
<p className={css.breakdownSubtitle}>
<FormattedMessage
id="OrderDetailsPanel.hostedBy"
values={{ name: authorDisplayName }}
/>
</p>
</div>
<h3 className={css.bookingBreakdownTitle}>
<FormattedMessage id="OrderDetailsPanel.bookingBreakdownTitle" />
</h3>
{bookingInfo}
</div>
</div>
</div>
<ReviewModal
id="ReviewOrderModal"
isOpen={this.state.isReviewModalOpen}
onCloseModal={() => this.setState({ isReviewModalOpen: false })}
onManageDisableScrolling={onManageDisableScrolling}
onSubmitReview={this.onSubmitReview}
revieweeName={authorDisplayName}
reviewSent={this.state.reviewSubmitted}
sendReviewInProgress={sendReviewInProgress}
sendReviewError={sendReviewError}
/>
</div>
);
}
}
OrderDetailsPanelComponent.defaultProps = {
rootClassName: null,
className: null,
currentUser: null,
fetchMessagesError: null,
sendMessageError: null,
sendReviewError: null,
};
const { string, arrayOf, bool, func, number } = PropTypes;
OrderDetailsPanelComponent.propTypes = {
rootClassName: string,
className: string,
currentUser: propTypes.currentUser,
transaction: propTypes.transaction.isRequired,
totalMessagePages: number.isRequired,
oldestMessagePageFetched: number.isRequired,
messages: arrayOf(propTypes.message).isRequired,
initialMessageFailed: bool.isRequired,
fetchMessagesInProgress: bool.isRequired,
fetchMessagesError: propTypes.error,
sendMessageInProgress: bool.isRequired,
sendMessageError: propTypes.error,
sendReviewInProgress: bool.isRequired,
sendReviewError: propTypes.error,
onManageDisableScrolling: func.isRequired,
onShowMoreMessages: func.isRequired,
onSendMessage: func.isRequired,
onSendReview: func.isRequired,
onResetForm: func.isRequired,
// from injectIntl
intl: intlShape,
};
const OrderDetailsPanel = injectIntl(OrderDetailsPanelComponent);
export default OrderDetailsPanel;

View file

@ -1,146 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { types } from '../../util/sdkLoader';
import {
createBooking,
createListing,
createUser,
createTransaction,
createCurrentUser,
createMessage,
} from '../../util/test-data';
import { renderShallow } from '../../util/test-helpers';
import { fakeIntl } from '../../util/test-data';
import * as propTypes from '../../util/propTypes';
import { BookingBreakdown } from '../../components';
import { OrderDetailsPanelComponent } from './OrderDetailsPanel.js';
const { Money } = types;
const baseTxAttrs = {
total: new Money(16500, 'USD'),
booking: createBooking('booking1', {
start: new Date(Date.UTC(2017, 5, 10)),
end: new Date(Date.UTC(2017, 5, 13)),
}),
listing: createListing('listing1'),
provider: createUser('provider'),
customer: createUser('customer'),
};
const txPreauthorized = createTransaction({
id: 'order-preauthorized',
lastTransition: propTypes.TX_TRANSITION_PREAUTHORIZE,
...baseTxAttrs,
});
const txAccepted = createTransaction({
id: 'order-accepted',
lastTransition: propTypes.TX_TRANSITION_ACCEPT,
...baseTxAttrs,
});
const txDeclined = createTransaction({
id: 'order-declined',
lastTransition: propTypes.TX_TRANSITION_DECLINE,
...baseTxAttrs,
});
const txAutoDeclined = createTransaction({
id: 'order-autodeclined',
lastTransition: propTypes.TX_TRANSITION_AUTO_DECLINE,
...baseTxAttrs,
});
const txCanceled = createTransaction({
id: 'order-canceled',
lastTransition: propTypes.TX_TRANSITION_CANCELED,
...baseTxAttrs,
});
const txDelivered = createTransaction({
id: 'order-delivered',
lastTransition: propTypes.TX_TRANSITION_MARK_DELIVERED,
...baseTxAttrs,
});
const noop = () => null;
describe('OrderDetailsPanel', () => {
const panelBaseProps = {
intl: fakeIntl,
currentUser: createCurrentUser('user2'),
totalMessages: 2,
totalMessagePages: 1,
oldestMessagePageFetched: 1,
messages: [
createMessage('msg1', {}, { sender: createUser('user1') }),
createMessage('msg2', {}, { sender: createUser('user2') }),
],
initialMessageFailed: false,
fetchMessagesInProgress: false,
sendMessageInProgress: false,
sendReviewInProgress: false,
onManageDisableScrolling: noop,
onOpenReviewModal: noop,
onShowMoreMessages: noop,
onSendMessage: noop,
onSendReview: noop,
onResetForm: noop,
};
it('preauthorized matches snapshot', () => {
const tree = renderShallow(
<OrderDetailsPanelComponent transaction={txPreauthorized} {...panelBaseProps} />
);
expect(tree).toMatchSnapshot();
});
it('accepted matches snapshot', () => {
const tree = renderShallow(
<OrderDetailsPanelComponent transaction={txAccepted} {...panelBaseProps} />
);
expect(tree).toMatchSnapshot();
});
it('declined matches snapshot', () => {
const tree = renderShallow(
<OrderDetailsPanelComponent transaction={txDeclined} {...panelBaseProps} />
);
expect(tree).toMatchSnapshot();
});
it('autodeclined matches snapshot', () => {
const tree = renderShallow(
<OrderDetailsPanelComponent transaction={txAutoDeclined} {...panelBaseProps} />
);
expect(tree).toMatchSnapshot();
});
it('canceled matches snapshot', () => {
const tree = renderShallow(
<OrderDetailsPanelComponent transaction={txCanceled} {...panelBaseProps} />
);
expect(tree).toMatchSnapshot();
});
it('delivered matches snapshot', () => {
const tree = renderShallow(
<OrderDetailsPanelComponent transaction={txDelivered} {...panelBaseProps} />
);
expect(tree).toMatchSnapshot();
});
it('renders correct total price', () => {
const { Money } = types;
const tx = createTransaction({
id: 'order-tx',
lastTransition: propTypes.TX_TRANSITION_PREAUTHORIZE,
total: new Money(16500, 'USD'),
booking: createBooking('booking1', {
start: new Date(Date.UTC(2017, 5, 10)),
end: new Date(Date.UTC(2017, 5, 13)),
}),
listing: createListing('listing1'),
provider: createUser('provider'),
customer: createUser('customer'),
});
const panel = shallow(<OrderDetailsPanelComponent transaction={tx} {...panelBaseProps} />);
const breakdownProps = panel.find(BookingBreakdown).props();
expect(breakdownProps.transaction.attributes.payinTotal).toEqual(new Money(16500, 'USD'));
});
});

View file

@ -1,459 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import * as propTypes from '../../util/propTypes';
import { createSlug } from '../../util/urlHelpers';
import { ensureListing, ensureTransaction, ensureUser, userDisplayName } from '../../util/data';
import { isMobileSafari } from '../../util/userAgent';
import {
ActivityFeed,
AvatarLarge,
AvatarMedium,
BookingBreakdown,
NamedLink,
PrimaryButton,
ResponsiveImage,
ReviewModal,
SecondaryButton,
} from '../../components';
import { SendMessageForm } from '../../containers';
import css from './SaleDetailsPanel.css';
const breakdown = transaction => {
const loaded = transaction && transaction.id && transaction.booking && transaction.booking.id;
return loaded ? (
<BookingBreakdown
className={css.breakdown}
userRole="provider"
transaction={transaction}
booking={transaction.booking}
/>
) : null;
};
const saleTitle = (transaction, listingLink, customerName) => {
if (propTypes.txIsPreauthorized(transaction)) {
return (
<FormattedMessage
id="SaleDetailsPanel.saleRequestedTitle"
values={{ customerName, listingLink }}
/>
);
} else if (propTypes.txIsAccepted(transaction)) {
return (
<FormattedMessage
id="SaleDetailsPanel.saleAcceptedTitle"
values={{ customerName, listingLink }}
/>
);
} else if (propTypes.txIsDeclined(transaction)) {
return (
<FormattedMessage
id="SaleDetailsPanel.saleDeclinedTitle"
values={{ customerName, listingLink }}
/>
);
} else if (propTypes.txIsAutodeclined(transaction)) {
return (
<FormattedMessage
id="SaleDetailsPanel.saleDeclinedTitle"
values={{ customerName, listingLink }}
/>
);
} else if (propTypes.txIsCanceled(transaction)) {
return (
<FormattedMessage
id="SaleDetailsPanel.saleCancelledTitle"
values={{ customerName, listingLink }}
/>
);
} else if (
propTypes.txIsDelivered(transaction) ||
propTypes.txHasFirstReview(transaction) ||
propTypes.txIsReviewed(transaction)
) {
return (
<FormattedMessage
id="SaleDetailsPanel.saleDeliveredTitle"
values={{ customerName, listingLink }}
/>
);
} else {
return null;
}
};
const saleInfoText = (transaction, customerName) => {
if (propTypes.txIsPreauthorized(transaction)) {
return <FormattedMessage id="SaleDetailsPanel.saleRequestedInfo" values={{ customerName }} />;
}
return null;
};
export class SaleDetailsPanelComponent extends Component {
constructor(props) {
super(props);
this.state = {
sendMessageFormFocused: false,
isReviewModalOpen: false,
reviewSubmitted: false,
};
this.onOpenReviewModal = this.onOpenReviewModal.bind(this);
this.onSubmitReview = this.onSubmitReview.bind(this);
}
onOpenReviewModal() {
this.setState({ isReviewModalOpen: true });
}
onSubmitReview(values) {
const { onSendReview, transaction } = this.props;
const currentTransaction = ensureTransaction(transaction);
const { reviewRating, reviewContent } = values;
const rating = Number.parseInt(reviewRating, 10);
onSendReview(currentTransaction, rating, reviewContent)
.then(r => this.setState({ isReviewModalOpen: false, reviewSubmitted: true }))
.catch(e => {
// Do nothing.
});
}
render() {
const {
rootClassName,
className,
currentUser,
transaction,
onAcceptSale,
onDeclineSale,
acceptInProgress,
declineInProgress,
acceptSaleError,
declineSaleError,
fetchMessagesInProgress,
fetchMessagesError,
totalMessagePages,
oldestMessagePageFetched,
messages,
sendMessageInProgress,
sendMessageError,
sendReviewInProgress,
sendReviewError,
onManageDisableScrolling,
onShowMoreMessages,
onSendMessage,
onResetForm,
intl,
} = this.props;
const currentTransaction = ensureTransaction(transaction);
const currentListing = ensureListing(currentTransaction.listing);
const currentCustomer = ensureUser(currentTransaction.customer);
const currentProvider = ensureUser(currentTransaction.provider);
const customerLoaded = !!currentCustomer.id;
const isCustomerBanned = customerLoaded && currentCustomer.attributes.banned;
const bannedUserDisplayName = intl.formatMessage({
id: 'SaleDetailsPanel.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;
}
let listingLink = null;
if (currentListing.id && currentListing.attributes.title) {
const title = currentListing.attributes.title;
const params = { id: currentListing.id.uuid, slug: createSlug(title) };
listingLink = (
<NamedLink name="ListingPage" params={params}>
{title}
</NamedLink>
);
}
const bookingInfo = breakdown(currentTransaction);
const title = saleTitle(currentTransaction, listingLink, customerDisplayName);
const infoText = isCustomerBanned
? intl.formatMessage({
id: 'SaleDetailsPanel.customerBannedStatus',
})
: saleInfoText(currentTransaction, customerDisplayName);
const showInfoText = !!infoText;
const listingTitle = currentListing.attributes.title;
const firstImage =
currentListing.images && currentListing.images.length > 0 ? currentListing.images[0] : null;
const feedContainerClasses = classNames(css.feedContainer, {
[css.feedContainerWithInfoAbove]: showInfoText,
});
const txTransitions = currentTransaction.attributes.transitions
? currentTransaction.attributes.transitions
: [];
const hasOlderMessages = totalMessagePages > oldestMessagePageFetched;
const handleShowOlderMessages = () => {
onShowMoreMessages(currentTransaction.id);
};
const showFeed = messages.length > 0 || txTransitions.length > 0 || fetchMessagesError;
const feedContainer = showFeed ? (
<div className={feedContainerClasses}>
<h3 className={css.feedHeading}>
<FormattedMessage id="SaleDetailsPanel.activityHeading" />
</h3>
{fetchMessagesError ? (
<p className={css.messagesError}>
<FormattedMessage id="SaleDetailsPanel.messageLoadingFailed" />
</p>
) : null}
<ActivityFeed
className={css.feed}
messages={messages}
transaction={currentTransaction}
currentUser={currentUser}
hasOlderMessages={hasOlderMessages && !fetchMessagesInProgress}
onOpenReviewModal={this.onOpenReviewModal}
onShowOlderMessages={handleShowOlderMessages}
fetchMessagesInProgress={fetchMessagesInProgress}
/>
</div>
) : null;
const canShowButtons = propTypes.txIsPreauthorized(currentTransaction) && !isCustomerBanned;
const buttonsDisabled = acceptInProgress || declineInProgress;
const acceptErrorMessage = acceptSaleError ? (
<p className={css.actionError}>
<FormattedMessage id="SaleDetailsPanel.acceptSaleFailed" />
</p>
) : null;
const declineErrorMessage = declineSaleError ? (
<p className={css.actionError}>
<FormattedMessage id="SaleDetailsPanel.declineSaleFailed" />
</p>
) : null;
const actionButtonClasses = classNames(css.actionButtons, {
[css.actionButtonsWithFormFocused]: this.state.sendMessageFormFocused,
});
const actionButtons = canShowButtons ? (
<div className={actionButtonClasses}>
<div className={css.actionErrors}>
{acceptErrorMessage}
{declineErrorMessage}
</div>
<div className={css.actionButtonWrapper}>
<SecondaryButton
inProgress={declineInProgress}
disabled={buttonsDisabled}
onClick={() => onDeclineSale(currentTransaction.id)}
>
<FormattedMessage id="SaleDetailsPanel.declineButton" />
</SecondaryButton>
<PrimaryButton
inProgress={acceptInProgress}
disabled={buttonsDisabled}
onClick={() => onAcceptSale(currentTransaction.id)}
>
<FormattedMessage id="SaleDetailsPanel.acceptButton" />
</PrimaryButton>
</div>
</div>
) : null;
const sendMessagePlaceholder = intl.formatMessage(
{ id: 'SaleDetailsPanel.sendMessagePlaceholder' },
{ name: otherUserDisplayName }
);
const sendMessageFormName = 'SaleDetailsPanel.SendMessageForm';
const scrollToMessage = messageId => {
const selector = `#msg-${messageId.uuid}`;
const el = document.querySelector(selector);
if (el) {
el.scrollIntoView({
block: 'start',
behavior: 'smooth',
});
}
};
const isMobSaf = isMobileSafari();
const handleSendMessageFormFocus = () => {
this.setState({ sendMessageFormFocused: true });
if (isMobSaf) {
// Scroll to bottom
window.scroll({ top: document.body.scrollHeight, left: 0, behavior: 'smooth' });
}
};
const handleSendMessageFormBlur = () => {
this.setState({ sendMessageFormFocused: false });
};
const handleMessageSubmit = values => {
const message = values.message ? values.message.trim() : null;
if (!message) {
return;
}
onSendMessage(currentTransaction.id, message)
.then(messageId => {
onResetForm(sendMessageFormName);
scrollToMessage(messageId);
})
.catch(e => {
// Ignore, Redux handles the error
});
};
const sendMessageFormClasses = classNames(css.sendMessageForm, {
[css.sendMessageFormFixed]: this.state.sendMessageFormFocused || !canShowButtons,
[css.sendMessageFormFocusedInMobileSafari]: isMobSaf && this.state.sendMessageFormFocused,
});
const classes = classNames(rootClassName || css.root, className);
return (
<div className={classes}>
<div className={css.container}>
<div className={css.saleInfo}>
<div className={css.imageWrapperMobile}>
<div className={css.aspectWrapper}>
<ResponsiveImage
rootClassName={css.rootForImage}
alt={listingTitle}
image={firstImage}
nameSet={[
{ name: 'landscape-crop', size: '400w' },
{ name: 'landscape-crop2x', size: '800w' },
]}
sizes="100vw"
/>
</div>
</div>
<div className={css.avatarWrapper}>
<AvatarMedium user={currentCustomer} className={css.avatarMobile} />
</div>
<div className={css.avatarWrapper}>
<AvatarLarge user={currentCustomer} className={css.avatarDesktop} />
</div>
<h1 className={css.title}>{title}</h1>
{showInfoText ? <p className={css.infoText}>{infoText}</p> : null}
<div className={css.breakdownContainerMobile}>
<h3 className={css.breakdownTitleMobile}>
<FormattedMessage id="SaleDetailsPanel.bookingBreakdownTitle" />
</h3>
{bookingInfo}
</div>
{feedContainer}
<SendMessageForm
form={sendMessageFormName}
rootClassName={sendMessageFormClasses}
messagePlaceholder={sendMessagePlaceholder}
inProgress={sendMessageInProgress}
sendMessageError={sendMessageError}
onFocus={handleSendMessageFormFocus}
onBlur={handleSendMessageFormBlur}
onSubmit={handleMessageSubmit}
/>
<div className={css.mobileActionButtons}>{actionButtons}</div>
</div>
<div className={css.desktopAside}>
<div className={css.breakdownContainerDesktop}>
<div className={css.breakdownImageWrapper}>
<div className={css.aspectWrapper}>
<ResponsiveImage
rootClassName={css.rootForImage}
alt={listingTitle}
image={firstImage}
nameSet={[
{ name: 'landscape-crop', size: '400w' },
{ name: 'landscape-crop2x', size: '800w' },
]}
sizes="100%"
/>
</div>
</div>
<h3 className={css.breakdownTitleDesktop}>
<FormattedMessage id="SaleDetailsPanel.bookingBreakdownTitle" />
</h3>
<div className={css.breakdownDesktop}>{bookingInfo}</div>
<div className={css.desktopActionButtons}>{actionButtons}</div>
</div>
</div>
</div>
<ReviewModal
id="ReviewSaleModal"
isOpen={this.state.isReviewModalOpen}
onCloseModal={() => this.setState({ isReviewModalOpen: false })}
onManageDisableScrolling={onManageDisableScrolling}
onSubmitReview={this.onSubmitReview}
revieweeName={customerDisplayName}
reviewSent={this.state.reviewSubmitted}
sendReviewInProgress={sendReviewInProgress}
sendReviewError={sendReviewError}
/>
</div>
);
}
}
SaleDetailsPanelComponent.defaultProps = {
rootClassName: null,
className: null,
currentUser: null,
acceptSaleError: null,
declineSaleError: null,
fetchMessagesError: null,
sendMessageError: null,
sendReviewError: null,
};
const { bool, func, string, arrayOf, number } = PropTypes;
SaleDetailsPanelComponent.propTypes = {
rootClassName: string,
className: string,
currentUser: propTypes.currentUser,
transaction: propTypes.transaction.isRequired,
onAcceptSale: func.isRequired,
onDeclineSale: func.isRequired,
acceptInProgress: bool.isRequired,
declineInProgress: bool.isRequired,
acceptSaleError: propTypes.error,
declineSaleError: propTypes.error,
fetchMessagesInProgress: bool.isRequired,
fetchMessagesError: propTypes.error,
totalMessagePages: number.isRequired,
oldestMessagePageFetched: number.isRequired,
messages: arrayOf(propTypes.message).isRequired,
sendMessageInProgress: bool.isRequired,
sendMessageError: propTypes.error,
sendReviewInProgress: bool.isRequired,
sendReviewError: propTypes.error,
onManageDisableScrolling: func.isRequired,
onShowMoreMessages: func.isRequired,
onSendMessage: func.isRequired,
onSendReview: func.isRequired,
onResetForm: func.isRequired,
// from injectIntl
intl: intlShape.isRequired,
};
const SaleDetailsPanel = injectIntl(SaleDetailsPanelComponent);
export default SaleDetailsPanel;

View file

@ -1,167 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { types } from '../../util/sdkLoader';
import {
createTransaction,
createBooking,
createListing,
createUser,
createCurrentUser,
createMessage,
} from '../../util/test-data';
import { renderShallow } from '../../util/test-helpers';
import { fakeIntl } from '../../util/test-data';
import * as propTypes from '../../util/propTypes';
import { BookingBreakdown } from '../../components';
import { SaleDetailsPanelComponent } from './SaleDetailsPanel.js';
const noop = () => null;
const { Money } = types;
const baseTxAttrs = {
total: new Money(16500, 'USD'),
commission: new Money(1000, 'USD'),
booking: createBooking('booking1', {
start: new Date(Date.UTC(2017, 5, 10)),
end: new Date(Date.UTC(2017, 5, 13)),
}),
listing: createListing('listing1'),
customer: createUser('customer1'),
lastTransitionedAt: new Date(Date.UTC(2017, 5, 10)),
};
const txPreauthorized = createTransaction({
id: 'sale-preauthorized',
lastTransition: propTypes.TX_TRANSITION_PREAUTHORIZE,
...baseTxAttrs,
});
const txAccepted = createTransaction({
id: 'sale-accepted',
lastTransition: propTypes.TX_TRANSITION_ACCEPT,
...baseTxAttrs,
});
const txDeclined = createTransaction({
id: 'sale-declined',
lastTransition: propTypes.TX_TRANSITION_DECLINE,
...baseTxAttrs,
});
const txAutoDeclined = createTransaction({
id: 'sale-autodeclined',
lastTransition: propTypes.TX_TRANSITION_AUTO_DECLINE,
...baseTxAttrs,
});
const txCanceled = createTransaction({
id: 'sale-canceled',
lastTransition: propTypes.TX_TRANSITION_CANCELED,
...baseTxAttrs,
});
const txDelivered = createTransaction({
id: 'sale-delivered',
lastTransition: propTypes.TX_TRANSITION_MARK_DELIVERED,
...baseTxAttrs,
});
describe('SaleDetailsPanel', () => {
const panelBaseProps = {
onAcceptSale: noop,
onDeclineSale: noop,
acceptInProgress: false,
declineInProgress: false,
currentUser: createCurrentUser('user1'),
totalMessages: 2,
totalMessagePages: 1,
oldestMessagePageFetched: 1,
messages: [
createMessage('msg1', {}, { sender: createUser('user1') }),
createMessage('msg2', {}, { sender: createUser('user2') }),
],
fetchMessagesInProgress: false,
sendMessageInProgress: false,
sendReviewInProgress: false,
onManageDisableScrolling: noop,
onOpenReviewModal: noop,
onShowMoreMessages: noop,
onSendMessage: noop,
onSendReview: noop,
onResetForm: noop,
intl: fakeIntl,
};
it('preauthorized matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txPreauthorized,
};
const tree = renderShallow(<SaleDetailsPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('accepted matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txAccepted,
};
const tree = renderShallow(<SaleDetailsPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('declined matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txDeclined,
};
const tree = renderShallow(<SaleDetailsPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('autodeclined matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txAutoDeclined,
};
const tree = renderShallow(<SaleDetailsPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('canceled matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txCanceled,
};
const tree = renderShallow(<SaleDetailsPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('delivered matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txDelivered,
};
const tree = renderShallow(<SaleDetailsPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('renders correct total price', () => {
const transaction = createTransaction({
id: 'sale-tx',
lastTransition: propTypes.TX_TRANSITION_PREAUTHORIZE,
total: new Money(16500, 'USD'),
commission: new Money(1000, 'USD'),
booking: createBooking('booking1', {
start: new Date(Date.UTC(2017, 5, 10)),
end: new Date(Date.UTC(2017, 5, 13)),
}),
listing: createListing('listing1'),
customer: createUser('customer1'),
lastTransitionedAt: new Date(Date.UTC(2017, 5, 10)),
});
const props = {
...panelBaseProps,
transaction,
};
const panel = shallow(<SaleDetailsPanelComponent {...props} />);
const breakdownProps = panel.find(BookingBreakdown).props();
// Total price for the provider should be transaction total minus the commission.
expect(breakdownProps.transaction.attributes.payoutTotal).toEqual(new Money(15500, 'USD'));
});
});

View file

@ -17,19 +17,18 @@
}
}
.saleInfo {
margin-bottom: 104px;
.txInfo {
margin-bottom: 210px;
@media (--viewportLarge) {
max-width: 542px;
flex-basis: 538px;
flex-grow: 0;
flex-shrink: 1;
margin-right: 56px;
margin-bottom: 0;
}
}
.nowrap {
white-space: nowrap;
}
.imageWrapperMobile {
/* Layout */
display: block;
@ -41,25 +40,12 @@
}
}
/* Firefox doesn't support image aspect ratio inside flexbox */
.aspectWrapper {
padding-bottom: 66.6667%; /* 3:2 Aspect Ratio */
background-color: var(--matterColorNegative); /* Loading BG color */
}
.aspectWrapperMobile {
@media (--viewportLarge) {
display: none;
}
}
.aspectWrapperDesktop {
display: none;
@media (--viewportLarge) {
display: block;
}
}
.rootForImage {
/* Layout - image will take space defined by aspect ratio wrapper */
position: absolute;
@ -68,68 +54,33 @@
left: 0;
right: 0;
width: 100%;
}
.title {
margin: 22px 24px 0 24px;
@media (--viewportMedium) {
max-width: 80%;
}
@media (--viewportLarge) {
max-width: 100%;
margin: 42px 0 0 0;
border-radius: 2px 2px 0 0;
}
}
.infoText {
margin: 18px 24px 0 24px;
.avatarWrapperMobile {
/* Position (over the listing image)*/
margin-left: 24px;
margin-top: -31px;
@media (--viewportMedium) {
margin: 23px 24px 0 24px;
}
@media (--viewportLarge) {
margin: 22px 0 -2px 0;
}
/* Rendering context to the same lavel as listing image */
position: relative;
/* Layout */
display: block;
}
.messagesError {
color: var(--failColor);
margin: 13px 0 0 0;
}
.actionError {
@apply --marketplaceH5FontStyles;
color: var(--failColor);
margin: 0 0 11px 0;
@media (--viewportMedium) {
margin: 0 0 10px 0;
}
@media (--viewportLarge) {
margin: 0;
}
}
.actionErrors {
width: 100%;
text-align: center;
.avatarWrapperCustomerDesktop {
composes: avatarWrapperMobile;
@media (--viewportLarge) {
position: absolute;
top: 151px;
margin-left: 48px;
}
}
.avatarMobile {
/* Position (over the listing image)*/
margin-top: -31px;
margin-left: 24px;
/* Bring on top of image */
position: relative;
@media (--viewportLarge) {
display: none;
}
@ -144,22 +95,57 @@
}
}
.breakdown {
margin: 14px 0 0 0;
.headingOrder {
margin: 29px 24px 0 24px;
@media (--viewportMedium) {
margin: 18px 0 0 0;
margin: 25px 24px 0 24px;
max-width: 80%;
}
@media (--viewportLarge) {
margin: 14px 0 0 0;
max-width: 100%;
margin: 177px 0 0 0;
}
}
.breakdownContainerMobile {
margin: 47px 24px 0 24px;
.headingSale {
margin: 22px 24px 0 24px;
@media (--viewportMedium) {
margin-top: 44px;
max-width: 80%;
}
@media (--viewportLarge) {
max-width: 100%;
margin: 42px 0 0 0;
}
}
.mainTitle {
display: block;
}
.transactionInfoMessage {
margin: 18px 24px 0 24px;
@media (--viewportMedium) {
margin: 23px 24px 0 24px;
}
@media (--viewportLarge) {
margin: 23px 0 0 0;
}
}
.transitionDate {
white-space: nowrap;
}
.breakdownMobile {
margin-top: 47px;
@media (--viewportMedium) {
margin-top: 43px;
}
@media (--viewportLarge) {
display: none;
@ -167,21 +153,28 @@
}
.desktopAside {
display: none;
margin: 1px 0 0 0;
@media (--viewportLarge) {
display: block;
margin: 120px 0 0 0;
margin-top: 123px;
margin-left: 0;
margin-right: 0;
}
}
.breakdownContainerDesktop {
.breakdownDesktop {
display: none;
position: sticky;
top: -272px; /* This is a hack to showcase how the component would look when the image isn't sticky */
width: 409px;
background-color: var(--matterColorLight);
border: 1px solid var(--matterColorNegative);
border-radius: 2px;
width: 409px;
@media (--viewportLarge) {
display: block;
}
}
.breakdownImageWrapper {
@ -191,27 +184,62 @@
position: relative;
}
.breakdownTitleMobile {
/* Font */
color: var(--matterColorAnti);
margin: 0;
.breakdownHeadings {
display: none;
@media (--viewportMedium) {
margin: 0;
@media (--viewportLarge) {
display: block;
margin-left: 48px;
margin-right: 48px;
}
}
.breakdownTitleDesktop {
margin: 44px 48px 18px 48px;
color: var(--matterColorAnti);
.breakdownTitle {
margin-bottom: 10px;
@media (--viewportLarge) {
margin-top: 14px;
margin-bottom: 0;
}
}
.breakdownDesktop {
margin: 0 48px 47px 48px;
.breakdownSubtitle {
@apply --marketplaceH5FontStyles;
color: var(--matterColorAnti);
margin-top: 0;
margin-bottom: 0;
@media (--viewportLarge) {
margin-top: 1px;
margin-bottom: 0;
}
}
.bookingBreakdownTitle {
/* Font */
color: var(--matterColorAnti);
margin: 0 24px 0 24px;
@media (--viewportLarge) {
margin: 37px 48px 26px 48px;
}
}
.breakdown {
margin: 14px 24px 0 24px;
@media (--viewportMedium) {
margin: 18px 24px 0 24px;
}
@media (--viewportLarge) {
margin: 14px 48px 47px 48px;
}
}
.feedContainer {
margin: 46px 24px 36px 24px;
margin: 46px 24px 0 24px;
@media (--viewportMedium) {
margin: 46px 24px 0 24px;
@ -292,7 +320,7 @@
position: static;
box-shadow: none;
width: auto;
margin: 0 48px 48px 48px;
margin: 0 48px 0 48px;
padding: 0;
}
}
@ -310,6 +338,7 @@
@media (--viewportLarge) {
display: block;
margin-bottom: 48px;
}
}
@ -341,3 +370,39 @@
}
}
}
.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);
margin: 0 0 11px 0;
@media (--viewportMedium) {
margin: 0 0 10px 0;
}
@media (--viewportLarge) {
margin: 0;
}
}
.actionErrors {
width: 100%;
text-align: center;
@media (--viewportLarge) {
position: absolute;
top: 151px;
}
}

View file

@ -0,0 +1,426 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import * as propTypes from '../../util/propTypes';
import { userDisplayName } from '../../util/data';
import { createSlug } from '../../util/urlHelpers';
import {
ActivityFeed,
BookingBreakdown,
NamedLink,
PrimaryButton,
SecondaryButton,
} from '../../components';
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 BookingBreakdown
export const BreakdownMaybe = props => {
const { className, rootClassName, transaction, transactionRole } = props;
const loaded = transaction && transaction.id && transaction.booking && transaction.booking.id;
const classes = classNames(rootClassName || css.breakdown, className);
return loaded ? (
<div>
<h3 className={css.bookingBreakdownTitle}>
<FormattedMessage id="TransactionPanel.bookingBreakdownTitle" />
</h3>
<BookingBreakdown
className={classes}
userRole={transactionRole}
transaction={transaction}
booking={transaction.booking}
/>
</div>
) : null;
};
// Functional component as a helper to build ActionButtons
// Provider only when state is preauthorized
export const ActionButtonsMaybe = 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;
};
const createListingLink = (listingLoaded, title, id) => {
if (listingLoaded && title) {
const params = { id: id.uuid, slug: createSlug(title) };
return (
<NamedLink name="ListingPage" params={params}>
{title}
</NamedLink>
);
} else {
return <FormattedMessage id="TransactionPanel.deletedListingOrderTitle" />;
}
};
// Functional component as a helper to build order title
export const OrderTitle = props => {
const {
className,
rootClassName,
transaction,
customerDisplayName: customerName,
currentListing,
listingTitle,
} = props;
const listingLoaded = !!currentListing.id;
const listingLink = createListingLink(listingLoaded, listingTitle, currentListing.id);
const classes = classNames(rootClassName || css.headingOrder, className);
if (propTypes.txIsPreauthorized(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 (propTypes.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 (propTypes.txIsDeclined(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.orderDeclinedTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (propTypes.txIsAutodeclined(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.orderDeclinedTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (propTypes.txIsCanceled(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.orderCancelledTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (
propTypes.txIsDelivered(transaction) ||
propTypes.txHasFirstReview(transaction) ||
propTypes.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 && propTypes.txIsPreauthorized(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 listingLoaded = !!currentListing.id;
const listingLink = createListingLink(listingLoaded, listingTitle, currentListing.id);
const classes = classNames(rootClassName || css.headingSale, className);
if (propTypes.txIsPreauthorized(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.saleRequestedTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (propTypes.txIsAccepted(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.saleAcceptedTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (propTypes.txIsDeclined(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.saleDeclinedTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (propTypes.txIsAutodeclined(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.saleDeclinedTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (propTypes.txIsCanceled(transaction)) {
return (
<h1 className={classes}>
<FormattedMessage
id="TransactionPanel.saleCancelledTitle"
values={{ customerName, listingLink }}
/>
</h1>
);
} else if (
propTypes.txIsDelivered(transaction) ||
propTypes.txHasFirstReview(transaction) ||
propTypes.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 && propTypes.txIsPreauthorized(transaction)) {
return (
<p className={classes}>
<FormattedMessage id="TransactionPanel.saleRequestedInfo" values={{ customerName }} />
</p>
);
} else if (isCustomerBanned) {
return (
<p className={classes}>
<FormattedMessage id="TransactionPanel.customerBannedStatus" />
</p>
);
}
return null;
};
// Functional component as a helper to choose and show Order or Sale title
export const TransactionPageTitle = props => {
return props.transactionRole === 'customer' ? (
<OrderTitle {...props} />
) : (
<SaleTitle {...props} />
);
};
// Functional component as a helper to choose and show Order or Sale message
export const TransactionPageMessage = props => {
return props.transactionRole === 'customer' ? (
<OrderMessage {...props} />
) : (
<SaleMessage {...props} />
);
};
// Helper function to get display names for different roles
export const displayNames = (
currentUser,
currentProvider,
currentCustomer,
bannedUserDisplayName
) => {
const authorDisplayName = userDisplayName(currentProvider, bannedUserDisplayName);
const customerDisplayName = userDisplayName(currentCustomer, bannedUserDisplayName);
let otherUserDisplayName = '';
const currentUserIsCustomer =
currentUser.id && currentCustomer.id && currentUser.id.uuid === currentCustomer.id.uuid;
const currentUserIsProvider =
currentUser.id && currentProvider.id && currentUser.id.uuid === currentProvider.id.uuid;
if (currentUserIsCustomer) {
otherUserDisplayName = authorDisplayName;
} else if (currentUserIsProvider) {
otherUserDisplayName = customerDisplayName;
}
return {
authorDisplayName,
customerDisplayName,
otherUserDisplayName,
};
};

View file

@ -0,0 +1,387 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import * as propTypes from '../../util/propTypes';
import { ensureListing, ensureTransaction, ensureUser } from '../../util/data';
import { isMobileSafari } from '../../util/userAgent';
import { AvatarMedium, AvatarLarge, ResponsiveImage, ReviewModal } from '../../components';
import { SendMessageForm } from '../../containers';
// These are internal components that make this file more readable.
import {
ActionButtonsMaybe,
BreakdownMaybe,
FeedSection,
TransactionPageTitle,
TransactionPageMessage,
displayNames,
} from './TransactionPanel.helpers';
import css from './TransactionPanel.css';
export class TransactionPanelComponent extends Component {
constructor(props) {
super(props);
this.state = {
sendMessageFormFocused: false,
isReviewModalOpen: false,
reviewSubmitted: false,
};
this.isMobSaf = false;
this.sendMessageFormName = 'TransactionPanel.SendMessageForm';
this.onOpenReviewModal = this.onOpenReviewModal.bind(this);
this.onSubmitReview = this.onSubmitReview.bind(this);
this.onSendMessageFormFocus = this.onSendMessageFormFocus.bind(this);
this.onSendMessageFormBlur = this.onSendMessageFormBlur.bind(this);
this.onMessageSubmit = this.onMessageSubmit.bind(this);
this.scrollToMessage = this.scrollToMessage.bind(this);
}
componentWillMount() {
this.isMobSaf = isMobileSafari();
}
onOpenReviewModal() {
this.setState({ isReviewModalOpen: true });
}
onSubmitReview(values) {
const { onSendReview, transaction } = this.props;
const currentTransaction = ensureTransaction(transaction);
const { reviewRating, reviewContent } = values;
const rating = Number.parseInt(reviewRating, 10);
onSendReview(currentTransaction, rating, reviewContent)
.then(r => this.setState({ isReviewModalOpen: false, reviewSubmitted: true }))
.catch(e => {
// Do nothing.
});
}
onSendMessageFormFocus() {
this.setState({ sendMessageFormFocused: true });
if (this.isMobSaf) {
// Scroll to bottom
window.scroll({ top: document.body.scrollHeight, left: 0, behavior: 'smooth' });
}
}
onSendMessageFormBlur() {
this.setState({ sendMessageFormFocused: false });
}
onMessageSubmit(values) {
const message = values.message ? values.message.trim() : null;
const { transaction, onResetForm, onSendMessage } = this.props;
const ensuredTransaction = ensureTransaction(transaction);
if (!message) {
return;
}
onSendMessage(ensuredTransaction.id, message)
.then(messageId => {
onResetForm(this.sendMessageFormName);
this.scrollToMessage(messageId);
})
.catch(e => {
// Ignore, Redux handles the error
});
}
scrollToMessage(messageId) {
const selector = `#msg-${messageId.uuid}`;
const el = document.querySelector(selector);
if (el) {
el.scrollIntoView({
block: 'start',
behavior: 'smooth',
});
}
}
render() {
const {
rootClassName,
className,
currentUser,
transaction,
totalMessagePages,
oldestMessagePageFetched,
messages,
initialMessageFailed,
fetchMessagesInProgress,
fetchMessagesError,
sendMessageInProgress,
sendMessageError,
sendReviewInProgress,
sendReviewError,
onManageDisableScrolling,
onShowMoreMessages,
transactionRole,
intl,
onAcceptSale,
onDeclineSale,
acceptInProgress,
declineInProgress,
acceptSaleError,
declineSaleError,
} = this.props;
const currentTransaction = ensureTransaction(transaction);
const currentListing = ensureListing(currentTransaction.listing);
const currentProvider = ensureUser(currentTransaction.provider);
const currentCustomer = ensureUser(currentTransaction.customer);
const isCustomer = transactionRole === 'customer';
const isProvider = transactionRole === 'provider';
const listingLoaded = !!currentListing.id;
const listingDeleted = listingLoaded && currentListing.attributes.deleted;
const customerLoaded = !!currentCustomer.id;
const isCustomerBanned = customerLoaded && currentCustomer.attributes.banned;
const canShowButtons =
isProvider && propTypes.txIsPreauthorized(currentTransaction) && !isCustomerBanned;
const bannedUserDisplayName = intl.formatMessage({
id: 'TransactionPanel.bannedUserDisplayName',
});
const deletedListingTitle = intl.formatMessage({
id: 'TransactionPanel.deletedListingTitle',
});
const { authorDisplayName, customerDisplayName, otherUserDisplayName } = displayNames(
currentUser,
currentProvider,
currentCustomer,
bannedUserDisplayName
);
const listingTitle = currentListing.attributes.deleted
? deletedListingTitle
: currentListing.attributes.title;
const firstImage =
currentListing.images && currentListing.images.length > 0 ? currentListing.images[0] : null;
const actionButtonClasses = classNames(css.actionButtons, {
[css.actionButtonsWithFormFocused]: this.state.sendMessageFormFocused,
});
const actionButtons = (
<ActionButtonsMaybe
rootClassName={actionButtonClasses}
canShowButtons={canShowButtons}
transaction={currentTransaction}
acceptInProgress={acceptInProgress}
declineInProgress={declineInProgress}
acceptSaleError={acceptSaleError}
declineSaleError={declineSaleError}
onAcceptSale={onAcceptSale}
onDeclineSale={onDeclineSale}
/>
);
const sendMessagePlaceholder = intl.formatMessage(
{ id: 'TransactionPanel.sendMessagePlaceholder' },
{ name: otherUserDisplayName }
);
const sendMessageFormClasses = classNames(css.sendMessageForm, {
[css.sendMessageFormFixed]: !canShowButtons || this.state.sendMessageFormFocused,
[css.sendMessageFormFocusedInMobileSafari]:
this.isMobSaf && this.state.sendMessageFormFocused,
});
const showInfoMessage =
listingDeleted || (!listingDeleted && propTypes.txIsPreauthorized(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}
nameSet={[
{ name: 'landscape-crop', size: '400w' },
{ name: 'landscape-crop2x', size: '800w' },
]}
sizes="100vw"
/>
</div>
</div>
<div className={classNames(css.avatarWrapperMobile, css.avatarMobile)}>
<AvatarMedium user={currentProvider} />
</div>
{isProvider ? (
<div className={css.avatarWrapper}>
<AvatarLarge user={currentCustomer} className={css.avatarDesktop} />
</div>
) : null}
<TransactionPageTitle
transaction={currentTransaction}
customerDisplayName={customerDisplayName}
currentListing={currentListing}
listingTitle={listingTitle}
transactionRole={transactionRole}
/>
<TransactionPageMessage
transaction={currentTransaction}
authorDisplayName={authorDisplayName}
customerDisplayName={customerDisplayName}
listingDeleted={listingDeleted}
isCustomerBanned={isCustomerBanned}
transactionRole={transactionRole}
/>
<div className={css.breakdownMobile}>
<h3 className={css.bookingBreakdownTitle}>
<FormattedMessage id="TransactionPanel.bookingBreakdownTitle" />
</h3>
<BreakdownMaybe transaction={currentTransaction} transactionRole={transactionRole} />
</div>
<FeedSection
rootClassName={feedContainerClasses}
currentTransaction={currentTransaction}
currentUser={currentUser}
fetchMessagesError={fetchMessagesError}
fetchMessagesInProgress={fetchMessagesInProgress}
initialMessageFailed={initialMessageFailed}
messages={messages}
oldestMessagePageFetched={oldestMessagePageFetched}
onOpenReviewModal={this.onOpenReviewModal}
onShowMoreMessages={onShowMoreMessages}
totalMessagePages={totalMessagePages}
/>
<SendMessageForm
form={this.sendMessageFormName}
rootClassName={sendMessageFormClasses}
messagePlaceholder={sendMessagePlaceholder}
inProgress={sendMessageInProgress}
sendMessageError={sendMessageError}
onFocus={this.onSendMessageFormFocus}
onBlur={this.onSendMessageFormBlur}
onSubmit={this.onMessageSubmit}
/>
{isProvider && canShowButtons ? (
<div className={css.mobileActionButtons}>{actionButtons}</div>
) : null}
</div>
<div className={css.desktopAside}>
<div className={css.breakdownDesktop}>
<div className={css.breakdownImageWrapper}>
<div className={css.aspectWrapper}>
<ResponsiveImage
rootClassName={css.rootForImage}
alt={listingTitle}
image={firstImage}
nameSet={[
{ name: 'landscape-crop', size: '400w' },
{ name: 'landscape-crop2x', size: '800w' },
]}
sizes="100%"
/>
</div>
</div>
{isCustomer ? (
<div className={css.avatarWrapperCustomerDesktop}>
<AvatarMedium user={currentProvider} />
</div>
) : null}
{isCustomer ? (
<div className={css.breakdownHeadings}>
<h2 className={css.breakdownTitle}>{listingTitle}</h2>
<p className={css.breakdownSubtitle}>
<FormattedMessage
id="TransactionPanel.hostedBy"
values={{ name: authorDisplayName }}
/>
</p>
</div>
) : null}
<BreakdownMaybe transaction={currentTransaction} transactionRole={transactionRole} />
{isProvider && canShowButtons ? (
<div className={css.desktopActionButtons}>{actionButtons}</div>
) : null}
</div>
</div>
</div>
<ReviewModal
id="ReviewOrderModal"
isOpen={this.state.isReviewModalOpen}
onCloseModal={() => this.setState({ isReviewModalOpen: false })}
onManageDisableScrolling={onManageDisableScrolling}
onSubmitReview={this.onSubmitReview}
revieweeName={authorDisplayName}
reviewSent={this.state.reviewSubmitted}
sendReviewInProgress={sendReviewInProgress}
sendReviewError={sendReviewError}
/>
</div>
);
}
}
TransactionPanelComponent.defaultProps = {
rootClassName: null,
className: null,
currentUser: null,
acceptSaleError: null,
declineSaleError: null,
fetchMessagesError: null,
initialMessageFailed: null,
sendMessageError: null,
sendReviewError: null,
};
const { arrayOf, bool, func, number, string } = PropTypes;
TransactionPanelComponent.propTypes = {
rootClassName: string,
className: string,
currentUser: propTypes.currentUser,
transaction: propTypes.transaction.isRequired,
totalMessagePages: number.isRequired,
oldestMessagePageFetched: number.isRequired,
messages: arrayOf(propTypes.message).isRequired,
initialMessageFailed: bool,
fetchMessagesInProgress: bool.isRequired,
fetchMessagesError: propTypes.error,
sendMessageInProgress: bool.isRequired,
sendMessageError: propTypes.error,
sendReviewInProgress: bool.isRequired,
sendReviewError: propTypes.error,
onManageDisableScrolling: func.isRequired,
onShowMoreMessages: func.isRequired,
onSendMessage: func.isRequired,
onSendReview: func.isRequired,
onResetForm: func.isRequired,
// Sale related props
onAcceptSale: func.isRequired,
onDeclineSale: func.isRequired,
acceptInProgress: bool.isRequired,
declineInProgress: bool.isRequired,
acceptSaleError: propTypes.error,
declineSaleError: propTypes.error,
// from injectIntl
intl: intlShape,
};
const TransactionPanel = injectIntl(TransactionPanelComponent);
export default TransactionPanel;

View file

@ -0,0 +1,321 @@
import React from 'react';
import { shallow } from 'enzyme';
import { types } from '../../util/sdkLoader';
import {
createTransaction,
createBooking,
createListing,
createUser,
createCurrentUser,
createMessage,
} from '../../util/test-data';
import { renderShallow } from '../../util/test-helpers';
import { fakeIntl } from '../../util/test-data';
import * as propTypes from '../../util/propTypes';
import { BreakdownMaybe } from './TransactionPanel.helpers';
import { TransactionPanelComponent } from './TransactionPanel';
const noop = () => null;
const { Money } = types;
describe('TransactionPanel - Sale', () => {
const baseTxAttrs = {
total: new Money(16500, 'USD'),
commission: new Money(1000, 'USD'),
booking: createBooking('booking1', {
start: new Date(Date.UTC(2017, 5, 10)),
end: new Date(Date.UTC(2017, 5, 13)),
}),
listing: createListing('listing1'),
customer: createUser('customer1'),
lastTransitionedAt: new Date(Date.UTC(2017, 5, 10)),
};
const txPreauthorized = createTransaction({
id: 'sale-preauthorized',
lastTransition: propTypes.TX_TRANSITION_PREAUTHORIZE,
...baseTxAttrs,
});
const txAccepted = createTransaction({
id: 'sale-accepted',
lastTransition: propTypes.TX_TRANSITION_ACCEPT,
...baseTxAttrs,
});
const txDeclined = createTransaction({
id: 'sale-declined',
lastTransition: propTypes.TX_TRANSITION_DECLINE,
...baseTxAttrs,
});
const txAutoDeclined = createTransaction({
id: 'sale-autodeclined',
lastTransition: propTypes.TX_TRANSITION_AUTO_DECLINE,
...baseTxAttrs,
});
const txCanceled = createTransaction({
id: 'sale-canceled',
lastTransition: propTypes.TX_TRANSITION_CANCELED,
...baseTxAttrs,
});
const txDelivered = createTransaction({
id: 'sale-delivered',
lastTransition: propTypes.TX_TRANSITION_MARK_DELIVERED,
...baseTxAttrs,
});
const panelBaseProps = {
onAcceptSale: noop,
onDeclineSale: noop,
acceptInProgress: false,
declineInProgress: false,
currentUser: createCurrentUser('user1'),
totalMessages: 2,
totalMessagePages: 1,
oldestMessagePageFetched: 1,
messages: [
createMessage('msg1', {}, { sender: createUser('user1') }),
createMessage('msg2', {}, { sender: createUser('user2') }),
],
initialMessageFailed: false,
fetchMessagesInProgress: false,
sendMessageInProgress: false,
sendReviewInProgress: false,
onManageDisableScrolling: noop,
onOpenReviewModal: noop,
onShowMoreMessages: noop,
onSendMessage: noop,
onSendReview: noop,
onResetForm: noop,
intl: fakeIntl,
};
it('preauthorized matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txPreauthorized,
};
const tree = renderShallow(<TransactionPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('accepted matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txAccepted,
};
const tree = renderShallow(<TransactionPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('declined matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txDeclined,
};
const tree = renderShallow(<TransactionPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('autodeclined matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txAutoDeclined,
};
const tree = renderShallow(<TransactionPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('canceled matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txCanceled,
};
const tree = renderShallow(<TransactionPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('delivered matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txDelivered,
};
const tree = renderShallow(<TransactionPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('renders correct total price', () => {
const transaction = createTransaction({
id: 'sale-tx',
lastTransition: propTypes.TX_TRANSITION_PREAUTHORIZE,
total: new Money(16500, 'USD'),
commission: new Money(1000, 'USD'),
booking: createBooking('booking1', {
start: new Date(Date.UTC(2017, 5, 10)),
end: new Date(Date.UTC(2017, 5, 13)),
}),
listing: createListing('listing1'),
customer: createUser('customer1'),
lastTransitionedAt: new Date(Date.UTC(2017, 5, 10)),
});
const props = {
...panelBaseProps,
transaction,
};
const panel = shallow(<TransactionPanelComponent {...props} />);
const breakdownProps = panel
.find(BreakdownMaybe)
.first()
.props();
// Total price for the provider should be transaction total minus the commission.
expect(breakdownProps.transaction.attributes.payoutTotal).toEqual(new Money(15500, 'USD'));
});
});
describe('TransactionPanel - Order', () => {
const baseTxAttrs = {
total: new Money(16500, 'USD'),
booking: createBooking('booking1', {
start: new Date(Date.UTC(2017, 5, 10)),
end: new Date(Date.UTC(2017, 5, 13)),
}),
listing: createListing('listing1'),
provider: createUser('provider'),
customer: createUser('customer'),
};
const txPreauthorized = createTransaction({
id: 'order-preauthorized',
lastTransition: propTypes.TX_TRANSITION_PREAUTHORIZE,
...baseTxAttrs,
});
const txAccepted = createTransaction({
id: 'order-accepted',
lastTransition: propTypes.TX_TRANSITION_ACCEPT,
...baseTxAttrs,
});
const txDeclined = createTransaction({
id: 'order-declined',
lastTransition: propTypes.TX_TRANSITION_DECLINE,
...baseTxAttrs,
});
const txAutoDeclined = createTransaction({
id: 'order-autodeclined',
lastTransition: propTypes.TX_TRANSITION_AUTO_DECLINE,
...baseTxAttrs,
});
const txCanceled = createTransaction({
id: 'order-canceled',
lastTransition: propTypes.TX_TRANSITION_CANCELED,
...baseTxAttrs,
});
const txDelivered = createTransaction({
id: 'order-delivered',
lastTransition: propTypes.TX_TRANSITION_MARK_DELIVERED,
...baseTxAttrs,
});
const panelBaseProps = {
intl: fakeIntl,
currentUser: createCurrentUser('user2'),
totalMessages: 2,
totalMessagePages: 1,
oldestMessagePageFetched: 1,
messages: [
createMessage('msg1', {}, { sender: createUser('user1') }),
createMessage('msg2', {}, { sender: createUser('user2') }),
],
initialMessageFailed: false,
fetchMessagesInProgress: false,
sendMessageInProgress: false,
sendReviewInProgress: false,
onManageDisableScrolling: noop,
onOpenReviewModal: noop,
onShowMoreMessages: noop,
onSendMessage: noop,
onSendReview: noop,
onResetForm: noop,
onAcceptSale: noop,
onDeclineSale: noop,
acceptInProgress: false,
declineInProgress: false,
};
it('preauthorized matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txPreauthorized,
};
const tree = renderShallow(<TransactionPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('accepted matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txAccepted,
};
const tree = renderShallow(<TransactionPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('declined matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txDeclined,
};
const tree = renderShallow(<TransactionPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('autodeclined matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txAutoDeclined,
};
const tree = renderShallow(<TransactionPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('canceled matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txCanceled,
};
const tree = renderShallow(<TransactionPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('delivered matches snapshot', () => {
const props = {
...panelBaseProps,
transaction: txDelivered,
};
const tree = renderShallow(<TransactionPanelComponent {...props} />);
expect(tree).toMatchSnapshot();
});
it('renders correct total price', () => {
const { Money } = types;
const tx = createTransaction({
id: 'order-tx',
lastTransition: propTypes.TX_TRANSITION_PREAUTHORIZE,
total: new Money(16500, 'USD'),
booking: createBooking('booking1', {
start: new Date(Date.UTC(2017, 5, 10)),
end: new Date(Date.UTC(2017, 5, 13)),
}),
listing: createListing('listing1'),
provider: createUser('provider'),
customer: createUser('customer'),
});
const props = {
...panelBaseProps,
transaction: tx,
};
const panel = shallow(<TransactionPanelComponent {...props} />);
const breakdownProps = panel
.find(BreakdownMaybe)
.first()
.props();
expect(breakdownProps.transaction.attributes.payinTotal).toEqual(new Money(16500, 'USD'));
});
});

File diff suppressed because it is too large Load diff

View file

@ -76,7 +76,6 @@ export { default as ModalInMobile } from './ModalInMobile/ModalInMobile';
export { default as NamedLink } from './NamedLink/NamedLink';
export { default as NamedRedirect } from './NamedRedirect/NamedRedirect';
export { default as NotificationBadge } from './NotificationBadge/NotificationBadge';
export { default as OrderDetailsPanel } from './OrderDetailsPanel/OrderDetailsPanel';
export { default as OrderDiscussionPanel } from './OrderDiscussionPanel/OrderDiscussionPanel';
export { default as Page } from './Page/Page';
export { default as PaginationLinks } from './PaginationLinks/PaginationLinks';
@ -86,7 +85,6 @@ export { default as ResponsiveImage } from './ResponsiveImage/ResponsiveImage';
export { default as ReviewModal } from './ReviewModal/ReviewModal';
export { default as ReviewRating } from './ReviewRating/ReviewRating';
export { default as Reviews } from './Reviews/Reviews';
export { default as SaleDetailsPanel } from './SaleDetailsPanel/SaleDetailsPanel';
export { default as SearchMap } from './SearchMap/SearchMap';
export { default as SearchMapGroupLabel } from './SearchMapGroupLabel/SearchMapGroupLabel';
export { default as SearchMapInfoCard } from './SearchMapInfoCard/SearchMapInfoCard';
@ -107,6 +105,7 @@ export { default as TextInputField } from './TextInputField/TextInputField';
export { default as Topbar } from './Topbar/Topbar';
export { default as TopbarDesktop } from './TopbarDesktop/TopbarDesktop';
export { default as TopbarMobileMenu } from './TopbarMobileMenu/TopbarMobileMenu';
export { default as TransactionPanel } from './TransactionPanel/TransactionPanel';
export { default as UserCard } from './UserCard/UserCard';
export { default as UserNav } from './UserNav/UserNav';
export { default as ValidationError } from './ValidationError/ValidationError';

View file

@ -23,6 +23,10 @@
margin: 0;
max-height: 183px;
/* We need to remove horizontal paddings,
since textarea uses content-box (autosize library fix). */
width: calc(100vw - 100px);
background-color: var(--matterColorLight);
@media (--viewportMedium) {
@ -32,6 +36,7 @@
padding: 0 0 5px 0;
margin: 0;
max-height: initial;
width: 100%;
border-bottom-width: 2px;
border-bottom-color: var(--attentionColor);
background-color: transparent;

View file

@ -11,8 +11,7 @@ import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck';
import { isScrollingDisabled, manageDisableScrolling } from '../../ducks/UI.duck';
import {
NamedRedirect,
OrderDetailsPanel,
SaleDetailsPanel,
TransactionPanel,
Page,
LayoutSingleColumn,
LayoutWrapperTopbar,
@ -71,7 +70,12 @@ export const TransactionPageComponent = props => {
const currentTransaction = ensureTransaction(transaction);
const currentListing = ensureListing(currentTransaction.listing);
const listingTitle = currentListing.attributes.title;
const deletedListingTitle = intl.formatMessage({
id: 'TransactionPage.deletedListing',
});
const listingTitle = currentListing.attributes.deleted
? deletedListingTitle
: currentListing.attributes.title;
// Redirect users with someone else's direct link to their own inbox/sales or inbox/orders page.
const isDataAvailable =
@ -122,42 +126,16 @@ export const TransactionPageComponent = props => {
</p>
);
const salePanel = isDataAvailable ? (
<SaleDetailsPanel
className={detailsClassName}
currentUser={currentUser}
transaction={currentTransaction}
onAcceptSale={onAcceptSale}
onDeclineSale={onDeclineSale}
acceptInProgress={acceptInProgress}
declineInProgress={declineInProgress}
acceptSaleError={acceptSaleError}
declineSaleError={declineSaleError}
totalMessagePages={totalMessagePages}
oldestMessagePageFetched={oldestMessagePageFetched}
messages={messages}
fetchMessagesInProgress={fetchMessagesInProgress}
fetchMessagesError={fetchMessagesError}
sendMessageInProgress={sendMessageInProgress}
sendMessageError={sendMessageError}
sendReviewInProgress={sendReviewInProgress}
sendReviewError={sendReviewError}
onManageDisableScrolling={onManageDisableScrolling}
onShowMoreMessages={onShowMoreMessages}
onSendMessage={onSendMessage}
onSendReview={onSendReview}
onResetForm={onResetForm}
/>
) : null;
const initialMessageFailed = !!(
initialMessageFailedToTransaction &&
currentTransaction.id &&
initialMessageFailedToTransaction.uuid === currentTransaction.id.uuid
);
const orderPanel = isDataAvailable ? (
<OrderDetailsPanel
// TransactionPanel is presentational component
// that currently handles showing everything inside layout's main view area.
const panel = isDataAvailable ? (
<TransactionPanel
className={detailsClassName}
currentUser={currentUser}
transaction={currentTransaction}
@ -176,11 +154,17 @@ export const TransactionPageComponent = props => {
onSendMessage={onSendMessage}
onSendReview={onSendReview}
onResetForm={onResetForm}
transactionRole={transactionRole}
onAcceptSale={onAcceptSale}
onDeclineSale={onDeclineSale}
acceptInProgress={acceptInProgress}
declineInProgress={declineInProgress}
acceptSaleError={acceptSaleError}
declineSaleError={declineSaleError}
/>
) : null;
const saleOrOrderPanel = isDataAvailable && isOwnSale ? salePanel : orderPanel;
const panel = isDataAvailable ? saleOrOrderPanel : loadingOrFailedFetching;
) : (
loadingOrFailedFetching
);
return (
<Page

View file

@ -20,7 +20,9 @@ exports[`TransactionPage - Order matches snapshot 1`] = `
rootClassName={null}
>
<div>
<InjectIntl(OrderDetailsPanelComponent)
<InjectIntl(TransactionPanelComponent)
acceptInProgress={false}
acceptSaleError={null}
className=""
currentUser={
Object {
@ -42,11 +44,15 @@ exports[`TransactionPage - Order matches snapshot 1`] = `
"type": "currentUser",
}
}
declineInProgress={false}
declineSaleError={null}
fetchMessagesError={null}
fetchMessagesInProgress={false}
initialMessageFailed={false}
messages={Array []}
oldestMessagePageFetched={0}
onAcceptSale={[Function]}
onDeclineSale={[Function]}
onResetForm={[Function]}
onSendMessage={[Function]}
onShowMoreMessages={[Function]}
@ -171,6 +177,7 @@ exports[`TransactionPage - Order matches snapshot 1`] = `
"type": "transaction",
}
}
transactionRole="customer"
/>
</div>
</LayoutWrapperMain>
@ -204,7 +211,7 @@ exports[`TransactionPage - Sale matches snapshot 1`] = `
rootClassName={null}
>
<div>
<InjectIntl(SaleDetailsPanelComponent)
<InjectIntl(TransactionPanelComponent)
acceptInProgress={false}
acceptSaleError={null}
className=""
@ -231,6 +238,7 @@ exports[`TransactionPage - Sale matches snapshot 1`] = `
declineInProgress={false}
declineSaleError={null}
fetchMessagesError={null}
initialMessageFailed={false}
messages={Array []}
oldestMessagePageFetched={0}
onAcceptSale={[Function]}
@ -359,6 +367,7 @@ exports[`TransactionPage - Sale matches snapshot 1`] = `
"type": "transaction",
}
}
transactionRole="provider"
/>
</div>
</LayoutWrapperMain>

View file

@ -1,4 +1,6 @@
{
"ActivityFeed.bannedUserDisplayName": "Banned user",
"ActivityFeed.deletedListing": "deleted listing",
"ActivityFeed.leaveAReview": "Leave a review for { displayName }.",
"ActivityFeed.leaveAReviewSecond": "Leave a review for { displayName } to see their review.",
"ActivityFeed.ownTransitionAccept": "You accepted the booking request.",
@ -284,24 +286,6 @@
"NotFoundPage.description": "We can't find the page or sauna you're looking for. Make sure you've typed in the URL correctly or try searching Saunatime.",
"NotFoundPage.heading": "Sorry, we couldn't find that page.",
"NotFoundPage.title": "Page not found",
"OrderDetailsPanel.activityHeading": "Activity",
"OrderDetailsPanel.bannedUserDisplayName": "Banned user",
"OrderDetailsPanel.bookingBreakdownTitle": "Booking breakdown",
"OrderDetailsPanel.deletedListingOrderTitle": "a listing",
"OrderDetailsPanel.deletedListingTitle": "Deleted listing",
"OrderDetailsPanel.hostedBy": "Hosted by {name}",
"OrderDetailsPanel.initialMessageFailed": "Whoops, failed to send message from checkout.",
"OrderDetailsPanel.messageDeletedListing": "However, the listing is deleted and cannot be viewed anymore.",
"OrderDetailsPanel.messageLoadingFailed": "Something went wrong when loading messages. Please refresh the page and try again.",
"OrderDetailsPanel.orderAcceptedSubtitle": "Your booking for {listingLink} has been accepted.",
"OrderDetailsPanel.orderAcceptedTitle": "Woohoo {customerName}!",
"OrderDetailsPanel.orderCancelledTitle": "{customerName}, your booking for {listingLink} has been cancelled.",
"OrderDetailsPanel.orderDeclinedTitle": "{customerName}, your booking for {listingLink} has been declined.",
"OrderDetailsPanel.orderDeliveredTitle": "{customerName}, your booking for {listingLink} has been completed.",
"OrderDetailsPanel.orderPreauthorizedInfo": "{providerName} has been notified about the booking request. Sit back and relax.",
"OrderDetailsPanel.orderPreauthorizedSubtitle": "You have requested to book {listingLink}.",
"OrderDetailsPanel.orderPreauthorizedTitle": "Great success, {customerName}!",
"OrderDetailsPanel.sendMessagePlaceholder": "Send a message to {name}…",
"Page.schemaDescription": "Book a sauna using Saunatime or earn some income by sharing your sauna",
"Page.schemaTitle": "Book saunas everywhere | {siteTitle}",
"PaginationLinks.next": "Next page",
@ -458,22 +442,6 @@
"ReviewModal.description": "Reviews are an important part of the Saunatime community. Please share what went well and what could have been improved.",
"ReviewModal.later": "Later",
"ReviewModal.title": "Leave a review for {revieweeName}",
"SaleDetailsPanel.acceptButton": "Accept",
"SaleDetailsPanel.acceptSaleFailed": "Oops, accepting failed. Please try again.",
"SaleDetailsPanel.activityHeading": "Activity",
"SaleDetailsPanel.bannedUserDisplayName": "Banned user",
"SaleDetailsPanel.bookingBreakdownTitle": "Booking breakdown",
"SaleDetailsPanel.customerBannedStatus": "The user made the request, but was later banned.",
"SaleDetailsPanel.declineButton": "Decline",
"SaleDetailsPanel.declineSaleFailed": "Oops, declining failed. Please try again.",
"SaleDetailsPanel.messageLoadingFailed": "Something went wrong when loading messages. Please refresh the page and try again.",
"SaleDetailsPanel.saleAcceptedTitle": "You accepted a request from {customerName} to book {listingLink}.",
"SaleDetailsPanel.saleCancelledTitle": "The booking from {customerName} for {listingLink} has been cancelled.",
"SaleDetailsPanel.saleDeclinedTitle": "The request from {customerName} to book {listingLink} has been declined.",
"SaleDetailsPanel.saleDeliveredTitle": "The booking from {customerName} for {listingLink} has been completed.",
"SaleDetailsPanel.saleRequestedInfo": "{customerName} is waiting for your response.",
"SaleDetailsPanel.saleRequestedTitle": "{customerName} has requested to book {listingLink}.",
"SaleDetailsPanel.sendMessagePlaceholder": "Send a message to {name}…",
"SearchMapInfoCard.noImage": "No image",
"SearchPage.foundResults": "{count, number} {count, plural, one {sauna} other {saunas}} found",
"SearchPage.foundResultsMobile": "{count, number} {count, plural, one {result} other {results}}",
@ -621,11 +589,41 @@
"TopbarMobileMenu.yourListingsLink": "Your listings",
"TopbarSearchForm.placeholder": "Search saunas…",
"TopbarSearchForm.searchHelp": "Tip: You can also search saunas by zip code, for example \"00500\" or city district \"Sörnäinen\".",
"TransactionPage.deletedListing": "deleted listing",
"TransactionPage.fetchOrderFailed": "Fetching order data failed.",
"TransactionPage.fetchSaleFailed": "Fetching sale data failed.",
"TransactionPage.loadingOrderData": "Loading order data.",
"TransactionPage.loadingSaleData": "Loading sale data.",
"TransactionPage.title": "Sale details: {title}",
"TransactionPanel.acceptButton": "Accept",
"TransactionPanel.acceptSaleFailed": "Oops, accepting failed. Please try again.",
"TransactionPanel.activityHeading": "Activity",
"TransactionPanel.bannedUserDisplayName": "Banned user",
"TransactionPanel.bookingBreakdownTitle": "Booking breakdown",
"TransactionPanel.customerBannedStatus": "The user made the request, but was later banned.",
"TransactionPanel.declineButton": "Decline",
"TransactionPanel.declineSaleFailed": "Oops, declining failed. Please try again.",
"TransactionPanel.deletedListingOrderTitle": "a listing",
"TransactionPanel.deletedListingTitle": "Deleted listing",
"TransactionPanel.hostedBy": "Hosted by {name}",
"TransactionPanel.initialMessageFailed": "Whoops, failed to send message from checkout.",
"TransactionPanel.messageDeletedListing": "However, the listing is deleted and cannot be viewed anymore.",
"TransactionPanel.messageLoadingFailed": "Something went wrong when loading messages. Please refresh the page and try again.",
"TransactionPanel.orderAcceptedSubtitle": "Your booking for {listingLink} has been accepted.",
"TransactionPanel.orderAcceptedTitle": "Woohoo {customerName}!",
"TransactionPanel.orderCancelledTitle": "{customerName}, your booking for {listingLink} has been cancelled.",
"TransactionPanel.orderDeclinedTitle": "{customerName}, your booking for {listingLink} has been declined.",
"TransactionPanel.orderDeliveredTitle": "{customerName}, your booking for {listingLink} has been completed.",
"TransactionPanel.orderPreauthorizedInfo": "{providerName} has been notified about the booking request. Sit back and relax.",
"TransactionPanel.orderPreauthorizedSubtitle": "You have requested to book {listingLink}.",
"TransactionPanel.orderPreauthorizedTitle": "Great success, {customerName}!",
"TransactionPanel.saleAcceptedTitle": "You accepted a request from {customerName} to book {listingLink}.",
"TransactionPanel.saleCancelledTitle": "The booking from {customerName} for {listingLink} has been cancelled.",
"TransactionPanel.saleDeclinedTitle": "The request from {customerName} to book {listingLink} has been declined.",
"TransactionPanel.saleDeliveredTitle": "The booking from {customerName} for {listingLink} has been completed.",
"TransactionPanel.saleRequestedInfo": "{customerName} is waiting for your response.",
"TransactionPanel.saleRequestedTitle": "{customerName} has requested to book {listingLink}.",
"TransactionPanel.sendMessagePlaceholder": "Send a message to {name}…",
"UserCard.contactUser": "Contact",
"UserCard.heading": "Hello, I'm {name}.",
"UserCard.showFullBioLink": "more",

View file

@ -17,13 +17,23 @@
* defined. This way we get the validation errors only in the most
* specific place and avoid duplicate errros.
*/
import PropTypes from 'prop-types';
import {
arrayOf,
bool,
func,
instanceOf,
number,
object,
oneOf,
oneOfType,
shape,
string,
} from 'prop-types';
import Decimal from 'decimal.js';
import { types as sdkTypes } from './sdkLoader';
import { ensureTransaction } from './data';
const { UUID, LatLng, LatLngBounds, Money } = sdkTypes;
const { arrayOf, bool, func, instanceOf, number, object, oneOf, shape, string } = PropTypes;
// Fixed value
export const value = val => oneOf([val]);
@ -113,19 +123,26 @@ export const user = shape({
profileImage: image,
});
const listingAttributes = shape({
title: string.isRequired,
description: string.isRequired,
address: string.isRequired,
geolocation: latlng.isRequired,
closed: bool.isRequired,
deleted: value(false).isRequired,
price: money,
});
const deletedListingAttributes = shape({
closed: bool.isRequired,
deleted: value(true).isRequired,
});
// Denormalised listing object
export const listing = shape({
id: uuid.isRequired,
type: value('listing').isRequired,
attributes: shape({
title: string.isRequired,
description: string.isRequired,
address: string.isRequired,
geolocation: latlng.isRequired,
closed: bool.isRequired,
deleted: bool.isRequired,
price: money,
}).isRequired,
attributes: oneOfType([listingAttributes, deletedListingAttributes]).isRequired,
author: user,
images: arrayOf(image),
});