Send messages from Order page

This commit is contained in:
Kimmo Puputti 2017-11-14 16:24:13 +02:00
parent ff108ca489
commit a3ca212a24
8 changed files with 263 additions and 156 deletions

View file

@ -8,4 +8,5 @@
height: 28px;
stroke: var(--marketplaceColor);
stroke-width: 3px;
}

View file

@ -11,7 +11,6 @@ const IconSpinner = props => {
<svg
className={classes}
viewBox="0 0 30 30"
strokeWidth="3"
preserveAspectRatio="xMidYMid"
xmlns="http://www.w3.org/2000/svg"
>

View file

@ -63,7 +63,7 @@ export const MessagesComponent = props => {
return (
<ul className={classes}>
{messages.map(m => (
<li key={m.id.uuid} className={css.messageItem}>
<li id={`msg-${m.id.uuid}`} key={m.id.uuid} className={css.messageItem}>
{msg(m)}
</li>
))}

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
@ -78,141 +78,136 @@ const orderMessage = (transaction, providerName) => {
return null;
};
export const OrderDetailsPanelComponent = props => {
const {
rootClassName,
className,
currentUser,
transaction,
messages,
initialMessageFailed,
fetchMessagesError,
intl,
} = 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 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;
export class OrderDetailsPanelComponent extends Component {
constructor(props) {
super(props);
this.state = { setInitialEmptyMessage: false };
}
render() {
const {
rootClassName,
className,
currentUser,
transaction,
messages,
initialMessageFailed,
fetchMessagesError,
sendMessageInProgress,
sendMessageError,
onSendMessage,
onResetForm,
intl,
} = this.props;
const listingTitle = currentListing.attributes.deleted
? deletedListingTitle
: currentListing.attributes.title;
const currentTransaction = ensureTransaction(transaction);
const currentListing = ensureListing(currentTransaction.listing);
const currentProvider = ensureUser(currentTransaction.provider);
const currentCustomer = ensureUser(currentTransaction.customer);
const bookingInfo = breakdown(currentTransaction);
const orderHeading = orderTitle(currentTransaction, listingLink, customerDisplayName);
const orderInfoText = listingDeleted
? orderMessageDeletedListing
: orderMessage(currentTransaction, authorDisplayName);
const showInfoMessage = !!orderInfoText;
const listingLoaded = !!currentListing.id;
const listingDeleted = listingLoaded && currentListing.attributes.deleted;
const firstImage =
currentListing.images && currentListing.images.length > 0 ? currentListing.images[0] : null;
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 messagesContainerClasses = classNames(css.messagesContainer, {
[css.messagesContainerWithInfoAbove]: showInfoMessage,
});
const showMessages = messages.length > 0 || initialMessageFailed || fetchMessagesError;
const messagesContainer = showMessages ? (
<div className={messagesContainerClasses}>
<h3 className={css.messagesHeading}>
<FormattedMessage id="OrderDetailsPanel.messagesHeading" />
</h3>
{initialMessageFailed ? (
<p className={css.error}>
<FormattedMessage id="OrderDetailsPanel.initialMessageFailed" />
</p>
) : null}
{fetchMessagesError ? (
<p className={css.error}>
<FormattedMessage id="OrderDetailsPanel.messageLoadingFailed" />
</p>
) : null}
<Messages className={css.messages} messages={messages} currentUser={currentUser} />
</div>
) : null;
const authorDisplayName = userDisplayName(currentProvider, bannedUserDisplayName);
const customerDisplayName = userDisplayName(currentCustomer, bannedUserDisplayName);
const sendMessagePlaceholder = intl.formatMessage(
{ id: 'OrderDetailsPanel.sendMessagePlaceholder' },
{ name: 'Juho' }
);
const sendMessageForm = (
<SendMessageForm
rootClassName={css.sendMessageForm}
messagePlaceholder={sendMessagePlaceholder}
/>
);
let listingLink = null;
const classes = classNames(rootClassName || css.root, className);
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;
}
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>
{messagesContainer}
{sendMessageForm}
</div>
<div className={css.bookingBreakdownContainer}>
<div className={css.breakdownDesktop}>
<div className={css.breakdownImageWrapper}>
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 messagesContainerClasses = classNames(css.messagesContainer, {
[css.messagesContainerWithInfoAbove]: showInfoMessage,
});
const showMessages = messages.length > 0 || initialMessageFailed || fetchMessagesError;
const messagesContainer = showMessages ? (
<div className={messagesContainerClasses}>
<h3 className={css.messagesHeading}>
<FormattedMessage id="OrderDetailsPanel.messagesHeading" />
</h3>
{initialMessageFailed ? (
<p className={css.error}>
<FormattedMessage id="OrderDetailsPanel.initialMessageFailed" />
</p>
) : null}
{fetchMessagesError ? (
<p className={css.error}>
<FormattedMessage id="OrderDetailsPanel.messageLoadingFailed" />
</p>
) : null}
<Messages className={css.messages} messages={messages} currentUser={currentUser} />
</div>
) : null;
const sendMessagePlaceholder = intl.formatMessage(
{ id: 'OrderDetailsPanel.sendMessagePlaceholder' },
{ name: 'Juho' }
);
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 handleMessageSubmit = values => {
return onSendMessage(currentTransaction.id, values.message)
.then(messageId => {
onResetForm(sendMessageFormName);
scrollToMessage(messageId);
})
.catch(e => {
console.error(e);
});
};
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}
@ -222,40 +217,80 @@ export const OrderDetailsPanelComponent = props => {
{ name: 'landscape-crop', size: '400w' },
{ name: 'landscape-crop2x', size: '800w' },
]}
sizes="100%"
sizes="100vw"
/>
</div>
</div>
<div className={css.avatarWrapper}>
<div className={classNames(css.avatarWrapper, css.avatarMobile)}>
<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>
<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>
{messagesContainer}
<SendMessageForm
form={sendMessageFormName}
rootClassName={css.sendMessageForm}
messagePlaceholder={sendMessagePlaceholder}
inProgress={sendMessageInProgress}
sendMessageError={sendMessageError}
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>
<h3 className={css.bookingBreakdownTitle}>
<FormattedMessage id="OrderDetailsPanel.bookingBreakdownTitle" />
</h3>
{bookingInfo}
</div>
</div>
</div>
</div>
);
};
);
}
}
OrderDetailsPanelComponent.defaultProps = {
rootClassName: null,
className: null,
fetchMessagesError: null,
sendMessageError: null,
};
const { string, arrayOf, bool } = PropTypes;
const { string, arrayOf, bool, func } = PropTypes;
OrderDetailsPanelComponent.propTypes = {
rootClassName: string,
@ -266,6 +301,10 @@ OrderDetailsPanelComponent.propTypes = {
messages: arrayOf(propTypes.message).isRequired,
initialMessageFailed: bool.isRequired,
fetchMessagesError: propTypes.error,
sendMessageInProgress: bool.isRequired,
sendMessageError: propTypes.error,
onSendMessage: func.isRequired,
onResetForm: func.isRequired,
// from injectIntl
intl: intlShape,

View file

@ -16,6 +16,10 @@ export const FETCH_MESSAGES_REQUEST = 'app/OrderPage/FETCH_MESSAGES_REQUEST';
export const FETCH_MESSAGES_SUCCESS = 'app/OrderPage/FETCH_MESSAGES_SUCCESS';
export const FETCH_MESSAGES_ERROR = 'app/OrderPage/FETCH_MESSAGES_ERROR';
export const SEND_MESSAGE_REQUEST = 'app/OrderPage/SEND_MESSAGE_REQUEST';
export const SEND_MESSAGE_SUCCESS = 'app/OrderPage/SEND_MESSAGE_SUCCESS';
export const SEND_MESSAGE_ERROR = 'app/OrderPage/SEND_MESSAGE_ERROR';
// ================ Reducer ================ //
const initialState = {
@ -26,6 +30,8 @@ const initialState = {
fetchMessagesError: null,
messages: [],
messageSendingFailedToTransaction: null,
sendMessageInProgress: false,
sendMessageError: null,
};
export default function checkoutPageReducer(state = initialState, action = {}) {
@ -43,6 +49,7 @@ export default function checkoutPageReducer(state = initialState, action = {}) {
case FETCH_ORDER_ERROR:
console.error(payload); // eslint-disable-line
return { ...state, fetchOrderInProgress: false, fetchOrderError: payload };
case FETCH_MESSAGES_REQUEST:
return { ...state, fetchMessagesInProgress: true, fetchMessagesError: null };
case FETCH_MESSAGES_SUCCESS:
@ -50,6 +57,13 @@ export default function checkoutPageReducer(state = initialState, action = {}) {
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 };
default:
return state;
}
@ -70,6 +84,10 @@ const fetchMessagesRequest = () => ({ type: FETCH_MESSAGES_REQUEST });
const fetchMessagesSuccess = messages => ({ type: FETCH_MESSAGES_SUCCESS, payload: messages });
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 });
// ================ Thunks ================ //
const listingRelationship = txResponse => {
@ -130,7 +148,7 @@ export const fetchMessages = txId => (dispatch, getState, sdk) => {
dispatch(fetchMessagesSuccess(denormalized));
})
.catch(e => {
dispatch(fetchMessagesError(e));
dispatch(fetchMessagesError(storableError(e)));
throw e;
});
};
@ -140,6 +158,32 @@ export const fetchMessages = txId => (dispatch, getState, sdk) => {
export const loadData = params => dispatch => {
const orderId = new types.UUID(params.id);
// Clear the send error since the message form is emptied as well.
dispatch(setInitialValues({ sendMessageError: null }));
// Order (i.e. transaction entity in API, but from buyers perspective) contains order details
return Promise.all([dispatch(fetchOrder(orderId)), dispatch(fetchMessages(orderId))]);
};
export const sendMessage = (orderId, message) => (dispatch, getState, sdk) => {
console.log('sendMessage:', orderId, message);
dispatch(sendMessageRequest());
return sdk.messages
.send({ transactionId: orderId, content: message })
.then(response => {
const messageId = response.data.data.id;
return dispatch(fetchMessages(orderId))
.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;
});
};

View file

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { reset as resetForm } from 'redux-form';
import classNames from 'classnames';
import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
import * as propTypes from '../../util/propTypes';
@ -20,7 +21,7 @@ import {
} from '../../components';
import { TopbarContainer } from '../../containers';
import { loadData, setInitialValues } from './OrderPage.duck';
import { loadData, setInitialValues, sendMessage } from './OrderPage.duck';
import css from './OrderPage.css';
// OrderPage handles data loading
@ -32,6 +33,10 @@ export const OrderPageComponent = props => {
fetchMessagesError,
messages,
messageSendingFailedToTransaction,
sendMessageInProgress,
sendMessageError,
onSendMessage,
onResetForm,
intl,
params,
scrollingDisabled,
@ -84,6 +89,10 @@ export const OrderPageComponent = props => {
messages={messages}
initialMessageFailed={initialMessageFailed}
fetchMessagesError={fetchMessagesError}
sendMessageInProgress={sendMessageInProgress}
sendMessageError={sendMessageError}
onSendMessage={onSendMessage}
onResetForm={onResetForm}
/>
) : (
loadingOrFaildFetching
@ -112,10 +121,11 @@ OrderPageComponent.defaultProps = {
fetchOrderError: null,
fetchMessagesError: null,
messageSendingFailedToTransaction: null,
sendMessageError: null,
transaction: null,
};
const { bool, oneOf, shape, string, array } = PropTypes;
const { bool, oneOf, shape, string, array, func } = PropTypes;
OrderPageComponent.propTypes = {
params: shape({ id: string }).isRequired,
@ -126,8 +136,12 @@ OrderPageComponent.propTypes = {
fetchMessagesError: propTypes.error,
messages: array.isRequired,
messageSendingFailedToTransaction: propTypes.uuid,
sendMessageInProgress: bool.isRequired,
sendMessageError: propTypes.error,
scrollingDisabled: bool.isRequired,
transaction: propTypes.transaction,
onSendMessage: func.isRequired,
onResetForm: func.isRequired,
// from injectIntl
intl: intlShape.isRequired,
@ -141,6 +155,8 @@ const mapStateToProps = state => {
fetchMessagesError,
messages,
messageSendingFailedToTransaction,
sendMessageInProgress,
sendMessageError,
} = state.OrderPage;
const transactions = getMarketplaceEntities(state, transactionRef ? [transactionRef] : []);
const transaction = transactions.length > 0 ? transactions[0] : null;
@ -151,12 +167,21 @@ const mapStateToProps = state => {
fetchMessagesError,
messages,
messageSendingFailedToTransaction,
sendMessageInProgress,
sendMessageError,
scrollingDisabled: isScrollingDisabled(state),
transaction,
};
};
const OrderPage = compose(connect(mapStateToProps), injectIntl)(OrderPageComponent);
const mapDispatchToProps = dispatch => ({
onSendMessage: (orderId, message) => dispatch(sendMessage(orderId, message)),
onResetForm: formName => dispatch(resetForm(formName)),
});
const OrderPage = compose(connect(mapStateToProps, mapDispatchToProps), injectIntl)(
OrderPageComponent
);
OrderPage.setInitialValues = setInitialValues;
OrderPage.loadData = loadData;

View file

@ -100,8 +100,6 @@ class SendMessageFormComponent extends Component {
invalid,
} = this.props;
console.log({ sendMessageError });
const classes = classNames(rootClassName || css.root, className);
const submitInProgress = submitting || inProgress;
const submitDisabled = invalid || submitInProgress;

View file

@ -62,12 +62,13 @@ export const createResourceLocatorString = (
routeName,
routes,
pathParams = {},
searchParams = {}
searchParams = {},
hash = ''
) => {
const searchQuery = stringify(searchParams);
const includeSearchQuery = searchQuery.length > 0 ? `?${searchQuery}` : '';
const path = pathByRouteName(routeName, routes, pathParams);
return `${path}${includeSearchQuery}`;
return `${path}${includeSearchQuery}${hash}`;
};
/**