TransactionPage

This commit is contained in:
Vesa Luusua 2017-12-12 14:34:40 +02:00
parent a861954b9f
commit cbc1fcc05c
12 changed files with 1284 additions and 14 deletions

View file

@ -264,14 +264,14 @@ export class SaleDetailsPanelComponent extends Component {
disabled={buttonsDisabled}
onClick={() => onDeclineSale(currentTransaction.id)}
>
<FormattedMessage id="SalePage.declineButton" />
<FormattedMessage id="SaleDetailsPanel.declineButton" />
</SecondaryButton>
<PrimaryButton
inProgress={acceptInProgress}
disabled={buttonsDisabled}
onClick={() => onAcceptSale(currentTransaction.id)}
>
<FormattedMessage id="SalePage.acceptButton" />
<FormattedMessage id="SaleDetailsPanel.acceptButton" />
</PrimaryButton>
</div>
</div>

View file

@ -3441,7 +3441,7 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = `
onClick={[Function]}
>
<FormattedMessage
id="SalePage.declineButton"
id="SaleDetailsPanel.declineButton"
values={Object {}}
/>
</SecondaryButton>
@ -3451,7 +3451,7 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = `
onClick={[Function]}
>
<FormattedMessage
id="SalePage.acceptButton"
id="SaleDetailsPanel.acceptButton"
values={Object {}}
/>
</PrimaryButton>
@ -3626,7 +3626,7 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = `
onClick={[Function]}
>
<FormattedMessage
id="SalePage.declineButton"
id="SaleDetailsPanel.declineButton"
values={Object {}}
/>
</SecondaryButton>
@ -3636,7 +3636,7 @@ exports[`SaleDetailsPanel preauthorized matches snapshot 1`] = `
onClick={[Function]}
>
<FormattedMessage
id="SalePage.acceptButton"
id="SaleDetailsPanel.acceptButton"
values={Object {}}
/>
</PrimaryButton>

View file

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

View file

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

View file

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

View file

@ -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 <NamedRedirect name="InboxPage" params={{ tab: 'sales' }} />;
} 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 <NamedRedirect name="InboxPage" params={{ tab: 'orders' }} />;
}
const detailsClassName = classNames(css.tabContent, css.tabContentVisible);
const fetchErrorMessage = isCustomerRole
? 'TransactionPage.fetchOrderFailed'
: 'TransactionPage.fetchSaleFailed';
const loadingMessage = isCustomerRole
? 'TransactionPage.loadingOrderData'
: 'TransactionPage.loadingSaleData';
const loadingOrFailedFetching = fetchTransactionError ? (
<p className={css.error}>
<FormattedMessage id={`${fetchErrorMessage}`} />
</p>
) : (
<p className={css.loading}>
<FormattedMessage id={`${loadingMessage}`} />
</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
className={detailsClassName}
currentUser={currentUser}
transaction={currentTransaction}
fetchMessagesInProgress={fetchMessagesInProgress}
totalMessagePages={totalMessagePages}
oldestMessagePageFetched={oldestMessagePageFetched}
messages={messages}
initialMessageFailed={initialMessageFailed}
fetchMessagesError={fetchMessagesError}
sendMessageInProgress={sendMessageInProgress}
sendMessageError={sendMessageError}
sendReviewInProgress={sendReviewInProgress}
sendReviewError={sendReviewError}
onManageDisableScrolling={onManageDisableScrolling}
onShowMoreMessages={onShowMoreMessages}
onSendMessage={onSendMessage}
onSendReview={onSendReview}
onResetForm={onResetForm}
/>
) : null;
const saleOrOrderPanel = isDataAvailable && isOwnSale ? salePanel : orderPanel;
const panel = isDataAvailable ? saleOrOrderPanel : loadingOrFailedFetching;
return (
<Page
title={intl.formatMessage({ id: 'TransactionPage.title' }, { title: listingTitle })}
scrollingDisabled={scrollingDisabled}
>
<LayoutSingleColumn>
<LayoutWrapperTopbar>
<TopbarContainer />
</LayoutWrapperTopbar>
<LayoutWrapperMain>
<div className={css.root}>{panel}</div>
</LayoutWrapperMain>
<LayoutWrapperFooter className={css.footer}>
<Footer />
</LayoutWrapperFooter>
</LayoutSingleColumn>
</Page>
);
};
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;

View file

@ -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(<TransactionPageComponent {...props} />);
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(<TransactionPageComponent {...props} />);
expect(tree).toMatchSnapshot();
});
});

View file

@ -0,0 +1,373 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TransactionPage - Order matches snapshot 1`] = `
<Page
scrollingDisabled={false}
title="TransactionPage.title"
>
<LayoutSingleColumn
className={null}
rootClassName={null}
>
<LayoutWrapperTopbar
className={null}
rootClassName={null}
>
<withRouter(Connect(TopbarContainerComponent)) />
</LayoutWrapperTopbar>
<LayoutWrapperMain
className={null}
rootClassName={null}
>
<div>
<InjectIntl(OrderDetailsPanelComponent)
className=""
currentUser={
Object {
"attributes": Object {
"banned": false,
"email": "customer1@example.com",
"emailVerified": true,
"profile": Object {
"abbreviatedName": "customer1 abbreviated name",
"displayName": "customer1 display name",
"firstName": "customer1 first name",
"lastName": "customer1 last name",
},
"stripeConnected": true,
},
"id": UUID {
"uuid": "customer1",
},
"type": "currentUser",
}
}
fetchMessagesError={null}
fetchMessagesInProgress={false}
initialMessageFailed={false}
messages={Array []}
oldestMessagePageFetched={0}
onResetForm={[Function]}
onSendMessage={[Function]}
onShowMoreMessages={[Function]}
sendMessageError={null}
sendMessageInProgress={false}
totalMessagePages={0}
transaction={
Object {
"attributes": Object {
"createdAt": 2017-05-01T00:00:00.000Z,
"lastTransition": "transition/preauthorize",
"lastTransitionedAt": 2017-06-01T00:00:00.000Z,
"lineItems": Array [
Object {
"code": "line-item/night",
"lineTotal": Money {
"amount": 1000,
"currency": "USD",
},
"quantity": "3",
"reversal": false,
"unitPrice": Money {
"amount": 333.3333333333333,
"currency": "USD",
},
},
Object {
"code": "line-item/provider-commission",
"lineTotal": Money {
"amount": -100,
"currency": "USD",
},
"reversal": false,
"unitPrice": Money {
"amount": -100,
"currency": "USD",
},
},
],
"payinTotal": Money {
"amount": 1000,
"currency": "USD",
},
"payoutTotal": Money {
"amount": 900,
"currency": "USD",
},
"transitions": Array [
Object {
"at": 2017-05-01T00:00:00.000Z,
"by": "customer",
"transition": "transition/preauthorize",
},
Object {
"at": 2017-06-01T00:00:00.000Z,
"by": "provider",
"transition": "transition/accept",
},
],
},
"booking": Object {
"attributes": Object {
"end": 2017-06-13T00:00:00.000Z,
"start": 2017-06-10T00:00:00.000Z,
},
"id": UUID {
"uuid": "booking1",
},
"type": "booking",
},
"customer": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "customer1 display name",
},
},
"id": UUID {
"uuid": "customer1",
},
"type": "user",
},
"id": UUID {
"uuid": "tx-order-1",
},
"listing": Object {
"attributes": Object {
"address": "listing1 address",
"closed": false,
"deleted": false,
"description": "listing1 description",
"geolocation": LatLng {
"lat": 40,
"lng": 60,
},
"price": Money {
"amount": 5500,
"currency": "USD",
},
"title": "listing1 title",
},
"id": UUID {
"uuid": "listing1",
},
"type": "listing",
},
"provider": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "provider1 display name",
},
},
"id": UUID {
"uuid": "provider1",
},
"type": "user",
},
"reviews": Array [],
"type": "transaction",
}
}
/>
</div>
</LayoutWrapperMain>
<LayoutWrapperFooter
className={null}
rootClassName={null}
>
<InjectIntl(Footer) />
</LayoutWrapperFooter>
</LayoutSingleColumn>
</Page>
`;
exports[`TransactionPage - Sale matches snapshot 1`] = `
<Page
scrollingDisabled={false}
title="TransactionPage.title"
>
<LayoutSingleColumn
className={null}
rootClassName={null}
>
<LayoutWrapperTopbar
className={null}
rootClassName={null}
>
<withRouter(Connect(TopbarContainerComponent)) />
</LayoutWrapperTopbar>
<LayoutWrapperMain
className={null}
rootClassName={null}
>
<div>
<InjectIntl(SaleDetailsPanelComponent)
acceptInProgress={false}
acceptSaleError={null}
className=""
currentUser={
Object {
"attributes": Object {
"banned": false,
"email": "provider1@example.com",
"emailVerified": true,
"profile": Object {
"abbreviatedName": "provider1 abbreviated name",
"displayName": "provider1 display name",
"firstName": "provider1 first name",
"lastName": "provider1 last name",
},
"stripeConnected": true,
},
"id": UUID {
"uuid": "provider1",
},
"type": "currentUser",
}
}
declineInProgress={false}
declineSaleError={null}
fetchMessagesError={null}
messages={Array []}
oldestMessagePageFetched={0}
onAcceptSale={[Function]}
onDeclineSale={[Function]}
onResetForm={[Function]}
onSendMessage={[Function]}
onShowMoreMessages={[Function]}
sendMessageError={null}
sendMessageInProgress={false}
totalMessagePages={0}
transaction={
Object {
"attributes": Object {
"createdAt": 2017-05-01T00:00:00.000Z,
"lastTransition": "transition/preauthorize",
"lastTransitionedAt": 2017-06-01T00:00:00.000Z,
"lineItems": Array [
Object {
"code": "line-item/night",
"lineTotal": Money {
"amount": 1000,
"currency": "USD",
},
"quantity": "3",
"reversal": false,
"unitPrice": Money {
"amount": 333.3333333333333,
"currency": "USD",
},
},
Object {
"code": "line-item/provider-commission",
"lineTotal": Money {
"amount": -100,
"currency": "USD",
},
"reversal": false,
"unitPrice": Money {
"amount": -100,
"currency": "USD",
},
},
],
"payinTotal": Money {
"amount": 1000,
"currency": "USD",
},
"payoutTotal": Money {
"amount": 900,
"currency": "USD",
},
"transitions": Array [
Object {
"at": 2017-05-01T00:00:00.000Z,
"by": "customer",
"transition": "transition/preauthorize",
},
Object {
"at": 2017-06-01T00:00:00.000Z,
"by": "provider",
"transition": "transition/accept",
},
],
},
"booking": Object {
"attributes": Object {
"end": 2017-06-13T00:00:00.000Z,
"start": 2017-06-10T00:00:00.000Z,
},
"id": UUID {
"uuid": "booking1",
},
"type": "booking",
},
"customer": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "customer1 display name",
},
},
"id": UUID {
"uuid": "customer1",
},
"type": "user",
},
"id": UUID {
"uuid": "tx-sale-1",
},
"listing": Object {
"attributes": Object {
"address": "listing1 address",
"closed": false,
"deleted": false,
"description": "listing1 description",
"geolocation": LatLng {
"lat": 40,
"lng": 60,
},
"price": Money {
"amount": 5500,
"currency": "USD",
},
"title": "listing1 title",
},
"id": UUID {
"uuid": "listing1",
},
"type": "listing",
},
"provider": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "provider1 display name",
},
},
"id": UUID {
"uuid": "provider1",
},
"type": "user",
},
"reviews": Array [],
"type": "transaction",
}
}
/>
</div>
</LayoutWrapperMain>
<LayoutWrapperFooter
className={null}
rootClassName={null}
>
<InjectIntl(Footer) />
</LayoutWrapperFooter>
</LayoutSingleColumn>
</Page>
`;

View file

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

View file

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

View file

@ -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 => <OrderPage {...props} tab="details" />,
loadData: OrderPage.loadData,
setInitialValues: OrderPage.setInitialValues,
component: props => <TransactionPage {...props} transactionRole="customer" />,
loadData: TransactionPage.loadData,
setInitialValues: TransactionPage.setInitialValues,
},
{
path: '/sale/:id',
@ -203,8 +202,8 @@ const routeConfiguration = () => {
name: 'SaleDetailsPage',
auth: true,
authPage: 'LoginPage',
component: props => <SalePage {...props} tab="details" />,
loadData: SalePage.loadData,
component: props => <TransactionPage {...props} transactionRole="provider" />,
loadData: TransactionPage.loadData,
},
{
path: '/listings',

View file

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