Merge pull request #551 from sharetribe/order-page-send-message

Send messages in Order page
This commit is contained in:
Kimmo Puputti 2017-11-15 14:10:03 +02:00 committed by GitHub
commit 69480bf823
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1091 additions and 179 deletions

View file

@ -91,6 +91,7 @@
width: 28px;
height: 28px;
stroke: var(--matterColorLight);
stroke-width: 3px;
}
.checkmark {

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

@ -6,6 +6,7 @@ exports[`Messages matches snapshot 1`] = `
>
<li
className={undefined}
id="msg-msg1"
>
<div
className={undefined}
@ -41,6 +42,7 @@ exports[`Messages matches snapshot 1`] = `
</li>
<li
className={undefined}
id="msg-msg2"
>
<div
className={undefined}

View file

@ -83,22 +83,26 @@
}
.orderInfo {
margin-bottom: 210px;
@media (--viewportLarge) {
max-width: 538px;
margin-right: 108px;
margin-bottom: 0;
}
}
.heading {
margin: 27px 24px 0 24px;
margin: 29px 24px 0 24px;
@media (--viewportMedium) {
margin: 25px 24px 0 24px;
max-width: 80%;
}
@media (--viewportLarge) {
max-width: 100%;
margin: 177px 0 0 0;
margin: 175px 0 0 0;
}
}
@ -152,6 +156,9 @@
width: 409px;
display: block;
padding-bottom: 55px;
background-color: var(--matterColorLight);
border: 1px solid var(--matterColorNegative);
border-radius: 2px;
}
}
@ -166,12 +173,9 @@
margin: 1px 0 0 0;
@media (--viewportLarge) {
margin-top: 122px;
margin-top: 121px;
margin-left: 0;
margin-right: 0;
background-color: var(--matterColorLight);
border: 1px solid var(--matterColorNegative);
border-radius: 2px;
}
}
@ -189,7 +193,7 @@
margin-bottom: 10px;
@media (--viewportLarge) {
margin-top: 13px;
margin-top: 14px;
margin-bottom: 0;
}
}
@ -262,3 +266,24 @@
margin-top: 19px;
}
}
.sendMessageForm {
position: fixed;
bottom: 0;
width: 100%;
box-shadow: var(--boxShadowBottomForm);
@media (--viewportLarge) {
margin-top: 47px;
position: relative;
box-shadow: none;
}
}
.sendMessageFormFocusedInMobileSafari {
position: absolute;
@media (--viewportLarge) {
position: relative;
}
}

View file

@ -1,10 +1,11 @@
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';
import * as propTypes from '../../util/propTypes';
import { createSlug } from '../../util/urlHelpers';
import { ensureListing, ensureTransaction, ensureUser, userDisplayName } from '../../util/data';
import { isMobileSafari } from '../../util/userAgent';
import {
BookingBreakdown,
NamedLink,
@ -12,6 +13,7 @@ import {
AvatarMedium,
Messages,
} from '../../components';
import { SendMessageForm } from '../../containers';
import css from './OrderDetailsPanel.css';
@ -77,129 +79,150 @@ 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 = { sendMessageFormFocused: 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 classes = classNames(rootClassName || css.root, className);
let listingLink = null;
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}
</div>
<div className={css.bookingBreakdownContainer}>
<div className={css.breakdownDesktop}>
<div className={css.breakdownImageWrapper}>
if (listingLoaded && currentListing.attributes.title) {
const title = currentListing.attributes.title;
const params = { id: currentListing.id.uuid, slug: createSlug(title) };
listingLink = (
<NamedLink name="ListingPage" params={params}>
{title}
</NamedLink>
);
} else {
listingLink = deletedListingOrderTitle;
}
const listingTitle = currentListing.attributes.deleted
? deletedListingTitle
: currentListing.attributes.title;
const bookingInfo = breakdown(currentTransaction);
const orderHeading = orderTitle(currentTransaction, listingLink, customerDisplayName);
const orderInfoText = listingDeleted
? orderMessageDeletedListing
: orderMessage(currentTransaction, authorDisplayName);
const showInfoMessage = !!orderInfoText;
const firstImage =
currentListing.images && currentListing.images.length > 0 ? currentListing.images[0] : null;
const 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 isMobSaf = isMobileSafari();
const handleSendMessageFormFocus = () => {
this.setState({ sendMessageFormFocused: true });
if (isMobSaf) {
// Scroll to bottom
window.scroll({ top: document.body.scrollHeight, left: 0, behavior: 'smooth' });
}
};
const handleSendMessageFormBlur = () => {
this.setState({ sendMessageFormFocused: false });
};
const handleMessageSubmit = values => {
return onSendMessage(currentTransaction.id, values.message)
.then(messageId => {
onResetForm(sendMessageFormName);
scrollToMessage(messageId);
})
.catch(e => {
console.error(e);
});
};
const sendMessageFormClasses = classNames(css.sendMessageForm, {
[css.sendMessageFormFocusedInMobileSafari]: isMobSaf && this.state.sendMessageFormFocused,
});
const classes = classNames(rootClassName || css.root, className);
return (
<div className={classes}>
<div className={css.container}>
<div className={css.orderInfo}>
<div className={css.imageWrapperMobile}>
<div className={css.aspectWrapper}>
<ResponsiveImage
rootClassName={css.rootForImage}
@ -209,40 +232,82 @@ 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={sendMessageFormClasses}
messagePlaceholder={sendMessagePlaceholder}
inProgress={sendMessageInProgress}
sendMessageError={sendMessageError}
onFocus={handleSendMessageFormFocus}
onBlur={handleSendMessageFormBlur}
onSubmit={handleMessageSubmit}
/>
</div>
<div className={css.bookingBreakdownContainer}>
<div className={css.breakdownDesktop}>
<div className={css.breakdownImageWrapper}>
<div className={css.aspectWrapper}>
<ResponsiveImage
rootClassName={css.rootForImage}
alt={listingTitle}
image={firstImage}
nameSet={[
{ name: 'landscape-crop', size: '400w' },
{ name: 'landscape-crop2x', size: '800w' },
]}
sizes="100%"
/>
</div>
</div>
<div className={css.avatarWrapper}>
<AvatarMedium user={currentProvider} />
</div>
<div className={css.breakdownHeadings}>
<h2 className={css.breakdownTitle}>{listingTitle}</h2>
<p className={css.breakdownSubtitle}>
<FormattedMessage
id="OrderDetailsPanel.hostedBy"
values={{ name: authorDisplayName }}
/>
</p>
</div>
<h3 className={css.bookingBreakdownTitle}>
<FormattedMessage id="OrderDetailsPanel.bookingBreakdownTitle" />
</h3>
{bookingInfo}
</div>
<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,
@ -253,6 +318,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

@ -1,7 +1,14 @@
import React from 'react';
import { shallow } from 'enzyme';
import { types } from '../../util/sdkLoader';
import { createBooking, createListing, createUser, createTransaction } from '../../util/test-data';
import {
createBooking,
createListing,
createUser,
createTransaction,
createCurrentUser,
createMessage,
} from '../../util/test-data';
import { renderShallow } from '../../util/test-helpers';
import { fakeIntl } from '../../util/test-data';
import * as propTypes from '../../util/propTypes';
@ -57,11 +64,20 @@ const txDelivered = createTransaction({
...baseTxAttrs,
});
const noop = () => null;
describe('OrderDetailsPanel', () => {
const panelBaseProps = {
intl: fakeIntl,
messages: [],
currentUser: createCurrentUser('user2'),
messages: [
createMessage('msg1', {}, { sender: createUser('user1') }),
createMessage('msg2', {}, { sender: createUser('user2') }),
],
initialMessageFailed: false,
sendMessageInProgress: false,
onSendMessage: noop,
onResetForm: noop,
};
it('preauthorized matches snapshot', () => {

View file

@ -211,6 +211,102 @@ exports[`OrderDetailsPanel accepted matches snapshot 1`] = `
userRole="customer"
/>
</div>
<div
className=""
>
<h3>
<FormattedMessage
id="OrderDetailsPanel.messagesHeading"
values={Object {}}
/>
</h3>
<InjectIntl(Component)
currentUser={
Object {
"attributes": Object {
"banned": false,
"email": "user2@example.com",
"emailVerified": true,
"profile": Object {
"abbreviatedName": "user2 abbreviated name",
"displayName": "user2 display name",
"firstName": "user2 first name",
"lastName": "user2 last name",
},
"stripeConnected": true,
},
"id": UUID {
"uuid": "user2",
},
"type": "currentUser",
}
}
messages={
Array [
Object {
"attributes": Object {
"at": 2017-11-09T08:12:00.000Z,
"content": "Message msg1
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
},
"id": UUID {
"uuid": "msg1",
},
"sender": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user1 display name",
},
},
"id": UUID {
"uuid": "user1",
},
"type": "user",
},
"type": "message",
},
Object {
"attributes": Object {
"at": 2017-11-09T08:12:00.000Z,
"content": "Message msg2
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
},
"id": UUID {
"uuid": "msg2",
},
"sender": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user2 display name",
},
},
"id": UUID {
"uuid": "user2",
},
"type": "user",
},
"type": "message",
},
]
}
/>
</div>
<SendMessageForm
form="OrderDetailsPanel.SendMessageForm"
inProgress={false}
messagePlaceholder="OrderDetailsPanel.sendMessagePlaceholder"
onBlur={[Function]}
onFocus={[Function]}
onSubmit={[Function]}
rootClassName=""
sendMessageError={null}
/>
</div>
<div>
<div>
@ -603,6 +699,102 @@ exports[`OrderDetailsPanel autodeclined matches snapshot 1`] = `
userRole="customer"
/>
</div>
<div
className=""
>
<h3>
<FormattedMessage
id="OrderDetailsPanel.messagesHeading"
values={Object {}}
/>
</h3>
<InjectIntl(Component)
currentUser={
Object {
"attributes": Object {
"banned": false,
"email": "user2@example.com",
"emailVerified": true,
"profile": Object {
"abbreviatedName": "user2 abbreviated name",
"displayName": "user2 display name",
"firstName": "user2 first name",
"lastName": "user2 last name",
},
"stripeConnected": true,
},
"id": UUID {
"uuid": "user2",
},
"type": "currentUser",
}
}
messages={
Array [
Object {
"attributes": Object {
"at": 2017-11-09T08:12:00.000Z,
"content": "Message msg1
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
},
"id": UUID {
"uuid": "msg1",
},
"sender": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user1 display name",
},
},
"id": UUID {
"uuid": "user1",
},
"type": "user",
},
"type": "message",
},
Object {
"attributes": Object {
"at": 2017-11-09T08:12:00.000Z,
"content": "Message msg2
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
},
"id": UUID {
"uuid": "msg2",
},
"sender": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user2 display name",
},
},
"id": UUID {
"uuid": "user2",
},
"type": "user",
},
"type": "message",
},
]
}
/>
</div>
<SendMessageForm
form="OrderDetailsPanel.SendMessageForm"
inProgress={false}
messagePlaceholder="OrderDetailsPanel.sendMessagePlaceholder"
onBlur={[Function]}
onFocus={[Function]}
onSubmit={[Function]}
rootClassName=""
sendMessageError={null}
/>
</div>
<div>
<div>
@ -1018,6 +1210,102 @@ exports[`OrderDetailsPanel canceled matches snapshot 1`] = `
userRole="customer"
/>
</div>
<div
className="undefined"
>
<h3>
<FormattedMessage
id="OrderDetailsPanel.messagesHeading"
values={Object {}}
/>
</h3>
<InjectIntl(Component)
currentUser={
Object {
"attributes": Object {
"banned": false,
"email": "user2@example.com",
"emailVerified": true,
"profile": Object {
"abbreviatedName": "user2 abbreviated name",
"displayName": "user2 display name",
"firstName": "user2 first name",
"lastName": "user2 last name",
},
"stripeConnected": true,
},
"id": UUID {
"uuid": "user2",
},
"type": "currentUser",
}
}
messages={
Array [
Object {
"attributes": Object {
"at": 2017-11-09T08:12:00.000Z,
"content": "Message msg1
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
},
"id": UUID {
"uuid": "msg1",
},
"sender": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user1 display name",
},
},
"id": UUID {
"uuid": "user1",
},
"type": "user",
},
"type": "message",
},
Object {
"attributes": Object {
"at": 2017-11-09T08:12:00.000Z,
"content": "Message msg2
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
},
"id": UUID {
"uuid": "msg2",
},
"sender": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user2 display name",
},
},
"id": UUID {
"uuid": "user2",
},
"type": "user",
},
"type": "message",
},
]
}
/>
</div>
<SendMessageForm
form="OrderDetailsPanel.SendMessageForm"
inProgress={false}
messagePlaceholder="OrderDetailsPanel.sendMessagePlaceholder"
onBlur={[Function]}
onFocus={[Function]}
onSubmit={[Function]}
rootClassName=""
sendMessageError={null}
/>
</div>
<div>
<div>
@ -1410,6 +1698,102 @@ exports[`OrderDetailsPanel declined matches snapshot 1`] = `
userRole="customer"
/>
</div>
<div
className=""
>
<h3>
<FormattedMessage
id="OrderDetailsPanel.messagesHeading"
values={Object {}}
/>
</h3>
<InjectIntl(Component)
currentUser={
Object {
"attributes": Object {
"banned": false,
"email": "user2@example.com",
"emailVerified": true,
"profile": Object {
"abbreviatedName": "user2 abbreviated name",
"displayName": "user2 display name",
"firstName": "user2 first name",
"lastName": "user2 last name",
},
"stripeConnected": true,
},
"id": UUID {
"uuid": "user2",
},
"type": "currentUser",
}
}
messages={
Array [
Object {
"attributes": Object {
"at": 2017-11-09T08:12:00.000Z,
"content": "Message msg1
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
},
"id": UUID {
"uuid": "msg1",
},
"sender": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user1 display name",
},
},
"id": UUID {
"uuid": "user1",
},
"type": "user",
},
"type": "message",
},
Object {
"attributes": Object {
"at": 2017-11-09T08:12:00.000Z,
"content": "Message msg2
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
},
"id": UUID {
"uuid": "msg2",
},
"sender": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user2 display name",
},
},
"id": UUID {
"uuid": "user2",
},
"type": "user",
},
"type": "message",
},
]
}
/>
</div>
<SendMessageForm
form="OrderDetailsPanel.SendMessageForm"
inProgress={false}
messagePlaceholder="OrderDetailsPanel.sendMessagePlaceholder"
onBlur={[Function]}
onFocus={[Function]}
onSubmit={[Function]}
rootClassName=""
sendMessageError={null}
/>
</div>
<div>
<div>
@ -1802,6 +2186,102 @@ exports[`OrderDetailsPanel delivered matches snapshot 1`] = `
userRole="customer"
/>
</div>
<div
className=""
>
<h3>
<FormattedMessage
id="OrderDetailsPanel.messagesHeading"
values={Object {}}
/>
</h3>
<InjectIntl(Component)
currentUser={
Object {
"attributes": Object {
"banned": false,
"email": "user2@example.com",
"emailVerified": true,
"profile": Object {
"abbreviatedName": "user2 abbreviated name",
"displayName": "user2 display name",
"firstName": "user2 first name",
"lastName": "user2 last name",
},
"stripeConnected": true,
},
"id": UUID {
"uuid": "user2",
},
"type": "currentUser",
}
}
messages={
Array [
Object {
"attributes": Object {
"at": 2017-11-09T08:12:00.000Z,
"content": "Message msg1
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
},
"id": UUID {
"uuid": "msg1",
},
"sender": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user1 display name",
},
},
"id": UUID {
"uuid": "user1",
},
"type": "user",
},
"type": "message",
},
Object {
"attributes": Object {
"at": 2017-11-09T08:12:00.000Z,
"content": "Message msg2
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
},
"id": UUID {
"uuid": "msg2",
},
"sender": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user2 display name",
},
},
"id": UUID {
"uuid": "user2",
},
"type": "user",
},
"type": "message",
},
]
}
/>
</div>
<SendMessageForm
form="OrderDetailsPanel.SendMessageForm"
inProgress={false}
messagePlaceholder="OrderDetailsPanel.sendMessagePlaceholder"
onBlur={[Function]}
onFocus={[Function]}
onSubmit={[Function]}
rootClassName=""
sendMessageError={null}
/>
</div>
<div>
<div>
@ -2217,6 +2697,102 @@ exports[`OrderDetailsPanel preauthorized matches snapshot 1`] = `
userRole="customer"
/>
</div>
<div
className="undefined"
>
<h3>
<FormattedMessage
id="OrderDetailsPanel.messagesHeading"
values={Object {}}
/>
</h3>
<InjectIntl(Component)
currentUser={
Object {
"attributes": Object {
"banned": false,
"email": "user2@example.com",
"emailVerified": true,
"profile": Object {
"abbreviatedName": "user2 abbreviated name",
"displayName": "user2 display name",
"firstName": "user2 first name",
"lastName": "user2 last name",
},
"stripeConnected": true,
},
"id": UUID {
"uuid": "user2",
},
"type": "currentUser",
}
}
messages={
Array [
Object {
"attributes": Object {
"at": 2017-11-09T08:12:00.000Z,
"content": "Message msg1
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
},
"id": UUID {
"uuid": "msg1",
},
"sender": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user1 display name",
},
},
"id": UUID {
"uuid": "user1",
},
"type": "user",
},
"type": "message",
},
Object {
"attributes": Object {
"at": 2017-11-09T08:12:00.000Z,
"content": "Message msg2
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
},
"id": UUID {
"uuid": "msg2",
},
"sender": Object {
"attributes": Object {
"banned": false,
"profile": Object {
"abbreviatedName": "TT",
"displayName": "user2 display name",
},
},
"id": UUID {
"uuid": "user2",
},
"type": "user",
},
"type": "message",
},
]
}
/>
</div>
<SendMessageForm
form="OrderDetailsPanel.SendMessageForm"
inProgress={false}
messagePlaceholder="OrderDetailsPanel.sendMessagePlaceholder"
onBlur={[Function]}
onFocus={[Function]}
onSubmit={[Function]}
rootClassName=""
sendMessageError={null}
/>
</div>
<div>
<div>

View file

@ -1,7 +1,9 @@
@import '../../marketplace.css';
.mainContent {
margin-bottom: 122px;
@media (--viewportLarge) {
margin-bottom: 122px;
}
}
.loading {
@ -26,3 +28,11 @@
.activeTab {
font-weight: bold;
}
.footer {
display: none;
@media (--viewportLarge) {
display: block;
}
}

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,31 @@ 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) => {
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
@ -100,7 +109,7 @@ export const OrderPageComponent = props => {
</LayoutWrapperTopbar>
<LayoutWrapperMain className={css.mainContent}>{panel}</LayoutWrapperMain>
<LayoutWrapperFooter>
<Footer />
<Footer className={css.footer} />
</LayoutWrapperFooter>
</LayoutSingleColumn>
</Page>
@ -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

@ -34,8 +34,11 @@ describe('OrderPage', () => {
currentUser: createCurrentUser('customer1'),
messages: [],
sendMessageInProgress: false,
scrollingDisabled: false,
transaction,
onSendMessage: noop,
onResetForm: noop,
intl: fakeIntl,
};

View file

@ -19,7 +19,7 @@ exports[`OrderPage matches snapshot 1`] = `
className={null}
rootClassName={null}
>
<InjectIntl(Component)
<InjectIntl(OrderDetailsPanelComponent)
className="undefined"
currentUser={
Object {
@ -44,6 +44,10 @@ exports[`OrderPage matches snapshot 1`] = `
fetchMessagesError={null}
initialMessageFailed={false}
messages={Array []}
onResetForm={[Function]}
onSendMessage={[Function]}
sendMessageError={null}
sendMessageInProgress={false}
transaction={
Object {
"attributes": Object {

View file

@ -40,18 +40,32 @@
.submitButtonMobile {
position: absolute;
top: -28px;
right: 20px;
width: 56px;
height: 56px;
top: -21px;
right: 24px;
width: 48px;
height: 48px;
border: none;
padding: 0;
background-color: var(--successColor);
border-radius: 50%;
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
@media (--viewportLarge) {
display: none;
}
}
.spinner {
stroke: var(--matterColorLight);
width: 18px;
height: 18px;
stroke-width: 4px;
}
.fillSuccess {
fill: var(--successColor);
}
@ -60,14 +74,61 @@
stroke: var(--matterColor);
}
.errorMobile {
@apply --marketplaceH5FontStyles;
font-weight: var(--fontWeightMedium);
color: var(--failColor);
margin: 0;
position: absolute;
top: -15px;
right: 80px;
padding: 5px 16px;
background-color: var(--matterColorLight);
@media (--viewportMedium) {
margin: 0;
top: -14px;
}
@media (--viewportLarge) {
display: none;
}
}
.submitContainerDesktop {
display: none;
@media (--viewportLarge) {
display: flex;
flex-direction: row;
}
}
.errorContainerDesktop {
display: none;
flex: 1;
text-align: right;
padding: 26px 24px 0 0;
@media (--viewportLarge) {
display: block;
}
}
.errorDesktop {
@apply --marketplaceH5FontStyles;
font-weight: var(--fontWeightMedium);
color: var(--failColor);
margin: 0;
}
.submitButtonDesktop {
@apply --marketplaceH5FontStyles;
font-weight: var(--fontWeightMedium);
display: none;
float: right;
padding: 0 16px;
min-height: auto;
min-width: 150px;
height: 41px;
@media (--viewportLarge) {
@ -77,6 +138,18 @@
}
}
.sendIconMobile {
margin: 0 3px 3px 0;
}
.sendIconMobileInProgress {
width: 20px;
height: 20px;
stroke: var(--matterColorLight);
stroke-width: 4px;
margin-bottom: 4px;
}
.sendIconDesktop {
margin: -3px 5px 0 0;
}

View file

@ -3,6 +3,7 @@ import SendMessageForm from './SendMessageForm';
export const Empty = {
component: SendMessageForm,
props: {
form: 'Styleguide_SendMessageForm_Empty',
messagePlaceholder: 'Send message to Juho…',
onChange: values => {
console.log('values changed to:', values);
@ -19,3 +20,23 @@ export const Empty = {
},
group: 'forms',
};
export const InProgress = {
component: SendMessageForm,
props: {
form: 'Styleguide_SendMessageForm_InProgress',
messagePlaceholder: 'Send message to Juho…',
inProgress: true,
},
group: 'forms',
};
export const Error = {
component: SendMessageForm,
props: {
form: 'Styleguide_SendMessageForm_Error',
messagePlaceholder: 'Send message to Juho…',
sendMessageError: { type: 'error', name: 'ExampleError' },
},
group: 'forms',
};

View file

@ -4,7 +4,8 @@ import { compose } from 'redux';
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import { reduxForm, propTypes as formPropTypes } from 'redux-form';
import classNames from 'classnames';
import { Form, TextInputField, SecondaryButton } from '../../components';
import { Form, TextInputField, SecondaryButton, IconSpinner } from '../../components';
import * as propTypes from '../../util/propTypes';
import css from './SendMessageForm.css';
@ -12,7 +13,13 @@ const BLUR_TIMEOUT_MS = 100;
const IconSendMessageMobile = () => {
return (
<svg width="56" height="56" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg">
<svg
className={css.sendIconMobile}
width="26"
height="26"
viewBox="0 0 26 26"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<filter
x="-1.9%"
@ -35,11 +42,8 @@ const IconSendMessageMobile = () => {
</feMerge>
</filter>
</defs>
<g transform="translate(4 6)" filter="url(#a)" fill="none" fillRule="evenodd">
<rect className={css.fillSuccess} width="48" height="48" rx="24" />
<g fill="#FFF">
<path d="M14.47 23.048c-.14.05-.237.193-.25.36-.013.163.062.317.19.39l4.623 2.688 12.162-10.593-16.726 7.155zM20.47 27.327l-.97 6.59c0 .228.184.416.417.416.145 0 .284-.076.36-.206l2.94-4.823 4.833 2.894c.118.066.26.067.373.015.12-.055.207-.162.234-.292l3.315-15.328-11.5 10.735z" />
</g>
<g filter="url(#a)" transform="translate(-313 -10)" fill="#FFF" fillRule="evenodd">
<path d="M317.47 23.048c-.14.05-.237.193-.25.36-.013.163.062.317.19.39l4.623 2.688 12.162-10.593-16.726 7.155zM323.47 27.327l-.97 6.59c0 .228.184.416.417.416.145 0 .284-.076.36-.206l2.94-4.823 4.833 2.894c.118.066.26.067.373.015.12-.055.207-.162.234-.292l3.315-15.328-11.5 10.735z" />
</g>
</svg>
);
@ -92,6 +96,7 @@ class SendMessageFormComponent extends Component {
handleSubmit,
submitting,
inProgress,
sendMessageError,
invalid,
} = this.props;
@ -110,19 +115,37 @@ class SendMessageFormComponent extends Component {
onFocus={this.handleFocus}
onBlur={this.handleBlur}
/>
<button className={css.submitButtonMobile}>
<IconSendMessageMobile />
{sendMessageError ? (
<p className={css.errorMobile}>
<FormattedMessage id="SendMessageForm.sendFailed" />
</p>
) : null}
<button className={css.submitButtonMobile} disabled={submitDisabled}>
{submitInProgress ? (
<IconSpinner className={css.sendIconMobileInProgress} />
) : (
<IconSendMessageMobile />
)}
</button>
<SecondaryButton
className={css.submitButtonDesktop}
inProgress={submitInProgress}
disabled={submitDisabled}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
>
<IconSendMessageDesktop />
<FormattedMessage id="SendMessageForm.sendMessage" />
</SecondaryButton>
<div className={css.submitContainerDesktop}>
<div className={css.errorContainerDesktop}>
{sendMessageError ? (
<p className={css.errorDesktop}>
<FormattedMessage id="SendMessageForm.sendFailed" />
</p>
) : null}
</div>
<SecondaryButton
className={css.submitButtonDesktop}
inProgress={submitInProgress}
disabled={submitDisabled}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
>
<IconSendMessageDesktop />
<FormattedMessage id="SendMessageForm.sendMessage" />
</SecondaryButton>
</div>
</Form>
);
}
@ -135,6 +158,7 @@ SendMessageFormComponent.defaultProps = {
messagePlaceholder: null,
onFocus: () => null,
onBlur: () => null,
sendMessageError: null,
};
SendMessageFormComponent.propTypes = {
@ -146,6 +170,7 @@ SendMessageFormComponent.propTypes = {
messagePlaceholder: string,
onFocus: func,
onBlur: func,
sendMessageError: propTypes.error,
// from injectIntl
intl: intlShape.isRequired,
@ -157,4 +182,6 @@ const SendMessageForm = compose(reduxForm({ form: defaultFormName }), injectIntl
SendMessageFormComponent
);
SendMessageForm.displayName = 'SendMessageForm';
export default SendMessageForm;

View file

@ -272,6 +272,7 @@
"OrderDetailsPanel.orderPreauthorizedStatus": "{providerName} has been notified about the booking request, so sit back and relax.",
"OrderDetailsPanel.orderPreauthorizedSubtitle": "You have requested to book {listingLink}",
"OrderDetailsPanel.orderPreauthorizedTitle": "Great success, {customerName}!",
"OrderDetailsPanel.sendMessagePlaceholder": "Send a message to {name}…",
"OrderPage.fetchOrderFailed": "Fetching order data failed.",
"OrderPage.loadingData": "Loading order data.",
"OrderPage.title": "Order details: {listingTitle}",
@ -470,6 +471,7 @@
"SectionLocations.listingsInLocation": "Saunas in {location}",
"SectionLocations.subtitle": "We have more than 1000 saunas around Finland. Here are some of our most popular locations.",
"SectionLocations.title": "We have wooden saunas, electric saunas and even tent saunas.",
"SendMessageForm.sendFailed": "Failed to send. Please try again.",
"SendMessageForm.sendMessage": "Send message",
"SignupForm.emailInvalid": "A valid email address is required",
"SignupForm.emailLabel": "Email",

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}`;
};
/**

14
src/util/userAgent.js Normal file
View file

@ -0,0 +1,14 @@
export const isMobileSafari = () => {
if (!window) {
return false;
}
// https://stackoverflow.com/a/29696509
const ua = window.navigator.userAgent;
const iOS = !!ua.match(/iPad/i) || !!ua.match(/iPhone/i);
const webkit = !!ua.match(/WebKit/i);
// If iOS Chrome needs to be separated, use `!ua.match(/CriOS/i)` as
// an extra condition.
return iOS && webkit;
};