Merge pull request #126 from sharetribe/salepage-real-data

Salepage: fetching actual transaction data with SDK
This commit is contained in:
Vesa Luusua 2017-04-27 17:03:28 +03:00 committed by GitHub
commit 86c245dab9
26 changed files with 784 additions and 141 deletions

View file

@ -1,7 +1,7 @@
import { types } from '../../util/sdkLoader';
import BookingInfo from './BookingInfo';
export const Empty = {
export const BeforeTX = {
component: BookingInfo,
props: {
unitPrice: new types.Money(10, 'USD'),
@ -9,3 +9,36 @@ export const Empty = {
bookingEnd: new Date(Date.UTC(2017, 3, 16)),
},
};
export const TXCustomerSide = {
component: BookingInfo,
props: {
unitPrice: new types.Money(10, 'USD'),
bookingStart: new Date(Date.UTC(2017, 3, 14)),
bookingEnd: new Date(Date.UTC(2017, 3, 16)),
totalPrice: new types.Money(20, 'USD'),
},
};
export const TXProviderSide = {
component: BookingInfo,
props: {
unitPrice: new types.Money(10, 'USD'),
bookingStart: new Date(Date.UTC(2017, 3, 14)),
bookingEnd: new Date(Date.UTC(2017, 3, 16)),
subtotalPrice: new types.Money(20, 'USD'),
commission: new types.Money(2, 'USD'),
},
};
export const TXNoCalculation = {
component: BookingInfo,
props: {
unitPrice: new types.Money(10, 'USD'),
bookingStart: new Date(Date.UTC(2017, 3, 14)),
bookingEnd: new Date(Date.UTC(2017, 3, 16)),
subtotalPrice: new types.Money(20, 'USD'),
commission: new types.Money(2, 'USD'),
totalPrice: new types.Money(18, 'USD'),
},
};

View file

@ -13,39 +13,80 @@ import { types } from '../../util/sdkLoader';
import css from './BookingInfo.css';
const BookingInfoComponent = props => {
const { bookingStart, bookingEnd, className, intl, totalPrice, unitPrice } = props;
const { bookingStart, bookingEnd, className, commission, intl, subtotalPrice, totalPrice, unitPrice } = props;
const hasSelectedDays = bookingStart && bookingEnd;
const bookingPeriod = hasSelectedDays
? <FormattedMessage
id="BookingInfo.bookingPeriod"
values={{
bookingStart: intl.formatDate(bookingStart),
bookingEnd: intl.formatDate(bookingEnd),
}}
/>
: '';
const nightCount = hasSelectedDays ? moment(bookingEnd).diff(moment(bookingStart), 'days') : null;
const nightCountMessage = nightCount
? <FormattedMessage id="BookingInfo.nightCount" values={{ count: nightCount }} />
: null;
const hasSelectedNights = bookingStart && bookingEnd;
// If there's not enough info, we print empty container
if (!hasSelectedNights) {
return <div className={classNames(css.container, className)} />;
}
const bookingPeriod = <FormattedMessage
id="BookingInfo.bookingPeriod"
values={{
bookingStart: intl.formatDate(bookingStart),
bookingEnd: intl.formatDate(bookingEnd),
}}
/>;
// diff gives night count between dates
const nightCount = moment(bookingEnd).diff(moment(bookingStart), 'days');
const nightCountMessage = <FormattedMessage id="BookingInfo.nightCount" values={{ count: nightCount }} />;
const currencyConfig = config.currencyConfig;
const subUnitDivisor = currencyConfig.subUnitDivisor;
const unitPriceAsNumber = convertMoneyToNumber(unitPrice, subUnitDivisor);
const formattedUnitPrice = intl.formatNumber(unitPriceAsNumber, currencyConfig);
const calculatedTotalPriceAsNumber = !totalPrice && hasSelectedDays
? new Decimal(unitPriceAsNumber).times(nightCount).toNumber()
// Subtotal price can be given (when it comes from API)
// or calculated: unit price * booked nights
const subtotalPriceAsNumber = subtotalPrice
? convertMoneyToNumber(subtotalPrice, subUnitDivisor)
: new Decimal(unitPriceAsNumber).times(nightCount).toNumber();
const formattedSubtotal = commission
? intl.formatNumber(subtotalPriceAsNumber, currencyConfig)
: null;
// Total price can be given (when it comes from API) or calculated based on unit price and nights
// If commission is passed it will be reduced from sub total.
const commissionAsNumber = commission ? convertMoneyToNumber(commission, subUnitDivisor) : 0;
const formattedCommission = commission
? intl.formatNumber(new Decimal(commissionAsNumber).negated().toNumber(), currencyConfig)
: null;
// Total price can be given (when it comes from API)
// or calculated: sub total - commission
const totalPriceAsNumber = totalPrice
? convertMoneyToNumber(totalPrice, subUnitDivisor)
: calculatedTotalPriceAsNumber;
: new Decimal(subtotalPriceAsNumber).minus(commissionAsNumber).toNumber();
const formattedTotalPrice = totalPriceAsNumber
? intl.formatNumber(totalPriceAsNumber, currencyConfig)
: null;
// Sub total is shown if commission is given
const subtotalInfo = commission
? <div className={css.row}>
<div className={css.subtotalLabel}>
<FormattedMessage id="BookingInfo.subtotal" />
</div>
<div className={css.subtotal}>
{formattedSubtotal}
</div>
</div>
: null;
const commisionInfo = commission
? <div className={css.row}>
<div className={css.commissionLabel}>
<FormattedMessage id="BookingInfo.commission" />
</div>
<div className={css.commission}>
{formattedCommission}
</div>
</div>
: null;
return (
<div className={classNames(css.container, className)}>
<div className={css.row}>
@ -66,6 +107,8 @@ const BookingInfoComponent = props => {
{nightCountMessage}
</div>
</div>
{subtotalInfo}
{commisionInfo}
<hr className={css.totalDivider} />
<div className={css.row}>
<div className={css.totalLabel}>
@ -83,7 +126,9 @@ BookingInfoComponent.defaultProps = {
bookingStart: null,
bookingEnd: null,
className: '',
subtotalPrice: null,
totalPrice: null,
commission: null,
};
const { instanceOf, string } = PropTypes;
@ -92,7 +137,9 @@ BookingInfoComponent.propTypes = {
bookingStart: instanceOf(Date),
bookingEnd: instanceOf(Date),
className: string,
commission: instanceOf(types.Money),
intl: intlShape.isRequired,
subtotalPrice: instanceOf(types.Money),
totalPrice: instanceOf(types.Money),
unitPrice: instanceOf(types.Money).isRequired,
};

View file

@ -5,7 +5,7 @@ import { types } from '../../util/sdkLoader';
import BookingInfo from './BookingInfo';
describe('BookingInfo', () => {
it('matches snapshot', () => {
it('pretransaction data matches snapshot', () => {
const tree = renderDeep(
<BookingInfo
unitPrice={new types.Money(1000, 'USD')}
@ -16,4 +16,31 @@ describe('BookingInfo', () => {
);
expect(tree).toMatchSnapshot();
});
it('customer transaction data matches snapshot', () => {
const tree = renderDeep(
<BookingInfo
unitPrice={new types.Money(1000, 'USD')}
bookingStart={new Date(Date.UTC(2017, 3, 14))}
bookingEnd={new Date(Date.UTC(2017, 3, 16))}
totalPrice={new types.Money(2000, 'USD')}
intl={fakeIntl}
/>
);
expect(tree).toMatchSnapshot();
});
it('provider transaction data matches snapshot', () => {
const tree = renderDeep(
<BookingInfo
unitPrice={new types.Money(1000, 'USD')}
bookingStart={new Date(Date.UTC(2017, 3, 14))}
bookingEnd={new Date(Date.UTC(2017, 3, 16))}
subtotalPrice={new types.Money(2000, 'USD')}
commission={new types.Money(200, 'USD')}
intl={fakeIntl}
/>
);
expect(tree).toMatchSnapshot();
});
});

View file

@ -1,4 +1,4 @@
exports[`BookingInfo matches snapshot 1`] = `
exports[`BookingInfo customer transaction data matches snapshot 1`] = `
<div
className="">
<div
@ -50,3 +50,135 @@ exports[`BookingInfo matches snapshot 1`] = `
</div>
</div>
`;
exports[`BookingInfo pretransaction data matches snapshot 1`] = `
<div
className="">
<div
className={undefined}>
<div
className={undefined}>
<span>
Price per night:
</span>
</div>
<div
className={undefined}>
$10.00
</div>
</div>
<div
className={undefined}>
<div
className={undefined}>
<span>
Booking period:
</span>
<br />
<span>
4/14/2017 - 4/16/2017
</span>
</div>
<div
className={undefined}>
<span>
2 nights
</span>
</div>
</div>
<hr
className={undefined} />
<div
className={undefined}>
<div
className={undefined}>
<span>
Total:
</span>
</div>
<div
className={undefined}>
$20.00
</div>
</div>
</div>
`;
exports[`BookingInfo provider transaction data matches snapshot 1`] = `
<div
className="">
<div
className={undefined}>
<div
className={undefined}>
<span>
Price per night:
</span>
</div>
<div
className={undefined}>
$10.00
</div>
</div>
<div
className={undefined}>
<div
className={undefined}>
<span>
Booking period:
</span>
<br />
<span>
4/14/2017 - 4/16/2017
</span>
</div>
<div
className={undefined}>
<span>
2 nights
</span>
</div>
</div>
<div
className={undefined}>
<div
className={undefined}>
<span>
Subtotal:
</span>
</div>
<div
className={undefined}>
$20.00
</div>
</div>
<div
className={undefined}>
<div
className={undefined}>
<span>
Charged commission:
</span>
</div>
<div
className={undefined}>
-$2.00
</div>
</div>
<hr
className={undefined} />
<div
className={undefined}>
<div
className={undefined}>
<span>
Total:
</span>
</div>
<div
className={undefined}>
$18.00
</div>
</div>
</div>
`;

View file

@ -0,0 +1,23 @@
.messagesContainer {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 2rem;
}
.title,
.message {
text-align: center;
}
.receipt {
margin: 1rem 1rem 2rem 1rem;
}
.avatarWrapper {
display: block;
flex-basis: 44px;
width: 44px;
height: 44px;
margin-right: 1rem;
}

View file

@ -0,0 +1,78 @@
import React, { PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import * as propTypes from '../../util/propTypes';
import { types } from '../../util/sdkLoader';
import { createSlug } from '../../util/urlHelpers';
import { Avatar, BookingInfo, NamedLink } from '../../components';
import css from './SaleDetailsPanel.css';
const SaleDetailsPanel = props => {
const { className, subtotalPrice, saleState, booking, listing, customer, commission } = props;
const { firstName, lastName } = customer.attributes.profile;
const customerName = firstName ? `${firstName} ${lastName}` : '';
const listingLinkParams = { id: listing.id.uuid, slug: createSlug(listing.attributes.title) };
const listingLink = (
<NamedLink name="ListingPage" params={listingLinkParams}>
{listing.attributes.title}
</NamedLink>
);
const title = saleState === propTypes.TX_STATE_PREAUTHORIZED
? <FormattedMessage
id="SaleDetailsPanel.listingTitle"
values={{ customerName: customerName, title: listingLink }}
/>
: null;
const message = saleState === propTypes.TX_STATE_PREAUTHORIZED
? <div className={css.message}>
<FormattedMessage id="SaleDetailsPanel.saleStatusMessage" values={{ customerName }} />
</div>
: null;
// TODO We can't use price from listing, since that might have changed.
// When API includes unit price and possible additional fees, we need to change this.
const unitPrice = listing.attributes.price;
const bookingInfo = unitPrice
? <BookingInfo
className={css.receipt}
bookingStart={booking.attributes.start}
bookingEnd={booking.attributes.end}
unitPrice={unitPrice}
commission={commission}
subtotalPrice={subtotalPrice}
/>
: <p className={css.error}>{'priceRequiredMessage'}</p>;
return (
<div className={className}>
<div className={css.messagesContainer}>
<div className={css.avatarWrapper}>
<Avatar name={customerName} />
</div>
<h1 className={css.title}>{title}</h1>
{message}
</div>
{bookingInfo}
</div>
);
};
SaleDetailsPanel.defaultProps = { className: null };
const { instanceOf, string } = PropTypes;
SaleDetailsPanel.propTypes = {
className: string,
subtotalPrice: instanceOf(types.Money).isRequired,
commission: instanceOf(types.Money).isRequired,
saleState: string.isRequired,
booking: propTypes.booking.isRequired,
listing: propTypes.listing.isRequired,
customer: propTypes.user.isRequired,
};
export default SaleDetailsPanel;

View file

@ -0,0 +1,25 @@
import React from 'react';
import { types } from '../../util/sdkLoader';
import { createBooking, createListing, createUser } from '../../util/test-data';
import { renderShallow } from '../../util/test-helpers';
import SaleDetailsPanel from './SaleDetailsPanel.js';
describe('SaleDetailsPanel', () => {
it('matches snapshot', () => {
const { Money } = types;
const props = {
commission: new Money(1650, 'USD'),
subtotalPrice: new Money(16500, 'USD'),
saleState: 'state/preauthorized',
booking: createBooking(
'booking1',
new Date(Date.UTC(2017, 5, 10)),
new Date(Date.UTC(2017, 5, 13))
),
listing: createListing('listing1'),
customer: createUser('customer1'),
};
const tree = renderShallow(<SaleDetailsPanel {...props} />);
expect(tree).toMatchSnapshot();
});
});

View file

@ -0,0 +1,61 @@
exports[`SaleDetailsPanel matches snapshot 1`] = `
<div
className={null}>
<div>
<div>
<Avatar
className={null}
name="customer1 first name customer1 last name" />
</div>
<h1>
<FormattedMessage
id="SaleDetailsPanel.listingTitle"
values={
Object {
"customerName": "customer1 first name customer1 last name",
"title": <withFlattenedRoutes(withRouter(NamedLink))
name="ListingPage"
params={
Object {
"id": "listing1",
"slug": "listing1-title",
}
}>
listing1 title
</withFlattenedRoutes(withRouter(NamedLink))>,
}
} />
</h1>
<div>
<FormattedMessage
id="SaleDetailsPanel.saleStatusMessage"
values={
Object {
"customerName": "customer1 first name customer1 last name",
}
} />
</div>
</div>
<BookingInfo
bookingEnd={2017-06-13T00:00:00.000Z}
bookingStart={2017-06-10T00:00:00.000Z}
commission={
Money {
"amount": 1650,
"currency": "USD",
}
}
subtotalPrice={
Money {
"amount": 16500,
"currency": "USD",
}
}
unitPrice={
Money {
"amount": 5500,
"currency": "USD",
}
} />
</div>
`;

View file

@ -26,6 +26,7 @@ import PageLayout from './PageLayout/PageLayout';
import PaginationLinks from './PaginationLinks/PaginationLinks';
import Promised from './Promised/Promised';
import RoutesProvider from './RoutesProvider/RoutesProvider';
import SaleDetailsPanel from './SaleDetailsPanel/SaleDetailsPanel';
import SearchResultsPanel from './SearchResultsPanel/SearchResultsPanel';
import StripeBankAccountToken from './StripeBankAccountToken/StripeBankAccountToken';
@ -58,6 +59,7 @@ export {
PaginationLinks,
Promised,
RoutesProvider,
SaleDetailsPanel,
SearchResultsPanel,
StripeBankAccountToken,
};

View file

@ -33,7 +33,7 @@ const timestamp = (intl, tx) => {
// Translated name of the state of the given transaction
const txState = (intl, tx) => {
const { state } = tx;
const { attributes: { state } } = tx;
if (state === propTypes.TX_STATE_ACCEPTED) {
return intl.formatMessage({
id: 'InboxPage.stateAccepted',

View file

@ -47,7 +47,7 @@ export const fetchOrder = id =>
dispatch(fetchOrderRequest());
return sdk.transactions
.show({ id, include: ['provider', 'customer', 'listing', 'booking'] }, { expand: true })
.show({ id, include: ['provider', 'listing', 'booking'] }, { expand: true })
.then(response => {
dispatch(addEntities(response));
dispatch(fetchOrderSuccess(response));

View file

@ -4,41 +4,12 @@ import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import { OrderDetailsPanel, PageLayout } from '../../components';
import * as propTypes from '../../util/propTypes';
import { ensureBooking, ensureListing, ensureTransaction, ensureUser } from '../../util/data';
import { getEntities } from '../../ducks/sdk.duck';
import { loadData } from './OrderPage.duck';
import css from './OrderPage.css';
// Create shell objects to ensure that attributes etc. exists.
// TODO: these could be moved to separate util file, if needed elsewhere
const ensureTransaction = transaction => {
const empty = {
id: null,
type: 'transaction',
attributes: {},
booking: {},
listing: {},
provider: {},
};
// assume own properties: id, type, attributes etc.
return { ...empty, ...transaction };
};
const ensureBooking = booking => {
const empty = { id: null, type: 'booking', attributes: {} };
return { ...empty, ...booking };
};
const ensureListing = listing => {
const empty = { id: null, type: 'listing', attributes: {}, images: [] };
return { ...empty, ...listing };
};
const ensureUser = user => {
const empty = { id: null, type: 'user', attributes: { profile: {} } };
return { ...empty, ...user };
};
// OrderPage handles data loading
// It show loading data text or OrderDetailsPanel (and later also another panel for messages).
export const OrderPageComponent = props => {

View file

@ -0,0 +1,15 @@
.title {
margin: 1rem 1rem 2rem 1rem;
}
.tabContent {
display: none;
}
.tabContentVisible {
display: block;
}
.activeTab {
font-weight: bold;
}

View file

@ -0,0 +1,73 @@
import { types } from '../../util/sdkLoader';
import { addEntities } from '../../ducks/sdk.duck';
import { fetchCurrentUser } from '../../ducks/user.duck';
// ================ Action types ================ //
export const FETCH_SALE_REQUEST = 'app/InboxPage/FETCH_SALE_REQUEST';
export const FETCH_SALE_SUCCESS = 'app/InboxPage/FETCH_SALE_SUCCESS';
export const FETCH_SALE_ERROR = 'app/InboxPage/FETCH_SALE_ERROR';
// ================ Reducer ================ //
const initialState = {
fetchInProgress: false,
fetchSaleError: null,
transactionRef: null,
};
export default function checkoutPageReducer(state = initialState, action = {}) {
const { type, payload } = action;
switch (type) {
case FETCH_SALE_REQUEST:
return { ...state, fetchInProgress: true, fetchSaleError: null };
case FETCH_SALE_SUCCESS: {
const transactionRef = { id: payload.data.data.id, type: 'transaction' };
return { ...state, fetchInProgress: false, transactionRef };
}
case FETCH_SALE_ERROR:
console.error(payload); // eslint-disable-line
return { ...state, fetchInProgress: false, fetchSaleError: payload };
default:
return state;
}
}
// ================ Action creators ================ //
const fetchSaleRequest = () => ({ type: FETCH_SALE_REQUEST });
const fetchSaleSuccess = response => ({ type: FETCH_SALE_SUCCESS, payload: response });
const fetchSaleError = e => ({ type: FETCH_SALE_ERROR, error: true, payload: e });
// ================ Thunks ================ //
export const fetchSale = id =>
(dispatch, getState, sdk) => {
dispatch(fetchSaleRequest());
return sdk.transactions
.show({ id, include: ['customer', 'listing', 'booking'] }, { expand: true })
.then(response => {
dispatch(addEntities(response));
dispatch(fetchSaleSuccess(response));
return response;
})
.catch(e => {
dispatch(fetchSaleError(e));
throw e;
});
};
// loadData is a collection of async calls that need to be made
// before page has all the info it needs to render itself
export const loadData = params =>
dispatch => {
const saleId = new types.UUID(params.id);
// Current user is needed to render Topbar
dispatch(fetchCurrentUser());
// Sale (i.e. transaction entity in API, but from buyers perspective) contains sale details
return dispatch(fetchSale(saleId));
};

View file

@ -0,0 +1,72 @@
import React, { PropTypes } from 'react';
import classNames from 'classnames';
import { FormattedMessage, intlShape, injectIntl } from 'react-intl';
import { connect } from 'react-redux';
import * as propTypes from '../../util/propTypes';
import { ensureBooking, ensureListing, ensureTransaction, ensureUser } from '../../util/data';
import { SaleDetailsPanel, PageLayout } from '../../components';
import { getEntities } from '../../ducks/sdk.duck';
import { loadData } from './SalePage.duck';
import css from './SalePage.css';
// SalePage handles data loading
// It show loading data text or SaleDetailsPanel (and later also another panel for messages).
export const SalePageComponent = props => {
const { intl, transaction } = props;
const currentTransaction = ensureTransaction(transaction);
const currentListing = ensureListing(currentTransaction.listing);
const title = currentListing.attributes.title;
const detailsProps = {
subtotalPrice: currentTransaction.attributes.total,
commission: currentTransaction.attributes.commission,
saleState: currentTransaction.attributes.state,
listing: currentListing,
booking: ensureBooking(currentTransaction.booking),
customer: ensureUser(currentTransaction.customer),
};
const detailsClassName = classNames(css.tabContent, {
[css.tabContentVisible]: props.tab === 'details',
});
const panel = currentTransaction.id
? <SaleDetailsPanel className={detailsClassName} {...detailsProps} />
: <h1 className={css.title}><FormattedMessage id="SalePage.loadingData" /></h1>;
return (
<PageLayout title={intl.formatMessage({ id: 'SalePage.title' }, { title })}>
{panel}
</PageLayout>
);
};
SalePageComponent.defaultProps = { transaction: null };
const { oneOf } = PropTypes;
SalePageComponent.propTypes = {
intl: intlShape.isRequired,
tab: oneOf(['details', 'discussion']).isRequired,
transaction: propTypes.transaction,
};
const mapStateToProps = state => {
const transactionRef = state.SalePage.transactionRef;
const transactions = getEntities(state.data, transactionRef ? [transactionRef] : []);
const transaction = transactions.length > 0 ? transactions[0] : null;
return {
transaction,
showSaleError: state.ListingPage.showListingError,
currentUser: state.user.currentUser,
};
};
const SalePage = connect(mapStateToProps)(injectIntl(SalePageComponent));
SalePage.loadData = params => {
return loadData(params);
};
export default SalePage;

View file

@ -0,0 +1,35 @@
import React from 'react';
import {
createBooking,
createListing,
createTransaction,
createUser,
fakeIntl,
} from '../../util/test-data';
import { renderShallow } from '../../util/test-helpers';
import { SalePageComponent } from './SalePage';
describe('SalePage', () => {
it('matches snapshot', () => {
const transaction = createTransaction({
id: 'tx-sale-1',
state: 'state/preauthorized',
booking: createBooking(
'booking1',
new Date(Date.UTC(2017, 5, 10)),
new Date(Date.UTC(2017, 5, 13))
),
listing: createListing('listing1'),
customer: createUser('customer1'),
});
const props = {
transaction,
tab: 'details',
intl: fakeIntl,
};
const tree = renderShallow(<SalePageComponent {...props} />);
expect(tree).toMatchSnapshot();
});
});

View file

@ -0,0 +1,69 @@
exports[`SalePage matches snapshot 1`] = `
<Connect(withRouter(PageLayout))
title="SalePage.title">
<SaleDetailsPanel
booking={
Object {
"attributes": Object {
"end": 2017-06-13T00:00:00.000Z,
"start": 2017-06-10T00:00:00.000Z,
},
"id": UUID {
"uuid": "booking1",
},
"type": "booking",
}
}
className="undefined"
commission={
Money {
"amount": 100,
"currency": "USD",
}
}
customer={
Object {
"attributes": Object {
"profile": Object {
"firstName": "customer1 first name",
"lastName": "customer1 last name",
},
},
"id": UUID {
"uuid": "customer1",
},
"type": "user",
}
}
listing={
Object {
"attributes": Object {
"address": "listing1 address",
"description": "listing1 description",
"geolocation": LatLng {
"lat": 40,
"lng": 60,
},
"price": Money {
"amount": 5500,
"currency": "USD",
},
"title": "listing1 title",
},
"author": null,
"id": UUID {
"uuid": "listing1",
},
"images": Array [],
"type": "listing",
}
}
saleState="state/preauthorized"
subtotalPrice={
Money {
"amount": 1000,
"currency": "USD",
}
} />
</Connect(withRouter(PageLayout))>
`;

View file

@ -1,27 +0,0 @@
/* eslint-disable react/no-unescaped-entities */
import React, { PropTypes } from 'react';
import { PageLayout, NamedLink } from '../../components';
const SalesConversationPage = props => {
const { params } = props;
return (
<PageLayout title="Sales conversation page">
<p>Sale id: {params.id}</p>
<NamedLink name="SaleDiscussionPage" params={{ id: params.id }}>Discussion tab</NamedLink>
<br />
<NamedLink name="SaleDetailsPage" params={{ id: params.id }}>Details tab</NamedLink>
<p>Mobile layout needs different views for discussion and details.</p>
<p>
Discussion view is the default if route doesn't specify mobile tab (e.g. <i>
/order/1234
</i>)
</p>
</PageLayout>
);
};
const { shape, number } = PropTypes;
SalesConversationPage.propTypes = { params: shape({ id: number.isRequired }).isRequired };
export default SalesConversationPage;

View file

@ -1,10 +0,0 @@
import React from 'react';
import { renderShallow } from '../../util/test-helpers';
import SalesConversationPage from './SalesConversationPage';
describe('SalesConversationPage', () => {
it('matches snapshot', () => {
const tree = renderShallow(<SalesConversationPage params={{ id: 12345 }} />);
expect(tree).toMatchSnapshot();
});
});

View file

@ -1,38 +0,0 @@
exports[`SalesConversationPage matches snapshot 1`] = `
<Connect(withRouter(PageLayout))
title="Sales conversation page">
<p>
Sale id:
12345
</p>
<withFlattenedRoutes(withRouter(NamedLink))
name="SaleDiscussionPage"
params={
Object {
"id": 12345,
}
}>
Discussion tab
</withFlattenedRoutes(withRouter(NamedLink))>
<br />
<withFlattenedRoutes(withRouter(NamedLink))
name="SaleDetailsPage"
params={
Object {
"id": 12345,
}
}>
Details tab
</withFlattenedRoutes(withRouter(NamedLink))>
<p>
Mobile layout needs different views for discussion and details.
</p>
<p>
Discussion view is the default if route doesn\'t specify mobile tab (e.g.
<i>
/order/1234
</i>
)
</p>
</Connect(withRouter(PageLayout))>
`;

View file

@ -20,7 +20,7 @@ import PasswordForgottenForm from './PasswordForgottenForm/PasswordForgottenForm
import PasswordForgottenPage from './PasswordForgottenPage/PasswordForgottenPage';
import PayoutPreferencesPage from './PayoutPreferencesPage/PayoutPreferencesPage';
import ProfilePage from './ProfilePage/ProfilePage';
import SalesConversationPage from './SalesConversationPage/SalesConversationPage';
import SalePage from './SalePage/SalePage';
import SearchPage from './SearchPage/SearchPage';
import SecurityPage from './SecurityPage/SecurityPage';
import SignUpForm from './SignUpForm/SignUpForm';
@ -51,7 +51,7 @@ export {
PasswordForgottenPage,
PayoutPreferencesPage,
ProfilePage,
SalesConversationPage,
SalePage,
SearchPage,
SecurityPage,
SignUpForm,

View file

@ -8,6 +8,7 @@ import EditListingPage from './EditListingPage/EditListingPage.duck';
import InboxPage from './InboxPage/InboxPage.duck';
import ListingPage from './ListingPage/ListingPage.duck';
import OrderPage from './OrderPage/OrderPage.duck';
import SalePage from './SalePage/SalePage.duck';
import SearchPage from './SearchPage/SearchPage.duck';
export { CheckoutPage, EditListingPage, InboxPage, ListingPage, OrderPage, SearchPage };
export { CheckoutPage, EditListingPage, InboxPage, ListingPage, OrderPage, SalePage, SearchPage };

View file

@ -16,7 +16,7 @@ import {
PasswordForgottenPage,
PayoutPreferencesPage,
ProfilePage,
SalesConversationPage,
SalePage,
SearchPage,
SecurityPage,
StyleguidePage,
@ -200,21 +200,23 @@ const routesConfiguration = [
auth: true,
exact: true,
name: 'SalePage',
component: props => <SalesConversationPage {...props} tab="discussion" />,
component: props => <SalePage {...props} tab="discussion" />,
routes: [
{
path: '/sale/:id/details',
auth: true,
exact: true,
name: 'SaleDetailsPage',
component: props => <SalesConversationPage {...props} tab="discussion" />,
component: props => <SalePage {...props} tab="details" />,
loadData: params => SalePage.loadData(params),
},
{
path: '/sale/:id/discussion',
auth: true,
exact: true,
name: 'SaleDiscussionPage',
component: props => <SalesConversationPage {...props} tab="discussion" />,
component: props => <SalePage {...props} tab="discussion" />,
loadData: params => SalePage.loadData(params),
},
],
},

View file

@ -15,6 +15,7 @@
"BookingInfo.commission": "Charged commission:",
"BookingInfo.nightCount": "{count, number} {count, plural, one {night} other {nights}}",
"BookingInfo.pricePerDay":"Price per night:",
"BookingInfo.subtotal": "Subtotal:",
"BookingInfo.total": "Total:",
"CheckoutPage.initiateOrderError": "Payment request failed. Please try again.",
"CheckoutPage.paymentInfo": "You'll only be charged if your request is accepted by the provider.",
@ -55,14 +56,18 @@
"ListingPage.loadingListingData": "Loading listing data",
"ListingPage.noListingData": "Could not find listing data",
"ModalInMobile.closeModal": "Close modal",
"OrderDetailsPanel.listingTitle": "You have requested to book {title}",
"OrderDetailsPanel.orderStatusMessage": "{providerName} has been notified about the booking request, so sit back and relax",
"OrderPage.loadingData": "Loading order data",
"OrderPage.title": "Order details for ${title}",
"OrderDetailsPanel.listingTitle": "You have requested to book {title}.",
"OrderDetailsPanel.orderStatusMessage": "{providerName} has been notified about the booking request, so sit back and relax.",
"OrderPage.loadingData": "Loading order data.",
"OrderPage.title": "Order details for ${title}.",
"PageLayout.authInfoFailed": "Could not get authentication information.",
"PageLayout.logoutFailed": "Logout failed. Please try again.",
"PaginationLinks.previous": "Previous page",
"PaginationLinks.next": "Next page",
"SaleDetailsPanel.listingTitle": "{customerName} has requested to book {title}.",
"SaleDetailsPanel.saleStatusMessage": "{customerName} is waiting for your response.",
"SalePage.loadingData": "Loading sale data.",
"SalePage.title": "Sale details for ${title}.",
"SearchPage.foundResults": "{count, number} {count, plural, one {listing} other {listings}} found.",
"SearchPage.loadingResults": "Loading search results...",
"SearchPage.loadingResults": "Loading search results...",

View file

@ -106,3 +106,50 @@ export const denormalisedEntities = (entities, type, ids) => {
return entityData;
});
};
/**
* Create shell objects to ensure that attributes etc. exists.
*
* @param {Object} transaction entity object, which is to be ensured agains null values
*/
export const ensureTransaction = transaction => {
const empty = {
id: null,
type: 'transaction',
attributes: {},
booking: {},
listing: {},
provider: {},
};
return { ...empty, ...transaction };
};
/**
* Create shell objects to ensure that attributes etc. exists.
*
* @param {Object} booking entity object, which is to be ensured agains null values
*/
export const ensureBooking = booking => {
const empty = { id: null, type: 'booking', attributes: {} };
return { ...empty, ...booking };
};
/**
* Create shell objects to ensure that attributes etc. exists.
*
* @param {Object} listing entity object, which is to be ensured agains null values
*/
export const ensureListing = listing => {
const empty = { id: null, type: 'listing', attributes: {}, images: [] };
return { ...empty, ...listing };
};
/**
* Create shell objects to ensure that attributes etc. exists.
*
* @param {Object} user entity object, which is to be ensured agains null values
*/
export const ensureUser = user => {
const empty = { id: null, type: 'user', attributes: { profile: {} } };
return { ...empty, ...user };
};

View file

@ -83,7 +83,7 @@ export const createTransaction = options => {
id: new UUID(id),
type: 'transaction',
attributes: {
commission: null,
commission: new Money(100, 'USD'),
createdAt: new Date(Date.UTC(2017, 4, 1)),
lastTransitionedAt,
state,