Merge pull request #118 from sharetribe/submit-booking-dates-branchfix

Submit booking dates branchfix
This commit is contained in:
Vesa Luusua 2017-04-12 16:51:43 +03:00 committed by GitHub
commit a444e94827
19 changed files with 331 additions and 70 deletions

View file

@ -63,8 +63,8 @@ describe('Application', () => {
const urlRedirects = {
'/l/new': '/login',
'/l/listing-title-slug/1234/edit': '/login',
'/l/listing-title-slug/1234/checkout': '/login',
'/u/1234/edit': '/login',
'/checkout': '/login',
'/orders': '/login',
'/sales': '/login',
'/order/1234': '/login',

View file

@ -0,0 +1,21 @@
.root {
width: 100%;
display: flex;
flex-direction: row;
}
.authorDetails {
width: 100%;
display: flex;
flex-direction: column;
}
.avatarWrapper {
display: block;
flex-basis: 44px;
width: 44px;
height: 44px;
margin-right: 1rem;
}
.avatar {
border-radius: 22px;
}

View file

@ -0,0 +1,35 @@
import React, { PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import * as propTypes from '../../util/propTypes';
import css from './AuthorInfo.css';
const AuthorInfo = props => {
const { className, author } = props;
const classes = classNames(css.root, className);
const currentAuthor = { id: null, type: 'user', attributes: {}, ...author };
const authorName = currentAuthor.attributes.profile
? `${currentAuthor.attributes.profile.firstName} ${currentAuthor.attributes.profile.lastName}`
: '';
const authorAvatar = 'https://placehold.it/44x44';
return (
<div className={classes}>
<div className={css.avatarWrapper}>
<img className={css.avatar} src={authorAvatar} alt={authorName} />
</div>
<div className={css.authorDetails}>
<span className={css.authorName}>
<FormattedMessage id="AuthorInfo.host" values={{ authorName }} />
</span>
</div>
</div>
);
};
const { string } = PropTypes;
AuthorInfo.defaultProps = { className: null };
AuthorInfo.propTypes = { author: propTypes.user.isRequired, className: string };
export default AuthorInfo;

View file

@ -1,4 +1,5 @@
import AddImages from './AddImages/AddImages';
import AuthorInfo from './AuthorInfo/AuthorInfo';
import BookingInfo from './BookingInfo/BookingInfo';
import Button from './Button/Button';
import CurrencyInput from './CurrencyInput/CurrencyInput';
@ -28,6 +29,7 @@ import StripeBankAccountToken from './StripeBankAccountToken/StripeBankAccountTo
export {
AddImages,
AuthorInfo,
BookingInfo,
Button,
CurrencyInput,

View file

@ -58,8 +58,9 @@ export const BookingDatesFormComponent = props => {
submitting,
} = props;
const placeholderText = intl.formatMessage({ id: 'BookingDatesForm.placeholder' });
const bookingStartLabel = intl.formatMessage({ id: 'BookingDatesForm.bookingStartTitle'});
const bookingEndLabel = intl.formatMessage({ id: 'BookingDatesForm.bookingEndTitle'});
const bookingStartLabel = intl.formatMessage({ id: 'BookingDatesForm.bookingStartTitle' });
const bookingEndLabel = intl.formatMessage({ id: 'BookingDatesForm.bookingEndTitle' });
const priceRequiredMessage = intl.formatMessage({ id: 'BookingDatesForm.priceRequired' });
const requiredMessage = intl.formatMessage({ id: 'BookingDatesForm.requiredDate' });
// A day is outside range if it is between today and booking end date (if end date has been chosen)
@ -72,7 +73,9 @@ export const BookingDatesFormComponent = props => {
: {};
// A day is outside range if it is after booking start date (or today if none is chosen)
const startOfBookingEndRange = bookingStart ? moment(bookingStart) : moment();
const startOfBookingEndRange = bookingStart
? moment(bookingStart).add(1, 'days')
: moment().add(1, 'days');
const isOutsideRangeEnd = bookingStart
? { isOutsideRange: day => !isInclusivelyAfterDay(day, startOfBookingEndRange) }
: {};
@ -84,9 +87,9 @@ export const BookingDatesFormComponent = props => {
bookingEnd={bookingEnd}
unitPrice={price}
/>
: null;
: <p className={css.error}>{priceRequiredMessage}</p>;
const invalid = !(bookingStart && bookingEnd);
const invalid = !(bookingStart && bookingEnd && price);
return (
<form className={className} onSubmit={handleSubmit}>
@ -119,10 +122,12 @@ export const BookingDatesFormComponent = props => {
);
};
BookingDatesFormComponent.defaultProps = { price: null };
BookingDatesFormComponent.propTypes = {
...formPropTypes,
intl: intlShape.isRequired,
price: instanceOf(types.Money).isRequired,
price: instanceOf(types.Money),
};
const formName = 'bookingDates';

View file

@ -2,6 +2,14 @@
margin: 1rem;
}
.authorContainer {
margin: 1rem;
}
.receipt {
margin: 1rem 1rem 2rem 1rem;
}
.payment {
padding: 0 1rem;
}

View file

@ -1,5 +1,9 @@
import { pick } from 'lodash';
// ================ Action types ================ //
export const SET_INITAL_VALUES = 'app/CheckoutPage/SET_INITIAL_VALUES';
export const INITIATE_ORDER_REQUEST = 'app/CheckoutPage/INITIATE_ORDER_REQUEST';
export const INITIATE_ORDER_SUCCESS = 'app/CheckoutPage/INITIATE_ORDER_SUCCESS';
export const INITIATE_ORDER_ERROR = 'app/CheckoutPage/INITIATE_ORDER_ERROR';
@ -7,12 +11,16 @@ export const INITIATE_ORDER_ERROR = 'app/CheckoutPage/INITIATE_ORDER_ERROR';
// ================ Reducer ================ //
const initialState = {
bookingDates: null,
initiateOrderError: null,
listing: null,
};
export default function checkoutPageReducer(state = initialState, action = {}) {
const { type, payload } = action;
switch (type) {
case SET_INITAL_VALUES:
return { ...state, ...payload };
case INITIATE_ORDER_REQUEST:
return { ...state, initiateOrderError: null };
case INITIATE_ORDER_SUCCESS:
@ -25,8 +33,15 @@ export default function checkoutPageReducer(state = initialState, action = {}) {
}
}
// ================ Selectors ================ //
// ================ Action creators ================ //
export const setInitialValues = initialValues => ({
type: SET_INITAL_VALUES,
payload: pick(initialValues, Object.keys(initialState)),
});
const initiateOrderRequest = () => ({ type: INITIATE_ORDER_REQUEST });
const initiateOrderSuccess = orderId => ({
@ -59,3 +74,5 @@ export const initiateOrder = params =>
throw e;
});
};
// ================ Thunk ================ //

View file

@ -3,32 +3,20 @@ import { compose } from 'redux';
import { connect } from 'react-redux';
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import { withRouter } from 'react-router-dom';
import { types } from '../../util/sdkLoader';
import { pathByRouteName } from '../../util/routes';
import * as propTypes from '../../util/propTypes';
import { withFlattenedRoutes } from '../../util/contextHelpers';
import { PageLayout } from '../../components';
import { AuthorInfo, BookingInfo, NamedRedirect, PageLayout } from '../../components';
import { StripePaymentForm } from '../../containers';
import { initiateOrder } from './CheckoutPage.duck';
import { initiateOrder, setInitialValues } from './CheckoutPage.duck';
import css from './CheckoutPage.css';
const { UUID, LatLng } = types;
const bookingStart = new Date(2017, 4, 18);
const bookingEnd = new Date(2017, 4, 19);
const listing = {
id: new UUID('927a30a2-3a69-4b0d-9c2e-a41744488703'),
type: 'listing',
attributes: {
title: 'Example listing',
description: 'Listing description here.',
address: 'Helsinki, Finland',
geolocation: new LatLng(60.16985569999999, 24.93837899999994),
},
const ensureListingProperties = listing => {
const empty = { id: null, type: 'listing', attributes: {}, author: {}, images: [] };
// assume own properties: id, type, attributes etc.
return { ...empty, ...listing };
};
const imageUrl = 'https://placehold.it/750x470';
export class CheckoutPageComponent extends Component {
constructor(props) {
@ -36,18 +24,15 @@ export class CheckoutPageComponent extends Component {
this.state = { submitting: false };
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit(cardToken) {
if (this.state.submitting) {
return;
}
this.setState({ submitting: true });
const { sendOrderRequest, history, flattenedRoutes } = this.props;
const params = {
listingId: listing.id,
bookingStart,
bookingEnd,
cardToken,
};
const { bookingDates, flattenedRoutes, history, sendOrderRequest, listing } = this.props;
const params = { listingId: listing.id, cardToken, ...bookingDates };
sendOrderRequest(params)
.then(orderId => {
this.setState({ submitting: false });
@ -60,15 +45,27 @@ export class CheckoutPageComponent extends Component {
this.setState({ submitting: false });
});
}
render() {
const { initiateOrderError, intl } = this.props;
const { bookingDates, initiateOrderError, intl, listing, params } = this.props;
const { bookingStart, bookingEnd } = bookingDates || {};
const currentListing = ensureListingProperties(listing);
const price = currentListing.attributes.price;
if (!listing || !price) {
// eslint-disable-next-line no-console
console.error(
`Listing price is undefined for listing (${currentListing.id}). Redirecting to SearchPage.`
);
return <NamedRedirect name="ListingPage" params={params} />;
}
const title = intl.formatMessage(
{
id: 'CheckoutPage.title',
},
{
listingTitle: listing.attributes.title,
listingTitle: currentListing.attributes.title,
}
);
@ -81,7 +78,13 @@ export class CheckoutPageComponent extends Component {
return (
<PageLayout title={title}>
<h1 className={css.title}>{title}</h1>
<img alt={listing.attributes.title} src={imageUrl} style={{ width: '100%' }} />
<AuthorInfo author={currentListing.author} className={css.authorContainer} />
<BookingInfo
className={css.receipt}
bookingStart={bookingStart}
bookingEnd={bookingEnd}
unitPrice={price}
/>
<section className={css.payment}>
{errorMessage}
<h2 className={css.paymentTitle}>
@ -97,12 +100,25 @@ export class CheckoutPageComponent extends Component {
}
}
CheckoutPageComponent.defaultProps = { initiateOrderError: null };
CheckoutPageComponent.defaultProps = {
bookingDates: null,
initiateOrderError: null,
listing: null,
};
const { func, shape, arrayOf, instanceOf } = PropTypes;
const { arrayOf, func, instanceOf, shape, string } = PropTypes;
CheckoutPageComponent.propTypes = {
bookingDates: shape({
bookingStart: instanceOf(Date).isRequired,
bookingEnd: instanceOf(Date).isRequired,
}),
initiateOrderError: instanceOf(Error),
listing: propTypes.listing,
params: shape({
id: string,
slug: string,
}).isRequired,
sendOrderRequest: func.isRequired,
// from injectIntl
@ -117,9 +133,10 @@ CheckoutPageComponent.propTypes = {
flattenedRoutes: arrayOf(propTypes.route).isRequired,
};
const mapStateToProps = state => ({
initiateOrderError: state.CheckoutPage.initiateOrderError,
});
const mapStateToProps = state => {
const { initiateOrderError, listing, bookingDates } = state.CheckoutPage;
return { initiateOrderError, listing, bookingDates };
};
const mapDispatchToProps = dispatch => ({
sendOrderRequest: params => dispatch(initiateOrder(params)),
@ -132,4 +149,8 @@ const CheckoutPage = compose(
injectIntl
)(CheckoutPageComponent);
CheckoutPage.setInitialValues = initialValues => setInitialValues(initialValues);
CheckoutPage.displayName = 'CheckoutPage';
export default CheckoutPage;

View file

@ -1,19 +1,65 @@
import React from 'react';
import { renderShallow } from '../../util/test-helpers';
import { fakeIntl } from '../../util/test-data';
import { createUser, createListing, fakeIntl } from '../../util/test-data';
import { CheckoutPageComponent } from './CheckoutPage';
import checkoutPageReducer, { SET_INITAL_VALUES, setInitialValues } from './CheckoutPage.duck';
const noop = () => null;
describe('CheckoutPage', () => {
it('matches snapshot', () => {
const props = {
intl: fakeIntl,
sendOrderRequest: noop,
history: { push: noop },
bookingDates: {
bookingStart: new Date('Fri, 14 Apr 2017 GMT'),
bookingEnd: new Date('Sun, 16 Apr 2017 GMT'),
},
flattenedRoutes: [],
history: { push: noop },
intl: fakeIntl,
listing: { ...createListing('listing1'), author: createUser('author') },
params: { id: 'listing1', slug: 'listing1' },
sendOrderRequest: noop,
};
const tree = renderShallow(<CheckoutPageComponent {...props} />);
expect(tree).toMatchSnapshot();
});
describe('Duck', () => {
it('ActionCreator: setInitialValues(initialValues)', () => {
const author = createUser('author1');
const listing = { ...createListing('00000000-0000-0000-0000-000000000000'), author };
const bookingDates = {
bookingStart: new Date('Fri, 14 Apr 2017 GMT'),
bookingEnd: new Date('Sun, 16 Apr 2017 GMT'),
};
const expectedAction = {
type: SET_INITAL_VALUES,
payload: { listing, bookingDates },
};
expect(setInitialValues({ listing, bookingDates })).toEqual(expectedAction);
});
describe('Reducer', () => {
it('should return the initial state', () => {
const initialValues = {
initiateOrderError: null,
listing: null,
bookingDates: null,
};
expect(checkoutPageReducer(undefined, {})).toEqual(initialValues);
});
it('should handle SET_INITAL_VALUES', () => {
const author = createUser('author1');
const listing = { ...createListing('00000000-0000-0000-0000-000000000000'), author };
const bookingDates = {
bookingStart: new Date('Fri, 14 Apr 2017 GMT'),
bookingEnd: new Date('Sun, 16 Apr 2017 GMT'),
};
const payload = { listing, bookingDates };
expect(checkoutPageReducer({}, { type: SET_INITAL_VALUES, payload })).toEqual(payload);
});
});
});
});

View file

@ -4,12 +4,31 @@ exports[`CheckoutPage matches snapshot 1`] = `
<h1>
CheckoutPage.title
</h1>
<img
alt="Example listing"
src="https://placehold.it/750x470"
style={
<AuthorInfo
author={
Object {
"width": "100%",
"attributes": Object {
"email": "author@example.com",
"profile": Object {
"firstName": "author first name",
"lastName": "author last name",
"slug": "author-slug",
},
},
"id": UUID {
"uuid": "author",
},
"type": "user",
}
}
className={null} />
<BookingInfo
bookingEnd={2017-04-16T00:00:00.000Z}
bookingStart={2017-04-14T00:00:00.000Z}
unitPrice={
Money {
"amount": 5500,
"currency": "USD",
}
} />
<section>

View file

@ -1,10 +1,14 @@
import React, { Component, PropTypes } from 'react';
import { intlShape, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { union, without } from 'lodash';
import config from '../../config';
import * as propTypes from '../../util/propTypes';
import { types } from '../../util/sdkLoader';
import { createSlug } from '../../util/urlHelpers';
import { convertMoneyToNumber } from '../../util/currency';
import { createResourceLocatorString, findRouteByRouteName } from '../../util/routes';
import { Button, Map, ModalInMobile, PageLayout } from '../../components';
import { BookingDatesForm } from '../../containers';
import { getListingsById } from '../../ducks/sdk.duck';
@ -16,6 +20,12 @@ const MODAL_BREAKPOINT = 2500;
const { UUID } = types;
const denormaliseListing = (marketplaceData, params) => {
const id = new UUID(params.id);
const listingsById = getListingsById(marketplaceData, [id]);
return listingsById.length > 0 ? listingsById[0] : null;
};
const priceData = (price, currencyConfig, intl) => {
if (price && price.currency === currencyConfig.currency) {
const priceAsNumber = convertMoneyToNumber(price, currencyConfig.subUnitDivisor);
@ -46,14 +56,24 @@ export class ListingPageComponent extends Component {
}
onSubmit(values) {
const { marketplaceData, params } = this.props;
const id = new UUID(params.id);
const listingsById = getListingsById(marketplaceData, [id]);
const currentListing = listingsById.length > 0 ? listingsById[0] : null;
const { dispatch, flattenedRoutes, history, marketplaceData, params } = this.props;
const listing = denormaliseListing(marketplaceData, params);
this.setState({ isBookingModalOpenOnMobile: false });
// eslint-disable-next-line no-console
console.log('Submitting: bookedDates', values, 'and price', currentListing.attributes.price);
// Customize checkout page state with current listing and selected bookingDates
const { setInitialValues } = findRouteByRouteName('CheckoutPage', flattenedRoutes);
dispatch(setInitialValues({ listing, bookingDates: values }));
// Redirect to CheckoutPage
history.push(
createResourceLocatorString(
'CheckoutPage',
flattenedRoutes,
{ id: listing.id.uuid, slug: createSlug(listing.attributes.title) },
{}
)
);
}
togglePageClassNames(className, addClass = true) {
@ -70,9 +90,7 @@ export class ListingPageComponent extends Component {
render() {
const { params, marketplaceData, showListingError, intl } = this.props;
const currencyConfig = config.currencyConfig;
const id = new UUID(params.id);
const listingsById = getListingsById(marketplaceData, [id]);
const currentListing = listingsById.length > 0 ? listingsById[0] : null;
const currentListing = denormaliseListing(marketplaceData, params);
const attributes = currentListing ? currentListing.attributes : {};
const {
@ -158,9 +176,17 @@ ListingPageComponent.defaultProps = {
tab: 'listing',
};
const { instanceOf, object, oneOf, shape, string } = PropTypes;
const { arrayOf, func, instanceOf, object, oneOf, shape, string } = PropTypes;
ListingPageComponent.propTypes = {
// from connect
dispatch: func.isRequired,
flattenedRoutes: arrayOf(propTypes.route).isRequired,
// from withRouter
history: shape({
push: func.isRequired,
}).isRequired,
// from injectIntl
intl: intlShape.isRequired,
marketplaceData: object.isRequired,
params: shape({
@ -176,7 +202,7 @@ const mapStateToProps = state => ({
showListingError: state.ListingPage.showListingError,
});
const ListingPage = connect(mapStateToProps)(injectIntl(ListingPageComponent));
const ListingPage = connect(mapStateToProps)(withRouter(injectIntl(ListingPageComponent)));
ListingPage.loadData = params => {
const id = new UUID(params.id);

View file

@ -12,6 +12,7 @@ describe('ListingPage', () => {
it('matches snapshot', () => {
const marketplaceData = { entities: { listing: { listing1: createListing('listing1') } } };
const props = {
dispatch: () => console.log('Dispatch called'),
flattenedRoutes: [],
location: { search: '' },
history: {

View file

@ -3,9 +3,9 @@
* We are following Ducks module proposition:
* https://github.com/erikras/ducks-modular-redux
*/
import CheckoutPage from './CheckoutPage/CheckoutPage.duck';
import EditListingPage from './EditListingPage/EditListingPage.duck';
import ListingPage from './ListingPage/ListingPage.duck';
import SearchPage from './SearchPage/SearchPage.duck';
import CheckoutPage from './CheckoutPage/CheckoutPage.duck';
export { EditListingPage, ListingPage, SearchPage, CheckoutPage };
export { CheckoutPage, EditListingPage, ListingPage, SearchPage };

View file

@ -75,6 +75,14 @@ const routesConfiguration = [
loadData: (params, search) => ListingPage.loadData(params, search),
component: props => <ListingPage {...props} tab="book" />,
},
{
path: '/l/:slug/:id/checkout',
auth: true,
exact: true,
name: 'CheckoutPage',
setInitialValues: initialValues => CheckoutPage.setInitialValues(initialValues),
component: props => <CheckoutPage {...props} />,
},
{
auth: true,
path: '/l/new',
@ -115,13 +123,6 @@ const routesConfiguration = [
},
],
},
{
path: '/checkout',
auth: true,
exact: true,
name: 'CheckoutPage',
component: props => <CheckoutPage {...props} />,
},
{
path: '/login',
exact: true,

View file

@ -1,9 +1,11 @@
{
"AuthenticationPage.loginFailed": "The email and password you entered did not match our records. Please double-check and try again.",
"AuthenticationPage.loginRequiredFor": "You must log in to view the page at",
"AuthorInfo.host": "Hosted by {authorName}",
"BookingDatesForm.bookingEndTitle": "End date",
"BookingDatesForm.bookingStartTitle": "Start date",
"BookingDatesForm.placeholder": "mm/dd/yyyy",
"BookingDatesForm.priceRequired": "Oops, this listing has no price!",
"BookingDatesForm.requiredDate": "Oops, make sure your date is correct!",
"BookingDatesForm.requestToBook": "Request to book",
"BookingDatesForm.youWontBeChargedInfo": "You won't be charged yet",

View file

@ -114,6 +114,7 @@ export const listing = shape({
description: string.isRequired,
address: string.isRequired,
geolocation: latlng.isRequired,
price: money,
}),
author: user,
images: arrayOf(image),

View file

@ -83,3 +83,22 @@ export const createResourceLocatorString = (
const path = pathByRouteName(routeName, flattenedRoutes, pathParams);
return `${path}${includeSearchQuery}`;
};
/**
* Find component related to route name
* E.g. `const PageComponent = findComponentByRouteName('CheckoutPage', routes);`
* Then we can call static methods of given component:
* `dispatch(PageComponent.setInitialValues({ listing, bookingDates }));`
*
* @param {String} nameToFind - Route name
* @param {Array<{ route }>} flattenedRoutes - Route configuration as flattened array.
*
* @return {Route} - Route that matches the given route name.
*/
export const findRouteByRouteName = (nameToFind, flattenedRoutes) => {
const route = findRouteByName(nameToFind, flattenedRoutes);
if (!route) {
throw new Error(`Component "${nameToFind}" was not found.`);
}
return route;
};

View file

@ -3,7 +3,7 @@ import { RoutesProvider } from '../components';
import routesConfiguration from '../routesConfiguration';
import { renderDeep, renderShallow } from './test-helpers';
import * as propTypes from './propTypes';
import { createResourceLocatorString, flattenRoutes } from './routes';
import { createResourceLocatorString, flattenRoutes, findRouteByRouteName } from './routes';
const { arrayOf } = PropTypes;
@ -73,4 +73,19 @@ describe('util/routes.js', () => {
).toEqual('/l/nice-listing/1234?extrainfo=true');
});
});
describe('findRouteByRouteName', () => {
const flattenedRoutes = flattenRoutes(routesConfiguration);
it('should return CheckoutPage route', () => {
const foundRoute = findRouteByRouteName('CheckoutPage', flattenedRoutes);
expect(foundRoute.name).toEqual('CheckoutPage');
expect(typeof foundRoute.setInitialValues).toEqual('function');
});
it('should throw exception for non-existing route (BlaaBlaaPage)', () => {
expect(() => findRouteByRouteName('BlaaBlaaPage', flattenedRoutes)).toThrowError(
'Component "BlaaBlaaPage" was not found.'
);
});
});
});

View file

@ -12,6 +12,28 @@ export const createUser = id => ({
},
});
// Create a user that conforms to the util/propTypes user schema
export const createImage = id => ({
id: new UUID(id),
type: 'image',
attributes: {
sizes: [
{
name: 'square',
height: 408,
width: 408,
url: 'https://placehold.it/408x408',
},
{
name: 'square2x',
height: 816,
width: 816,
url: 'https://placehold.it/816x816',
},
],
},
});
// Create a user that conforms to the util/propTypes listing schema
export const createListing = id => ({
id: new UUID(id),