diff --git a/src/components/SaleDetailsPanel/SaleDetailsPanel.js b/src/components/SaleDetailsPanel/SaleDetailsPanel.js
index 75cf8454..a3434e6a 100644
--- a/src/components/SaleDetailsPanel/SaleDetailsPanel.js
+++ b/src/components/SaleDetailsPanel/SaleDetailsPanel.js
@@ -264,14 +264,14 @@ export class SaleDetailsPanelComponent extends Component {
disabled={buttonsDisabled}
onClick={() => onDeclineSale(currentTransaction.id)}
>
-
+
onAcceptSale(currentTransaction.id)}
>
-
+
diff --git a/src/components/SaleDetailsPanel/__snapshots__/SaleDetailsPanel.test.js.snap b/src/components/SaleDetailsPanel/__snapshots__/SaleDetailsPanel.test.js.snap
index b89b5c5c..bc3882e1 100644
--- a/src/components/SaleDetailsPanel/__snapshots__/SaleDetailsPanel.test.js.snap
+++ b/src/components/SaleDetailsPanel/__snapshots__/SaleDetailsPanel.test.js.snap
@@ -3441,7 +3441,7 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = `
onClick={[Function]}
>
@@ -3451,7 +3451,7 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = `
onClick={[Function]}
>
@@ -3626,7 +3626,7 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = `
onClick={[Function]}
>
@@ -3636,7 +3636,7 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = `
onClick={[Function]}
>
diff --git a/src/containers/CheckoutPage/CheckoutPage.js b/src/containers/CheckoutPage/CheckoutPage.js
index dfcff6fe..87a6971c 100644
--- a/src/containers/CheckoutPage/CheckoutPage.js
+++ b/src/containers/CheckoutPage/CheckoutPage.js
@@ -123,7 +123,7 @@ export class CheckoutPageComponent extends Component {
// sending failed, we tell it to the OrderDetailsPage.
dispatch(
OrderPage.setInitialValues({
- messageSendingFailedToTransaction: initialMessageSuccess ? null : orderId,
+ initialMessageFailedToTransaction: initialMessageSuccess ? null : orderId,
})
);
const orderDetailsPath = pathByRouteName('OrderDetailsPage', routes, {
diff --git a/src/containers/TransactionPage/TransactionPage.css b/src/containers/TransactionPage/TransactionPage.css
new file mode 100644
index 00000000..b9a2e238
--- /dev/null
+++ b/src/containers/TransactionPage/TransactionPage.css
@@ -0,0 +1,34 @@
+@import '../../marketplace.css';
+
+.root {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+.loading {
+ margin-left: 24px;
+ margin-right: 24px;
+}
+
+.error {
+ margin-left: 24px;
+ margin-right: 24px;
+ color: var(--failColor);
+}
+
+.tabContent {
+ display: none;
+}
+
+.tabContentVisible {
+ display: block;
+}
+
+.footer {
+ display: none;
+
+ @media (--viewportLarge) {
+ display: block;
+ }
+}
diff --git a/src/containers/TransactionPage/TransactionPage.duck.js b/src/containers/TransactionPage/TransactionPage.duck.js
new file mode 100644
index 00000000..ba3beb4b
--- /dev/null
+++ b/src/containers/TransactionPage/TransactionPage.duck.js
@@ -0,0 +1,440 @@
+import { pick } from 'lodash';
+import { types } from '../../util/sdkLoader';
+import { isTransactionsTransitionInvalidTransition, storableError } from '../../util/errors';
+import * as propTypes from '../../util/propTypes';
+import * as log from '../../util/log';
+import { updatedEntities, denormalisedEntities } from '../../util/data';
+import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck';
+import { fetchCurrentUserNotifications } from '../../ducks/user.duck';
+
+const MESSAGES_PAGE_SIZE = 100;
+
+// ================ Action types ================ //
+
+export const SET_INITAL_VALUES = 'app/TransactionPage/SET_INITIAL_VALUES';
+
+export const FETCH_TRANSACTION_REQUEST = 'app/TransactionPage/FETCH_TRANSACTION_REQUEST';
+export const FETCH_TRANSACTION_SUCCESS = 'app/TransactionPage/FETCH_TRANSACTION_SUCCESS';
+export const FETCH_TRANSACTION_ERROR = 'app/TransactionPage/FETCH_TRANSACTION_ERROR';
+
+export const ACCEPT_SALE_REQUEST = 'app/TransactionPage/ACCEPT_SALE_REQUEST';
+export const ACCEPT_SALE_SUCCESS = 'app/TransactionPage/ACCEPT_SALE_SUCCESS';
+export const ACCEPT_SALE_ERROR = 'app/TransactionPage/ACCEPT_SALE_ERROR';
+
+export const DECLINE_SALE_REQUEST = 'app/TransactionPage/DECLINE_SALE_REQUEST';
+export const DECLINE_SALE_SUCCESS = 'app/TransactionPage/DECLINE_SALE_SUCCESS';
+export const DECLINE_SALE_ERROR = 'app/TransactionPage/DECLINE_SALE_ERROR';
+
+export const FETCH_MESSAGES_REQUEST = 'app/TransactionPage/FETCH_MESSAGES_REQUEST';
+export const FETCH_MESSAGES_SUCCESS = 'app/TransactionPage/FETCH_MESSAGES_SUCCESS';
+export const FETCH_MESSAGES_ERROR = 'app/TransactionPage/FETCH_MESSAGES_ERROR';
+
+export const SEND_MESSAGE_REQUEST = 'app/TransactionPage/SEND_MESSAGE_REQUEST';
+export const SEND_MESSAGE_SUCCESS = 'app/TransactionPage/SEND_MESSAGE_SUCCESS';
+export const SEND_MESSAGE_ERROR = 'app/TransactionPage/SEND_MESSAGE_ERROR';
+
+export const SEND_REVIEW_REQUEST = 'app/TransactionPage/SEND_REVIEW_REQUEST';
+export const SEND_REVIEW_SUCCESS = 'app/TransactionPage/SEND_REVIEW_SUCCESS';
+export const SEND_REVIEW_ERROR = 'app/TransactionPage/SEND_REVIEW_ERROR';
+
+// ================ Reducer ================ //
+
+const initialState = {
+ fetchTransactionInProgress: false,
+ fetchTransactionError: null,
+ transactionRef: null,
+ acceptInProgress: false,
+ acceptSaleError: null,
+ declineInProgress: false,
+ declineSaleError: null,
+ fetchMessagesInProgress: false,
+ fetchMessagesError: null,
+ totalMessages: 0,
+ totalMessagePages: 0,
+ oldestMessagePageFetched: 0,
+ messages: [],
+ initialMessageFailedToTransaction: null,
+ sendMessageInProgress: false,
+ sendMessageError: null,
+ sendReviewInProgress: false,
+ sendReviewError: null,
+};
+
+// Merge entity arrays using ids, so that conflicting items in newer array (b) overwrite old values (a).
+// const a = [{ id: { uuid: 1 } }, { id: { uuid: 3 } }];
+// const b = [{ id: : { uuid: 2 } }, { id: : { uuid: 1 } }];
+// mergeEntityArrays(a, b)
+// => [{ id: { uuid: 3 } }, { id: : { uuid: 2 } }, { id: : { uuid: 1 } }]
+const mergeEntityArrays = (a, b) => {
+ return a.filter(aEntity => !b.find(bEntity => aEntity.id.uuid === bEntity.id.uuid)).concat(b);
+};
+
+export default function checkoutPageReducer(state = initialState, action = {}) {
+ const { type, payload } = action;
+ switch (type) {
+ case SET_INITAL_VALUES:
+ return { ...initialState, ...payload };
+
+ case FETCH_TRANSACTION_REQUEST:
+ return { ...state, fetchTransactionInProgress: true, fetchTransactionError: null };
+ case FETCH_TRANSACTION_SUCCESS: {
+ const transactionRef = { id: payload.data.data.id, type: 'transaction' };
+ return { ...state, fetchTransactionInProgress: false, transactionRef };
+ }
+ case FETCH_TRANSACTION_ERROR:
+ console.error(payload); // eslint-disable-line
+ return { ...state, fetchTransactionInProgress: false, fetchTransactionError: payload };
+
+ case ACCEPT_SALE_REQUEST:
+ return { ...state, acceptInProgress: true, acceptSaleError: null, declineSaleError: null };
+ case ACCEPT_SALE_SUCCESS:
+ return { ...state, acceptInProgress: false };
+ case ACCEPT_SALE_ERROR:
+ return { ...state, acceptInProgress: false, acceptSaleError: payload };
+
+ case DECLINE_SALE_REQUEST:
+ return { ...state, declineInProgress: true, declineSaleError: null, acceptSaleError: null };
+ case DECLINE_SALE_SUCCESS:
+ return { ...state, declineInProgress: false };
+ case DECLINE_SALE_ERROR:
+ return { ...state, declineInProgress: false, declineSaleError: payload };
+
+ case FETCH_MESSAGES_REQUEST:
+ return { ...state, fetchMessagesInProgress: true, fetchMessagesError: null };
+ case FETCH_MESSAGES_SUCCESS: {
+ const oldestMessagePageFetched =
+ state.oldestMessagePageFetched > payload.page
+ ? state.oldestMessagePageFetched
+ : payload.page;
+ return {
+ ...state,
+ fetchMessagesInProgress: false,
+ messages: mergeEntityArrays(state.messages, payload.messages),
+ totalMessages: payload.totalItems,
+ totalMessagePages: payload.totalPages,
+ oldestMessagePageFetched,
+ };
+ }
+ case FETCH_MESSAGES_ERROR:
+ return { ...state, fetchMessagesInProgress: false, fetchMessagesError: payload };
+
+ case SEND_MESSAGE_REQUEST:
+ return { ...state, sendMessageInProgress: true, sendMessageError: null };
+ case SEND_MESSAGE_SUCCESS:
+ return { ...state, sendMessageInProgress: false };
+ case SEND_MESSAGE_ERROR:
+ return { ...state, sendMessageInProgress: false, sendMessageError: payload };
+
+ case SEND_REVIEW_REQUEST:
+ return { ...state, sendReviewInProgress: true, sendReviewError: null };
+ case SEND_REVIEW_SUCCESS:
+ return { ...state, sendReviewInProgress: false };
+ case SEND_REVIEW_ERROR:
+ return { ...state, sendReviewInProgress: false, sendReviewError: payload };
+
+ default:
+ return state;
+ }
+}
+
+// ================ Selectors ================ //
+
+export const acceptOrDeclineInProgress = state => {
+ return state.TransactionPage.acceptInProgress || state.TransactionPage.declineInProgress;
+};
+
+// ================ Action creators ================ //
+export const setInitialValues = initialValues => ({
+ type: SET_INITAL_VALUES,
+ payload: pick(initialValues, Object.keys(initialState)),
+});
+
+const fetchTransactionRequest = () => ({ type: FETCH_TRANSACTION_REQUEST });
+const fetchTransactionSuccess = response => ({
+ type: FETCH_TRANSACTION_SUCCESS,
+ payload: response,
+});
+const fetchTransactionError = e => ({ type: FETCH_TRANSACTION_ERROR, error: true, payload: e });
+
+const acceptSaleRequest = () => ({ type: ACCEPT_SALE_REQUEST });
+const acceptSaleSuccess = () => ({ type: ACCEPT_SALE_SUCCESS });
+const acceptSaleError = e => ({ type: ACCEPT_SALE_ERROR, error: true, payload: e });
+
+const declineSaleRequest = () => ({ type: DECLINE_SALE_REQUEST });
+const declineSaleSuccess = () => ({ type: DECLINE_SALE_SUCCESS });
+const declineSaleError = e => ({ type: DECLINE_SALE_ERROR, error: true, payload: e });
+
+const fetchMessagesRequest = () => ({ type: FETCH_MESSAGES_REQUEST });
+const fetchMessagesSuccess = (messages, pagination) => ({
+ type: FETCH_MESSAGES_SUCCESS,
+ payload: { messages, ...pagination },
+});
+const fetchMessagesError = e => ({ type: FETCH_MESSAGES_ERROR, error: true, payload: e });
+
+const sendMessageRequest = () => ({ type: SEND_MESSAGE_REQUEST });
+const sendMessageSuccess = () => ({ type: SEND_MESSAGE_SUCCESS });
+const sendMessageError = e => ({ type: SEND_MESSAGE_ERROR, error: true, payload: e });
+
+const sendReviewRequest = () => ({ type: SEND_REVIEW_REQUEST });
+const sendReviewSuccess = () => ({ type: SEND_REVIEW_SUCCESS });
+const sendReviewError = e => ({ type: SEND_REVIEW_ERROR, error: true, payload: e });
+
+// ================ Thunks ================ //
+
+const listingRelationship = txResponse => {
+ return txResponse.data.data.relationships.listing.data;
+};
+
+export const fetchTransaction = id => (dispatch, getState, sdk) => {
+ dispatch(fetchTransactionRequest());
+ let txResponse = null;
+
+ return sdk.transactions
+ .show(
+ {
+ id,
+ include: [
+ 'customer',
+ 'customer.profileImage',
+ 'provider',
+ 'provider.profileImage',
+ 'listing',
+ 'booking',
+ 'reviews',
+ 'reviews.author',
+ 'reviews.subject',
+ ],
+ },
+ { expand: true }
+ )
+ .then(response => {
+ txResponse = response;
+ const listingId = listingRelationship(response).id;
+ const entities = updatedEntities({}, response.data);
+ const denormalised = denormalisedEntities(entities, 'listing', [listingId]);
+ const listing = denormalised[0];
+
+ const canFetchListing = listing && listing.attributes && !listing.attributes.deleted;
+
+ if (canFetchListing) {
+ return sdk.listings.show({
+ id: listingId,
+ include: ['author', 'author.profileImage', 'images'],
+ });
+ } else {
+ return response;
+ }
+ })
+ .then(response => {
+ dispatch(addMarketplaceEntities(txResponse));
+ dispatch(addMarketplaceEntities(response));
+ dispatch(fetchTransactionSuccess(txResponse));
+ return response;
+ })
+ .catch(e => {
+ dispatch(fetchTransactionError(storableError(e)));
+ throw e;
+ });
+};
+
+export const acceptSale = id => (dispatch, getState, sdk) => {
+ if (acceptOrDeclineInProgress(getState())) {
+ return Promise.reject(new Error('Accept or decline already in progress'));
+ }
+ dispatch(acceptSaleRequest());
+
+ return sdk.transactions
+ .transition({ id, transition: propTypes.TX_TRANSITION_ACCEPT, params: {} }, { expand: true })
+ .then(response => {
+ dispatch(addMarketplaceEntities(response));
+ dispatch(acceptSaleSuccess());
+ dispatch(fetchCurrentUserNotifications());
+ return response;
+ })
+ .catch(e => {
+ dispatch(acceptSaleError(storableError(e)));
+ log.error(e, 'accept-sale-failed', {
+ txId: id,
+ transition: propTypes.TX_TRANSITION_ACCEPT,
+ });
+ throw e;
+ });
+};
+
+export const declineSale = id => (dispatch, getState, sdk) => {
+ if (acceptOrDeclineInProgress(getState())) {
+ return Promise.reject(new Error('Accept or decline already in progress'));
+ }
+ dispatch(declineSaleRequest());
+
+ return sdk.transactions
+ .transition({ id, transition: propTypes.TX_TRANSITION_DECLINE, params: {} }, { expand: true })
+ .then(response => {
+ dispatch(addMarketplaceEntities(response));
+ dispatch(declineSaleSuccess());
+ dispatch(fetchCurrentUserNotifications());
+ return response;
+ })
+ .catch(e => {
+ dispatch(declineSaleError(storableError(e)));
+ log.error(e, 'reject-sale-failed', {
+ txId: id,
+ transition: propTypes.TX_TRANSITION_DECLINE,
+ });
+ throw e;
+ });
+};
+
+const fetchMessages = (txId, page) => (dispatch, getState, sdk) => {
+ const paging = { page, per_page: MESSAGES_PAGE_SIZE };
+ dispatch(fetchMessagesRequest());
+
+ return sdk.messages
+ .query({ transaction_id: txId, include: ['sender', 'sender.profileImage'], ...paging })
+ .then(response => {
+ const entities = updatedEntities({}, response.data);
+ const messageIds = response.data.data.map(d => d.id);
+ const denormalizedMessages = denormalisedEntities(entities, 'message', messageIds);
+ const { totalItems, totalPages, page: fetchedPage } = response.data.meta;
+ const pagination = { totalItems, totalPages, page: fetchedPage };
+ const totalMessages = getState().TransactionPage.totalMessages;
+
+ // Original fetchMessages call succeeded
+ dispatch(fetchMessagesSuccess(denormalizedMessages, pagination));
+
+ // Check if totalItems has changed between fetched pagination pages
+ // if totalItems has changed, fetch first page again to include new incoming messages.
+ // TODO if there're more than 100 incoming messages,
+ // this should loop through most recent pages instead of fetching just the first one.
+ if (totalItems > totalMessages && page > 1) {
+ dispatch(fetchMessages(txId, 1))
+ .then(() => {
+ // Original fetch was enough as a response for user action,
+ // this just includes new incoming messages
+ })
+ .catch(() => {
+ // Background update, no need to to do anything atm.
+ });
+ }
+ })
+ .catch(e => {
+ dispatch(fetchMessagesError(storableError(e)));
+ throw e;
+ });
+};
+
+export const fetchMoreMessages = txId => (dispatch, getState, sdk) => {
+ const state = getState();
+ const { oldestMessagePageFetched, totalMessagePages } = state.TransactionPage;
+ const hasMoreOldMessages = totalMessagePages > oldestMessagePageFetched;
+
+ // In case there're no more old pages left we default to fetching the current cursor position
+ const nextPage = hasMoreOldMessages ? oldestMessagePageFetched + 1 : oldestMessagePageFetched;
+
+ return dispatch(fetchMessages(txId, nextPage));
+};
+
+export const sendMessage = (txId, message) => (dispatch, getState, sdk) => {
+ dispatch(sendMessageRequest());
+
+ return sdk.messages
+ .send({ transactionId: txId, content: message })
+ .then(response => {
+ const messageId = response.data.data.id;
+
+ // We fetch the first page again to add sent message to the page data
+ // and update possible incoming messages too.
+ // TODO if there're more than 100 incoming messages,
+ // this should loop through most recent pages instead of fetching just the first one.
+ return dispatch(fetchMessages(txId, 1))
+ .then(() => {
+ dispatch(sendMessageSuccess());
+ return messageId;
+ })
+ .catch(() => dispatch(sendMessageSuccess()));
+ })
+ .catch(e => {
+ dispatch(sendMessageError(storableError(e)));
+ // Rethrow so the page can track whether the sending failed, and
+ // keep the message in the form for a retry.
+ throw e;
+ });
+};
+
+const REVIEW_TX_INCLUDES = ['reviews', 'reviews.author', 'reviews.subject'];
+
+// If other party (customer) has already sent a review, we need to make transition to
+// TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND
+const sendReviewAsSecond = (id, params, dispatch, sdk) => {
+ const transition = propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_SECOND;
+ const include = REVIEW_TX_INCLUDES;
+
+ return sdk.transactions
+ .transition({ id, transition, params }, { expand: true, include })
+ .then(response => {
+ dispatch(addMarketplaceEntities(response));
+ dispatch(sendReviewSuccess());
+ return response;
+ })
+ .catch(e => {
+ dispatch(sendReviewError(storableError(e)));
+
+ // Rethrow so the page can track whether the sending failed, and
+ // keep the message in the form for a retry.
+ throw e;
+ });
+};
+
+// If other party (customer) has not yet sent a review, we need to make transition to
+// TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST
+// However, the other party might have made the review after previous data synch point.
+// So, error is likely to happen and then we must try another state transition
+// by calling sendReviewAsSecond().
+const sendReviewAsFirst = (id, params, dispatch, sdk) => {
+ const transition = propTypes.TX_TRANSITION_REVIEW_BY_PROVIDER_FIRST;
+ const include = REVIEW_TX_INCLUDES;
+
+ return sdk.transactions
+ .transition({ id, transition, params }, { expand: true, include })
+ .then(response => {
+ dispatch(addMarketplaceEntities(response));
+ dispatch(sendReviewSuccess());
+ return response;
+ })
+ .catch(e => {
+ // If transaction transition is invalid, lets try another endpoint.
+ if (isTransactionsTransitionInvalidTransition(e)) {
+ return sendReviewAsSecond(id, params, dispatch, sdk);
+ } else {
+ dispatch(sendReviewError(storableError(e)));
+
+ // Rethrow so the page can track whether the sending failed, and
+ // keep the message in the form for a retry.
+ throw e;
+ }
+ });
+};
+
+export const sendReview = (tx, reviewRating, reviewContent) => (dispatch, getState, sdk) => {
+ const params = { reviewRating, reviewContent };
+ const txStateProviderFirst =
+ tx.attributes.lastTransition === propTypes.TX_TRANSITION_REVIEW_BY_CUSTOMER_FIRST;
+
+ dispatch(sendReviewRequest());
+
+ return txStateProviderFirst
+ ? sendReviewAsSecond(tx.id, params, dispatch, sdk)
+ : sendReviewAsFirst(tx.id, params, dispatch, sdk);
+};
+
+// loadData is a collection of async calls that need to be made
+// before page has all the info it needs to render itself
+export const loadData = params => dispatch => {
+ const txId = new types.UUID(params.id);
+
+ // Clear the send error since the message form is emptied as well.
+ dispatch(setInitialValues({ sendMessageError: null, sendReviewError: null }));
+
+ // Sale / order (i.e. transaction entity in API)
+ return Promise.all([dispatch(fetchTransaction(txId)), dispatch(fetchMessages(txId, 1))]);
+};
diff --git a/src/containers/TransactionPage/TransactionPage.js b/src/containers/TransactionPage/TransactionPage.js
new file mode 100644
index 00000000..b1e9db68
--- /dev/null
+++ b/src/containers/TransactionPage/TransactionPage.js
@@ -0,0 +1,313 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { compose } from 'redux';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
+import { reset as resetForm } from 'redux-form';
+import * as propTypes from '../../util/propTypes';
+import { ensureListing, ensureTransaction } from '../../util/data';
+import { getMarketplaceEntities } from '../../ducks/marketplaceData.duck';
+import { isScrollingDisabled, manageDisableScrolling } from '../../ducks/UI.duck';
+import {
+ NamedRedirect,
+ OrderDetailsPanel,
+ SaleDetailsPanel,
+ Page,
+ LayoutSingleColumn,
+ LayoutWrapperTopbar,
+ LayoutWrapperMain,
+ LayoutWrapperFooter,
+ Footer,
+} from '../../components';
+import { TopbarContainer } from '../../containers';
+
+import {
+ acceptSale,
+ declineSale,
+ loadData,
+ setInitialValues,
+ sendMessage,
+ sendReview,
+ fetchMoreMessages,
+} from './TransactionPage.duck';
+import css from './TransactionPage.css';
+
+const PROVIDER = 'provider';
+const CUSTOMER = 'customer';
+
+// TransactionPage handles data loading for Sale and Order views to transaction pages in Inbox.
+export const TransactionPageComponent = props => {
+ const {
+ currentUser,
+ initialMessageFailedToTransaction,
+ fetchMessagesError,
+ fetchMessagesInProgress,
+ totalMessagePages,
+ oldestMessagePageFetched,
+ fetchTransactionError,
+ intl,
+ messages,
+ onManageDisableScrolling,
+ onResetForm,
+ onSendMessage,
+ onSendReview,
+ onShowMoreMessages,
+ params,
+ scrollingDisabled,
+ sendMessageError,
+ sendMessageInProgress,
+ sendReviewError,
+ sendReviewInProgress,
+ transaction,
+ transactionRole,
+ acceptInProgress,
+ acceptSaleError,
+ declineInProgress,
+ declineSaleError,
+ onAcceptSale,
+ onDeclineSale,
+ } = props;
+
+ const currentTransaction = ensureTransaction(transaction);
+ const currentListing = ensureListing(currentTransaction.listing);
+ const listingTitle = currentListing.attributes.title;
+
+ // Redirect users with someone else's direct link to their own inbox/sales or inbox/orders page.
+ const isDataAvailable =
+ currentUser &&
+ currentTransaction.id &&
+ currentTransaction.id.uuid === params.id &&
+ currentTransaction.customer &&
+ currentTransaction.provider &&
+ !fetchTransactionError;
+
+ const isProviderRole = transactionRole === PROVIDER;
+ const isCustomerRole = transactionRole === CUSTOMER;
+ const isOwnSale =
+ isDataAvailable &&
+ isProviderRole &&
+ currentUser.id.uuid === currentTransaction.provider.id.uuid;
+ const isOwnOrder =
+ isDataAvailable &&
+ isCustomerRole &&
+ currentUser.id.uuid === currentTransaction.customer.id.uuid;
+
+ if (isDataAvailable && isProviderRole && !isOwnSale) {
+ // eslint-disable-next-line no-console
+ console.error('Tried to access a sale that was not owned by the current user');
+ return ;
+ } else if (isDataAvailable && isCustomerRole && !isOwnOrder) {
+ // eslint-disable-next-line no-console
+ console.error('Tried to access an order that was not owned by the current user');
+ return ;
+ }
+
+ const detailsClassName = classNames(css.tabContent, css.tabContentVisible);
+
+ const fetchErrorMessage = isCustomerRole
+ ? 'TransactionPage.fetchOrderFailed'
+ : 'TransactionPage.fetchSaleFailed';
+ const loadingMessage = isCustomerRole
+ ? 'TransactionPage.loadingOrderData'
+ : 'TransactionPage.loadingSaleData';
+
+ const loadingOrFailedFetching = fetchTransactionError ? (
+
+
+
+ ) : (
+
+
+
+ );
+
+ const salePanel = isDataAvailable ? (
+
+ ) : null;
+
+ const initialMessageFailed = !!(
+ initialMessageFailedToTransaction &&
+ currentTransaction.id &&
+ initialMessageFailedToTransaction.uuid === currentTransaction.id.uuid
+ );
+
+ const orderPanel = isDataAvailable ? (
+
+ ) : null;
+
+ const saleOrOrderPanel = isDataAvailable && isOwnSale ? salePanel : orderPanel;
+ const panel = isDataAvailable ? saleOrOrderPanel : loadingOrFailedFetching;
+
+ return (
+
+
+
+
+
+
+ {panel}
+
+
+
+
+
+
+ );
+};
+
+TransactionPageComponent.defaultProps = {
+ currentUser: null,
+ fetchTransactionError: null,
+ acceptSaleError: null,
+ declineSaleError: null,
+ transaction: null,
+ fetchMessagesError: null,
+ initialMessageFailedToTransaction: null,
+ sendMessageError: null,
+};
+
+const { bool, func, oneOf, shape, string, arrayOf, number } = PropTypes;
+
+TransactionPageComponent.propTypes = {
+ params: shape({ id: string }).isRequired,
+ transactionRole: oneOf([PROVIDER, CUSTOMER]).isRequired,
+ currentUser: propTypes.currentUser,
+ fetchTransactionError: propTypes.error,
+ acceptSaleError: propTypes.error,
+ declineSaleError: propTypes.error,
+ acceptInProgress: bool.isRequired,
+ declineInProgress: bool.isRequired,
+ onAcceptSale: func.isRequired,
+ onDeclineSale: func.isRequired,
+ scrollingDisabled: bool.isRequired,
+ transaction: propTypes.transaction,
+ fetchMessagesError: propTypes.error,
+ totalMessagePages: number.isRequired,
+ oldestMessagePageFetched: number.isRequired,
+ messages: arrayOf(propTypes.message).isRequired,
+ initialMessageFailedToTransaction: propTypes.uuid,
+ sendMessageInProgress: bool.isRequired,
+ sendMessageError: propTypes.error,
+ onShowMoreMessages: func.isRequired,
+ onSendMessage: func.isRequired,
+ onResetForm: func.isRequired,
+
+ // from injectIntl
+ intl: intlShape.isRequired,
+};
+
+const mapStateToProps = state => {
+ const {
+ fetchTransactionError,
+ acceptSaleError,
+ declineSaleError,
+ acceptInProgress,
+ declineInProgress,
+ transactionRef,
+ fetchMessagesInProgress,
+ fetchMessagesError,
+ totalMessagePages,
+ oldestMessagePageFetched,
+ messages,
+ initialMessageFailedToTransaction,
+ sendMessageInProgress,
+ sendMessageError,
+ sendReviewInProgress,
+ sendReviewError,
+ } = state.TransactionPage;
+ const { currentUser } = state.user;
+
+ const transactions = getMarketplaceEntities(state, transactionRef ? [transactionRef] : []);
+ const transaction = transactions.length > 0 ? transactions[0] : null;
+
+ return {
+ currentUser,
+ fetchTransactionError,
+ acceptSaleError,
+ declineSaleError,
+ acceptInProgress,
+ declineInProgress,
+ scrollingDisabled: isScrollingDisabled(state),
+ transaction,
+ fetchMessagesInProgress,
+ fetchMessagesError,
+ totalMessagePages,
+ oldestMessagePageFetched,
+ messages,
+ initialMessageFailedToTransaction,
+ sendMessageInProgress,
+ sendMessageError,
+ sendReviewInProgress,
+ sendReviewError,
+ };
+};
+
+const mapDispatchToProps = dispatch => {
+ return {
+ onAcceptSale: transactionId => dispatch(acceptSale(transactionId)),
+ onDeclineSale: transactionId => dispatch(declineSale(transactionId)),
+ onShowMoreMessages: txId => dispatch(fetchMoreMessages(txId)),
+ onSendMessage: (txId, message) => dispatch(sendMessage(txId, message)),
+ onResetForm: formName => dispatch(resetForm(formName)),
+ onManageDisableScrolling: (componentId, disableScrolling) =>
+ dispatch(manageDisableScrolling(componentId, disableScrolling)),
+ onSendReview: (tx, reviewRating, reviewContent) =>
+ dispatch(sendReview(tx, reviewRating, reviewContent)),
+ };
+};
+
+const TransactionPage = compose(connect(mapStateToProps, mapDispatchToProps), injectIntl)(
+ TransactionPageComponent
+);
+
+TransactionPage.loadData = loadData;
+TransactionPage.setInitialValues = setInitialValues;
+
+export default TransactionPage;
diff --git a/src/containers/TransactionPage/TransactionPage.test.js b/src/containers/TransactionPage/TransactionPage.test.js
new file mode 100644
index 00000000..bb481464
--- /dev/null
+++ b/src/containers/TransactionPage/TransactionPage.test.js
@@ -0,0 +1,101 @@
+import React from 'react';
+import {
+ createBooking,
+ createCurrentUser,
+ createListing,
+ createTransaction,
+ createUser,
+ fakeIntl,
+} from '../../util/test-data';
+import { renderShallow } from '../../util/test-helpers';
+import { TX_TRANSITION_PREAUTHORIZE } from '../../util/propTypes';
+import { TransactionPageComponent } from './TransactionPage';
+
+const noop = () => null;
+
+describe('TransactionPage - Sale', () => {
+ it('matches snapshot', () => {
+ const txId = 'tx-sale-1';
+ const transaction = createTransaction({
+ id: txId,
+ lastTransition: TX_TRANSITION_PREAUTHORIZE,
+ 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'),
+ provider: createUser('provider1'),
+ });
+
+ const props = {
+ params: { id: txId },
+ transactionRole: 'provider',
+
+ currentUser: createCurrentUser('provider1'),
+ acceptInProgress: false,
+ declineInProgress: false,
+ onAcceptSale: noop,
+ onDeclineSale: noop,
+ scrollingDisabled: false,
+ transaction,
+ totalMessages: 0,
+ totalMessagePages: 0,
+ oldestMessagePageFetched: 0,
+ messages: [],
+ sendMessageInProgress: false,
+ onShowMoreMessages: noop,
+ onSendMessage: noop,
+ onResetForm: noop,
+ intl: fakeIntl,
+ };
+
+ const tree = renderShallow();
+ expect(tree).toMatchSnapshot();
+ });
+});
+
+
+describe('TransactionPage - Order', () => {
+ it('matches snapshot', () => {
+ const txId = 'tx-order-1';
+ const transaction = createTransaction({
+ id: txId,
+ lastTransition: TX_TRANSITION_PREAUTHORIZE,
+ 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'),
+ provider: createUser('provider1'),
+ });
+
+ const props = {
+ params: { id: txId },
+ transactionRole: 'customer',
+
+ currentUser: createCurrentUser('customer1'),
+ totalMessages: 0,
+ totalMessagePages: 0,
+ oldestMessagePageFetched: 0,
+ messages: [],
+ fetchMessagesInProgress: false,
+ sendMessageInProgress: false,
+ scrollingDisabled: false,
+ transaction,
+ onShowMoreMessages: noop,
+ onSendMessage: noop,
+ onResetForm: noop,
+ intl: fakeIntl,
+
+ acceptInProgress: false,
+ declineInProgress: false,
+ onAcceptSale: noop,
+ onDeclineSale: noop,
+ };
+
+ const tree = renderShallow();
+ expect(tree).toMatchSnapshot();
+ });
+});
diff --git a/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap b/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap
new file mode 100644
index 00000000..c3372ba3
--- /dev/null
+++ b/src/containers/TransactionPage/__snapshots__/TransactionPage.test.js.snap
@@ -0,0 +1,373 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TransactionPage - Order matches snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`TransactionPage - Sale matches snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/containers/index.js b/src/containers/index.js
index b2707ce1..1d98501c 100644
--- a/src/containers/index.js
+++ b/src/containers/index.js
@@ -46,3 +46,4 @@ export { default as StyleguidePage } from './StyleguidePage/StyleguidePage';
export { default as TermsOfServicePage } from './TermsOfServicePage/TermsOfServicePage';
export { default as TopbarContainer } from './TopbarContainer/TopbarContainer';
export { default as TopbarSearchForm } from './TopbarSearchForm/TopbarSearchForm';
+export { default as TransactionPage } from './TransactionPage/TransactionPage';
diff --git a/src/containers/reducers.js b/src/containers/reducers.js
index 4e8faa0a..0542b130 100644
--- a/src/containers/reducers.js
+++ b/src/containers/reducers.js
@@ -17,6 +17,7 @@ import ProfilePage from './ProfilePage/ProfilePage.duck';
import ProfileSettingsPage from './ProfileSettingsPage/ProfileSettingsPage.duck';
import SalePage from './SalePage/SalePage.duck';
import SearchPage from './SearchPage/SearchPage.duck';
+import TransactionPage from './TransactionPage/TransactionPage.duck';
export {
CheckoutPage,
@@ -33,4 +34,5 @@ export {
ProfileSettingsPage,
SalePage,
SearchPage,
+ TransactionPage,
};
diff --git a/src/routeConfiguration.js b/src/routeConfiguration.js
index c94f42ab..80d8b179 100644
--- a/src/routeConfiguration.js
+++ b/src/routeConfiguration.js
@@ -11,7 +11,6 @@ import {
ListingPage,
ManageListingsPage,
NotFoundPage,
- OrderPage,
PasswordChangePage,
PasswordRecoveryPage,
PasswordResetPage,
@@ -19,10 +18,10 @@ import {
PrivacyPolicyPage,
ProfilePage,
ProfileSettingsPage,
- SalePage,
SearchPage,
StyleguidePage,
TermsOfServicePage,
+ TransactionPage,
} from './containers';
// routeConfiguration needs to initialize containers first
@@ -187,9 +186,9 @@ const routeConfiguration = () => {
name: 'OrderDetailsPage',
auth: true,
authPage: 'LoginPage',
- component: props => ,
- loadData: OrderPage.loadData,
- setInitialValues: OrderPage.setInitialValues,
+ component: props => ,
+ loadData: TransactionPage.loadData,
+ setInitialValues: TransactionPage.setInitialValues,
},
{
path: '/sale/:id',
@@ -203,8 +202,8 @@ const routeConfiguration = () => {
name: 'SaleDetailsPage',
auth: true,
authPage: 'LoginPage',
- component: props => ,
- loadData: SalePage.loadData,
+ component: props => ,
+ loadData: TransactionPage.loadData,
},
{
path: '/listings',
diff --git a/src/translations/en.json b/src/translations/en.json
index 174739bf..5d9d4da1 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -455,11 +455,13 @@
"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}.",
@@ -622,6 +624,11 @@
"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.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}",
"UserCard.contactUser": "Contact",
"UserCard.heading": "Hello, I'm {name}.",
"UserCard.showFullBioLink": "more",