Merge pull request #543 from sharetribe/checkout-initial-message

Initial message in Checkout
This commit is contained in:
Kimmo Puputti 2017-11-09 13:57:23 +02:00 committed by GitHub
commit 13d9626152
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 256 additions and 88 deletions

View file

@ -19,6 +19,12 @@
@media (--viewportMedium) {
margin: -1px 0 8px 0;
&:last-of-type {
/* Last item should not have bottom margin that goes to the bottom of
the component */
margin-bottom: 0;
}
}
display: flex;

View file

@ -88,7 +88,7 @@
@media (--viewportLarge) {
flex-grow: 0;
flex-basis: 519px;
margin-top: 177px;
margin-top: 121px;
margin-right: 132px;
}
}
@ -228,23 +228,6 @@
}
}
.paymentTitle {
/* Font */
color: var(--matterColorAnti);
margin-top: 0;
margin-bottom: 14px;
@media (--viewportMedium) {
margin-top: 0;
margin-bottom: 14px;
}
@media (--viewportMedium) {
margin-top: 0;
margin-bottom: 26px;
}
}
.orderError,
.notFoundError {
margin: 11px 0 12px 0;
@ -340,7 +323,7 @@
margin: 5px 24px 25px 24px;
@media (--viewportLarge) {
margin: 38px 48px 25px 48px;
margin: 38px 48px 26px 48px;
}
}

View file

@ -104,11 +104,11 @@ export const speculateTransactionError = e => ({
/* ================ Thunks ================ */
export const initiateOrder = params => (dispatch, getState, sdk) => {
export const initiateOrder = (orderParams, initialMessage) => (dispatch, getState, sdk) => {
dispatch(initiateOrderRequest());
const bodyParams = {
transition: 'transition/preauthorize',
params,
params: orderParams,
};
return sdk.transactions
.initiate(bodyParams)
@ -116,14 +116,27 @@ export const initiateOrder = params => (dispatch, getState, sdk) => {
const orderId = response.data.data.id;
dispatch(initiateOrderSuccess(orderId));
dispatch(fetchCurrentUserHasOrdersSuccess(true));
return orderId;
if (initialMessage) {
return sdk.messages
.send({ transactionId: orderId, content: initialMessage })
.then(() => {
return { orderId, initialMessageSuccess: true };
})
.catch(e => {
log.error(e, 'initial-message-send-failed', { txId: orderId });
return { orderId, initialMessageSuccess: false };
});
} else {
return Promise.resolve({ orderId, initialMessageSuccess: true });
}
})
.catch(e => {
dispatch(initiateOrderError(storableError(e)));
log.error(e, 'initiate-order-failed', {
listingId: params.listingId.uuid,
bookingStart: params.bookingStart,
bookingEnd: params.bookingEnd,
listingId: orderParams.listingId.uuid,
bookingStart: orderParams.bookingStart,
bookingEnd: orderParams.bookingEnd,
});
throw e;
});

View file

@ -6,7 +6,7 @@ import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import { withRouter } from 'react-router-dom';
import classNames from 'classnames';
import routeConfiguration from '../../routeConfiguration';
import { pathByRouteName } from '../../util/routes';
import { pathByRouteName, findRouteByRouteName } from '../../util/routes';
import * as propTypes from '../../util/propTypes';
import { ensureListing, ensureUser, ensureTransaction, ensureBooking } from '../../util/data';
import { createSlug } from '../../util/urlHelpers';
@ -96,13 +96,15 @@ export class CheckoutPageComponent extends Component {
this.setState({ pageData: pageData || {}, dataLoaded: true });
}
handleSubmit(cardToken) {
handleSubmit(values) {
if (this.state.submitting) {
return;
}
this.setState({ submitting: true });
const { history, sendOrderRequest, speculatedTransaction } = this.props;
const cardToken = values.token;
const initialMessage = values.message;
const { history, sendOrderRequest, speculatedTransaction, dispatch } = this.props;
const requestParams = {
listingId: this.state.pageData.listing.id,
cardToken,
@ -110,10 +112,21 @@ export class CheckoutPageComponent extends Component {
bookingEnd: speculatedTransaction.booking.attributes.end,
};
sendOrderRequest(requestParams)
.then(orderId => {
sendOrderRequest(requestParams, initialMessage)
.then(values => {
const { orderId, initialMessageSuccess } = values;
this.setState({ submitting: false });
const orderDetailsPath = pathByRouteName('OrderDetailsPage', routeConfiguration(), {
const routes = routeConfiguration();
const OrderPage = findRouteByRouteName('OrderDetailsPage', routes);
// Transaction is already created, but if the initial message
// sending failed, we tell it to the OrderDetailsPage.
dispatch(
OrderPage.setInitialValues({
messageSendingFailedToTransaction: initialMessageSuccess ? null : orderId,
})
);
const orderDetailsPath = pathByRouteName('OrderDetailsPage', routes, {
id: orderId.uuid,
});
clearData(STORAGE_KEY);
@ -317,9 +330,6 @@ export class CheckoutPageComponent extends Component {
</div>
<section className={css.paymentContainer}>
<h3 className={css.paymentTitle}>
<FormattedMessage id="CheckoutPage.paymentTitle" />
</h3>
{initiateOrderErrorMessage}
{listingNotFoundErrorMessage}
{showPaymentForm ? (
@ -329,6 +339,7 @@ export class CheckoutPageComponent extends Component {
inProgress={this.state.submitting}
formId="CheckoutPagePaymentForm"
paymentInfo={intl.formatMessage({ id: 'CheckoutPage.paymentInfo' })}
authorDisplayName={currentAuthor.attributes.profile.displayName}
/>
) : null}
</section>
@ -401,6 +412,9 @@ CheckoutPageComponent.propTypes = {
}).isRequired,
sendOrderRequest: func.isRequired,
// from connect
dispatch: func.isRequired,
// from injectIntl
intl: intlShape.isRequired,
@ -433,7 +447,8 @@ const mapStateToProps = state => {
};
const mapDispatchToProps = dispatch => ({
sendOrderRequest: params => dispatch(initiateOrder(params)),
dispatch,
sendOrderRequest: (params, initialMessage) => dispatch(initiateOrder(params, initialMessage)),
fetchSpeculatedTransaction: (listingId, bookingStart, bookingEnd) =>
dispatch(speculateTransaction(listingId, bookingStart, bookingEnd)),
});

View file

@ -14,6 +14,7 @@ describe('CheckoutPage', () => {
bookingStart: new Date(Date.UTC(2017, 3, 14)),
bookingEnd: new Date(Date.UTC(2017, 3, 16)),
},
dispatch: noop,
history: { push: noop },
intl: fakeIntl,
listing,

View file

@ -94,13 +94,8 @@ exports[`CheckoutPage matches snapshot 1`] = `
</h3>
</div>
<section>
<h3>
<FormattedMessage
id="CheckoutPage.paymentTitle"
values={Object {}}
/>
</h3>
<InjectIntl(StripePaymentForm)
authorDisplayName="author display name"
formId="CheckoutPagePaymentForm"
inProgress={false}
onSubmit={[Function]}

View file

@ -1,3 +1,4 @@
import { pick } from 'lodash';
import { types } from '../../util/sdkLoader';
import { storableError } from '../../util/errors';
import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck';
@ -5,6 +6,8 @@ import { updatedEntities, denormalisedEntities } from '../../util/data';
// ================ Action types ================ //
export const SET_INITAL_VALUES = 'app/OrderPage/SET_INITIAL_VALUES';
export const FETCH_ORDER_REQUEST = 'app/OrderPage/FETCH_ORDER_REQUEST';
export const FETCH_ORDER_SUCCESS = 'app/OrderPage/FETCH_ORDER_SUCCESS';
export const FETCH_ORDER_ERROR = 'app/OrderPage/FETCH_ORDER_ERROR';
@ -22,11 +25,15 @@ const initialState = {
fetchMessagesInProgress: false,
fetchMessagesError: null,
messages: [],
messageSendingFailedToTransaction: null,
};
export default function checkoutPageReducer(state = initialState, action = {}) {
const { type, payload } = action;
switch (type) {
case SET_INITAL_VALUES:
return { ...initialState, ...payload };
case FETCH_ORDER_REQUEST:
return { ...state, fetchOrderInProgress: true, fetchOrderError: null };
case FETCH_ORDER_SUCCESS: {
@ -50,6 +57,11 @@ export default function checkoutPageReducer(state = initialState, action = {}) {
// ================ Action creators ================ //
export const setInitialValues = initialValues => ({
type: SET_INITAL_VALUES,
payload: pick(initialValues, Object.keys(initialState)),
});
const fetchOrderRequest = () => ({ type: FETCH_ORDER_REQUEST });
const fetchOrderSuccess = response => ({ type: FETCH_ORDER_SUCCESS, payload: response });
const fetchOrderError = e => ({ type: FETCH_ORDER_ERROR, error: true, payload: e });

View file

@ -20,17 +20,33 @@ import {
} from '../../components';
import { TopbarContainer } from '../../containers';
import { loadData } from './OrderPage.duck';
import { loadData, setInitialValues } from './OrderPage.duck';
import css from './OrderPage.css';
// OrderPage handles data loading
// It show loading data text or OrderDetailsPanel (and later also another panel for messages).
export const OrderPageComponent = props => {
const { currentUser, fetchOrderError, intl, params, scrollingDisabled, transaction } = props;
const {
currentUser,
fetchOrderError,
messageSendingFailedToTransaction,
intl,
params,
scrollingDisabled,
transaction,
} = props;
const currentTransaction = ensureTransaction(transaction);
const currentListing = ensureListing(currentTransaction.listing);
const listingTitle = currentListing.attributes.title;
if (messageSendingFailedToTransaction) {
// TODO: render error message with other messages
console.error(
'failed to send initial message to transaction:',
messageSendingFailedToTransaction
);
}
// Redirect users with someone else's direct link to their own inbox/orders page.
const isDataAvailable =
currentUser &&
@ -95,6 +111,7 @@ const { bool, oneOf, shape, string } = PropTypes;
OrderPageComponent.propTypes = {
currentUser: propTypes.currentUser,
fetchOrderError: propTypes.error,
messageSendingFailedToTransaction: propTypes.uuid,
intl: intlShape.isRequired,
params: shape({ id: string }).isRequired,
scrollingDisabled: bool.isRequired,
@ -103,7 +120,7 @@ OrderPageComponent.propTypes = {
};
const mapStateToProps = state => {
const { fetchOrderError, transactionRef } = state.OrderPage;
const { fetchOrderError, transactionRef, messageSendingFailedToTransaction } = state.OrderPage;
const { currentUser } = state.user;
const transactions = getMarketplaceEntities(state, transactionRef ? [transactionRef] : []);
const transaction = transactions.length > 0 ? transactions[0] : null;
@ -111,6 +128,7 @@ const mapStateToProps = state => {
return {
currentUser,
fetchOrderError,
messageSendingFailedToTransaction,
scrollingDisabled: isScrollingDisabled(state),
transaction,
};
@ -118,6 +136,7 @@ const mapStateToProps = state => {
const OrderPage = compose(connect(mapStateToProps), injectIntl)(OrderPageComponent);
OrderPage.setInitialValues = setInitialValues;
OrderPage.loadData = loadData;
export default OrderPage;

View file

@ -5,10 +5,6 @@
flex-direction: column;
}
.label {
margin: 0 0 0px 0;
}
.card {
@apply --marketplaceInputStyles;
@ -21,12 +17,10 @@
@media (--viewportMedium) {
height: 35px;
margin-bottom: 24px;
}
@media (--viewportLarge) {
height: 42px;
padding: 6px 0 14px 0;
margin-bottom: 0;
}
}
@ -38,11 +32,59 @@
border-bottom-color: var(--failColor);
}
.paymentHeading {
margin: 0 0 14px 0;
color: var(--matterColorAnti);
@media (--viewportMedium) {
margin: 0 0 26px 0;
}
}
.paymentLabel {
margin: 0;
@media (--viewportMedium) {
margin-bottom: 2px;
}
@media (--viewportLarge) {
margin-bottom: 4px;
}
}
.messageHeading {
color: var(--matterColorAnti);
margin: 40px 0 14px 0;
@media (--viewportMedium) {
margin: 41px 0 26px 0;
}
@media (--viewportLarge) {
margin: 40px 0 26px 0;
}
}
.messageLabel {
margin: 0 0 5px 0;
@media (--viewportMedium) {
margin: 0 0 6px 0;
}
}
.messageOptional {
color: var(--matterColorAnti);
}
.message {
border-bottom-color: var(--matterColorAnti);
}
.submitContainer {
margin-top: auto;
@media (--viewportLarge) {
margin-top: 133px;
margin-top: 72px;
}
}
@ -51,6 +93,7 @@
color: var(--matterColorAnti);
text-align: center;
padding: 0 42px;
margin: 28px 0 0 0;
@media (--viewportLarge) {
/* TODO this is ugly overwrite to fix unconsistent font styles */
@ -60,9 +103,12 @@
}
.submitButton {
margin-top: 21px;
margin-top: 22px;
@media (--viewportMedium) {
margin-top: 26px;
}
@media (--viewportLarge) {
margin-top: 18px;
margin-top: 17px;
}
}

View file

@ -1,27 +1,19 @@
import React, { Component } from 'react';
import { fakeIntl } from '../../util/test-data';
import StripePaymentForm from './StripePaymentForm';
class StripePaymentFormExample extends Component {
constructor(props) {
super(props);
this.state = { token: null };
}
render() {
const handleSubmit = token => {
this.setState({ token });
};
return (
<div>
<StripePaymentForm {...this.props} onSubmit={handleSubmit} />
{this.state.token ? <p>Token: {this.state.token}</p> : null}
</div>
);
}
}
export const Empty = {
component: StripePaymentFormExample,
props: { intl: fakeIntl, formId: 'StripePaymentFormExample' },
component: StripePaymentForm,
props: {
formId: 'StripePaymentFormExample',
authorDisplayName: 'Janne K',
paymentInfo: 'You might or might not be charged yet',
onChange: values => {
console.log('form onChange:', values);
},
onSubmit: values => {
console.log('form onSubmit:', values);
},
intl: fakeIntl,
},
group: 'forms',
};

View file

@ -2,7 +2,7 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import classNames from 'classnames';
import { Form, PrimaryButton } from '../../components';
import { Form, PrimaryButton, ExpandingTextarea } from '../../components';
import * as log from '../../util/log';
import config from '../../config';
@ -70,6 +70,14 @@ const cardStyles = {
},
};
const initialState = {
error: null,
submitting: false,
cardValueValid: false,
token: null,
message: '',
};
/**
* Payment form that asks for credit card info using Stripe Elements.
*
@ -82,7 +90,7 @@ const cardStyles = {
class StripePaymentForm extends Component {
constructor(props) {
super(props);
this.state = { error: null, submitting: false, cardValueValid: false };
this.state = initialState;
this.handleCardValueChange = this.handleCardValueChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
}
@ -105,6 +113,9 @@ class StripePaymentForm extends Component {
}
});
}
componentWillReceiveProps() {
this.setState(initialState);
}
componentWillUnmount() {
if (this.card) {
this.card.removeEventListener('change', this.handleCardValueChange);
@ -112,11 +123,22 @@ class StripePaymentForm extends Component {
}
}
handleCardValueChange(event) {
const { intl } = this.props;
const { intl, onChange } = this.props;
const { error, complete } = event;
this.setState({
error: error ? stripeErrorTranslation(intl, error) : null,
cardValueValid: complete,
// A change in the card should clear the token and trigger a call
// to the onChange prop with the cleared token and the current
// message.
this.setState(prevState => {
const { message } = prevState;
const token = null;
onChange({ token, message });
return {
error: error ? stripeErrorTranslation(intl, error) : null,
cardValueValid: complete,
token,
};
});
}
handleSubmit(event) {
@ -128,6 +150,13 @@ class StripePaymentForm extends Component {
}
const { intl, onSubmit } = this.props;
if (this.state.token) {
// Token already fetched for the current card value
onSubmit({ token: this.state.token, message: this.state.message.trim() });
return;
}
this.setState({ submitting: true });
this.stripe
@ -138,10 +167,11 @@ class StripePaymentForm extends Component {
this.setState({
submitting: false,
error: stripeErrorTranslation(intl, error),
token: null,
});
} else {
this.setState({ submitting: false });
onSubmit(token.id);
this.setState({ submitting: false, token: token.id });
onSubmit({ token: token.id, message: this.state.message.trim() });
}
})
.catch(e => {
@ -157,7 +187,16 @@ class StripePaymentForm extends Component {
});
}
render() {
const { className, rootClassName, inProgress, formId, paymentInfo } = this.props;
const {
className,
rootClassName,
inProgress,
formId,
paymentInfo,
onChange,
authorDisplayName,
intl,
} = this.props;
const submitInProgress = this.state.submitting || inProgress;
const submitDisabled = !this.state.cardValueValid || submitInProgress;
const classes = classNames(rootClassName || css.root, className);
@ -166,9 +205,35 @@ class StripePaymentForm extends Component {
[css.cardError]: this.state.error,
});
const messagePlaceholder = intl.formatMessage(
{ id: 'StripePaymentForm.messagePlaceholder' },
{ name: authorDisplayName }
);
const handleMessageChange = e => {
// A change in the message should call the onChange prop with
// the current token and the new message.
const message = e.target.value;
this.setState(prevState => {
const { token } = prevState;
const newState = { token, message };
onChange(newState);
return newState;
});
};
const messageOptionalText = (
<span className={css.messageOptional}>
<FormattedMessage id="StripePaymentForm.messageOptionalText" />
</span>
);
return (
<Form className={classes} onSubmit={this.handleSubmit}>
<label className={css.label} htmlFor={`${formId}-card`}>
<h3 className={css.paymentHeading}>
<FormattedMessage id="StripePaymentForm.paymentHeading" />
</h3>
<label className={css.paymentLabel} htmlFor={`${formId}-card`}>
<FormattedMessage id="StripePaymentForm.creditCardDetails" />
</label>
<div
@ -179,8 +244,21 @@ class StripePaymentForm extends Component {
}}
/>
{this.state.error ? <span style={{ color: 'red' }}>{this.state.error}</span> : null}
<h3 className={css.messageHeading}>
<FormattedMessage id="StripePaymentForm.messageHeading" />
</h3>
<label className={css.messageLabel} htmlFor={`${formId}-message`}>
<FormattedMessage id="StripePaymentForm.messageLabel" values={{ messageOptionalText }} />
</label>
<ExpandingTextarea
id={`${formId}-message`}
className={css.message}
placeholder={messagePlaceholder}
value={this.state.message}
onChange={handleMessageChange}
/>
<div className={css.submitContainer}>
{paymentInfo ? <p className={css.paymentInfo}>{paymentInfo}</p> : null}
<p className={css.paymentInfo}>{paymentInfo}</p>
<PrimaryButton
className={css.submitButton}
type="submit"
@ -199,7 +277,7 @@ StripePaymentForm.defaultProps = {
className: null,
rootClassName: null,
inProgress: false,
paymentInfo: null,
onChange: () => null,
};
const { bool, func, string } = PropTypes;
@ -211,7 +289,9 @@ StripePaymentForm.propTypes = {
formId: string.isRequired,
intl: intlShape.isRequired,
onSubmit: func.isRequired,
paymentInfo: string,
onChange: func,
paymentInfo: string.isRequired,
authorDisplayName: string.isRequired,
};
export default injectIntl(StripePaymentForm);

View file

@ -184,6 +184,7 @@ const routeConfiguration = () => {
name: 'OrderDetailsPage',
component: props => <OrderPage {...props} tab="details" />,
loadData: OrderPage.loadData,
setInitialValues: OrderPage.setInitialValues,
},
{
path: '/order/:id/discussion',
@ -191,6 +192,7 @@ const routeConfiguration = () => {
name: 'OrderDiscussionPage',
component: props => <OrderPage {...props} tab="discussion" />,
loadData: OrderPage.loadData,
setInitialValues: OrderPage.setInitialValues,
},
{
path: '/sale/:id',

View file

@ -48,7 +48,6 @@
"CheckoutPage.listingNotFoundError": "Unfortunately the listing is not available anymore.",
"CheckoutPage.loadingData": "Loading checkout data…",
"CheckoutPage.paymentInfo": "You'll only be charged if your request is accepted by the provider.",
"CheckoutPage.paymentTitle": "Payment",
"CheckoutPage.priceBreakdownTitle": "Booking breakdown",
"CheckoutPage.speculateTransactionError": "Failed to fetch breakdown information.",
"CheckoutPage.title": "Book {listingTitle}",
@ -521,6 +520,11 @@
"StripeBankAccountTokenInputField.unsupportedCountry": "Country not supported: {country}",
"StripePaymentForm.creditCardDetails": "Credit card details",
"StripePaymentForm.genericError": "Could not handle payment data. Please try again.",
"StripePaymentForm.messageHeading": "Message",
"StripePaymentForm.messageLabel": "Say hello to your host {messageOptionalText}",
"StripePaymentForm.messageOptionalText": "• optional",
"StripePaymentForm.messagePlaceholder": "Hello {name}! I'm looking forward to…",
"StripePaymentForm.paymentHeading": "Payment",
"StripePaymentForm.stripe.api_connection_error": "Could not connect to Stripe API.",
"StripePaymentForm.stripe.api_error": "Error in Stripe API.",
"StripePaymentForm.stripe.authentication_error": "Could not authenticate with Stripe.",