From d58b87ba72d1fdda6f8b50824e410d642565da19 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Wed, 12 Apr 2017 16:04:20 +0300 Subject: [PATCH] InboxPage with order and sale transactions --- src/containers/InboxPage/InboxPage.css | 45 ++++++ src/containers/InboxPage/InboxPage.duck.js | 96 +++++++++++++ src/containers/InboxPage/InboxPage.js | 131 ++++++++++++++++-- src/containers/InboxPage/InboxPage.test.js | 6 +- .../__snapshots__/InboxPage.test.js.snap | 16 ++- src/containers/reducers.js | 3 +- src/routesConfiguration.js | 31 +++-- src/translations/en.json | 20 +-- 8 files changed, 312 insertions(+), 36 deletions(-) create mode 100644 src/containers/InboxPage/InboxPage.duck.js diff --git a/src/containers/InboxPage/InboxPage.css b/src/containers/InboxPage/InboxPage.css index 5aa94ed1..4ab2be16 100644 --- a/src/containers/InboxPage/InboxPage.css +++ b/src/containers/InboxPage/InboxPage.css @@ -14,3 +14,48 @@ font-weight: bold; text-decoration: underline; } + +.listItem { + border-bottom: 1px solid #979797; + + &:last-child { + border-bottom: none; + } +} + +.itemLink { + display: flex; + padding: 1rem; + text-decoration: none; +} + +.itemAvatar { + margin-right: 1rem; + width: 44px; + height: 44px; +} + +.itemAvatarImage { + border-radius: 50%; +} + +.itemInfo { + flex: 1; +} + +.itemUsername { + font-weight: bold; + font-size: 16px; + text-decoration: none; +} + +.itemTimestamp { + float: right; + font-size: 14px; +} + +.itemState { + margin-top: 5px; + font-size: 14px; + text-decoration: none; +} diff --git a/src/containers/InboxPage/InboxPage.duck.js b/src/containers/InboxPage/InboxPage.duck.js new file mode 100644 index 00000000..75c68886 --- /dev/null +++ b/src/containers/InboxPage/InboxPage.duck.js @@ -0,0 +1,96 @@ +import _ from 'lodash'; +import { addEntities } from '../../ducks/sdk.duck'; + +// ================ Action types ================ // + +export const FETCH_ORDERS_OR_SALES_REQUEST = 'app/InboxPage/FETCH_ORDERS_OR_SALES_REQUEST'; +export const FETCH_ORDERS_OR_SALES_SUCCESS = 'app/InboxPage/FETCH_ORDERS_OR_SALES_SUCCESS'; +export const FETCH_ORDERS_OR_SALES_ERROR = 'app/InboxPage/FETCH_ORDERS_OR_SALES_ERROR'; + +// ================ Reducer ================ // + +const entityRefs = entities => + entities.map(entity => ({ + id: entity.id, + type: entity.type, + })); + +const initialState = { + fetchInProgress: false, + fetchOrdersOrSalesError: null, + transactionRefs: [], +}; + +export default function checkoutPageReducer(state = initialState, action = {}) { + const { type, payload } = action; + switch (type) { + case FETCH_ORDERS_OR_SALES_REQUEST: + return { ...state, fetchInProgress: true, fetchOrdersOrSalesError: null }; + case FETCH_ORDERS_OR_SALES_SUCCESS: + return { ...state, fetchInProgress: false, transactionRefs: entityRefs(payload) }; + case FETCH_ORDERS_OR_SALES_ERROR: + console.error(payload); // eslint-disable-line + return { ...state, fetchInProgress: false, fetchOrdersOrSalesError: payload }; + + default: + return state; + } +} + +// ================ Action creators ================ // + +const fetchOrdersOrSalesRequest = () => ({ type: FETCH_ORDERS_OR_SALES_REQUEST }); +const fetchOrdersOrSalesSuccess = transactions => ({ + type: FETCH_ORDERS_OR_SALES_SUCCESS, + payload: transactions, +}); +const fetchOrdersOrSalesError = e => ({ + type: FETCH_ORDERS_OR_SALES_ERROR, + error: true, + payload: e, +}); + +// ================ Thunks ================ // + +const sortedTransactions = txs => + _.chain(txs) + .sortBy(tx => { + return tx.attributes ? tx.attributes.lastTransitionedAt : null; + }) + .reverse() + .value(); + +export const loadData = params => + (dispatch, getState, sdk) => { + const { tab } = params; + + const onlyFilterValues = { + orders: 'order', + sales: 'sale', + }; + + const onlyFilter = onlyFilterValues[tab]; + if (!onlyFilter) { + return Promise.reject(new Error(`Invalid tab for InboxPage: ${tab}`)); + } + + dispatch(fetchOrdersOrSalesRequest()); + + const queryParams = { + only: onlyFilter, + include: ['provider', 'customer'], + }; + + return sdk.transactions + .query(queryParams) + .then(response => { + const transactions = sortedTransactions(response.data.data); + dispatch(addEntities(response)); + dispatch(fetchOrdersOrSalesSuccess(transactions)); + return response; + }) + .catch(e => { + dispatch(fetchOrdersOrSalesError(e)); + throw e; + }); + }; diff --git a/src/containers/InboxPage/InboxPage.js b/src/containers/InboxPage/InboxPage.js index 75606155..458a2ef1 100644 --- a/src/containers/InboxPage/InboxPage.js +++ b/src/containers/InboxPage/InboxPage.js @@ -1,15 +1,101 @@ import React, { PropTypes } from 'react'; +import { compose } from 'redux'; +import { connect } from 'react-redux'; import { FormattedMessage, injectIntl, intlShape } from 'react-intl'; -import { PageLayout, NamedLink, H1 } from '../../components'; +import { PageLayout, NamedRedirect, NamedLink, H1 } from '../../components'; +import * as propTypes from '../../util/propTypes'; +import { getEntities } from '../../ducks/sdk.duck'; +import { loadData } from './InboxPage.duck'; import css from './InboxPage.css'; +const { shape, string, arrayOf, bool, oneOf } = PropTypes; + +// Formatted username +const username = user => { + const profile = user && user.attributes && user.attributes.profile + ? user.attributes.profile + : null; + return profile ? `${profile.firstName} ${profile.lastName}` : ''; +}; + +// Localised timestamp when the given transaction was updated +const timestamp = (intl, tx) => { + const date = tx.attributes ? tx.attributes.lastTransitionedAt : null; + return date ? intl.formatDate(date) : ''; +}; + +// Translated name of the state of the given transaction +const txState = (intl, tx) => { + const { state } = tx; + if (state === propTypes.TX_STATE_ACCEPTED) { + return intl.formatMessage({ + id: 'InboxPage.stateAccepted', + }); + } else if (state === propTypes.TX_STATE_REJECTED) { + return intl.formatMessage({ + id: 'InboxPage.stateRejected', + }); + } + return intl.formatMessage({ + id: 'InboxPage.statePending', + }); +}; + +const InboxItem = props => { + const { type, tx, intl } = props; + const { customer, provider } = tx; + const isOrder = type === 'order'; + const otherUserName = username(isOrder ? provider : customer); + const otherUserAvatar = 'https://placehold.it/44x44'; + return ( + +
+ {otherUserName} +
+
+
+ {otherUserName} + {timestamp(intl, tx)} +
+
{txState(intl, tx)}
+
+
+ ); +}; + +InboxItem.propTypes = { + type: oneOf(['order', 'sale']).isRequired, + tx: propTypes.transaction.isRequired, + intl: intlShape.isRequired, +}; + export const InboxPageComponent = props => { - const { tab, intl } = props; + const { fetchInProgress, transactions, intl, params } = props; + const { tab } = params; + + const validTab = tab === 'orders' || tab === 'sales'; + if (!validTab) { + return ; + } + + const isOrders = tab === 'orders'; const ordersTitle = intl.formatMessage({ id: 'InboxPage.ordersTitle' }); const salesTitle = intl.formatMessage({ id: 'InboxPage.salesTitle' }); - const title = tab === 'orders' ? ordersTitle : salesTitle; + const title = isOrders ? ordersTitle : salesTitle; + + const toTxItem = tx => { + return ( +
  • + +
  • + ); + }; return ( @@ -17,26 +103,53 @@ export const InboxPageComponent = props => { +
      + {!fetchInProgress ? transactions.map(toTxItem) : null} +
    ); }; -const { oneOf } = PropTypes; - InboxPageComponent.propTypes = { - tab: oneOf(['orders', 'sales']).isRequired, + params: shape({ + tab: string.isRequired, + }).isRequired, + + fetchInProgress: bool.isRequired, + transactions: arrayOf(propTypes.transaction).isRequired, // from injectIntl intl: intlShape.isRequired, }; -const InboxPage = injectIntl(InboxPageComponent); +const mapStateToProps = state => { + const marketplaceData = state.data; + const refs = state.InboxPage.transactionRefs; + return { + fetchInProgress: state.InboxPage.fetchInProgress, + transactions: getEntities(marketplaceData, refs), + }; +}; + +const InboxPage = compose(connect(mapStateToProps), injectIntl)(InboxPageComponent); + +InboxPage.loadData = loadData; export default InboxPage; diff --git a/src/containers/InboxPage/InboxPage.test.js b/src/containers/InboxPage/InboxPage.test.js index ab40c7c8..e2eff6e9 100644 --- a/src/containers/InboxPage/InboxPage.test.js +++ b/src/containers/InboxPage/InboxPage.test.js @@ -6,7 +6,11 @@ import { InboxPageComponent } from './InboxPage'; describe('InboxPage', () => { it('matches snapshot', () => { const props = { - tab: 'orders', + params: { + tab: 'orders', + }, + fetchInProgress: false, + transactions: [], intl: fakeIntl, }; const tree = renderShallow(); diff --git a/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap b/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap index 8347e444..b0f6d351 100644 --- a/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap +++ b/src/containers/InboxPage/__snapshots__/InboxPage.test.js.snap @@ -9,17 +9,29 @@ exports[`InboxPage matches snapshot 1`] = ` +
      `; diff --git a/src/containers/reducers.js b/src/containers/reducers.js index b8650974..768f9afb 100644 --- a/src/containers/reducers.js +++ b/src/containers/reducers.js @@ -5,7 +5,8 @@ */ import CheckoutPage from './CheckoutPage/CheckoutPage.duck'; import EditListingPage from './EditListingPage/EditListingPage.duck'; +import InboxPage from './InboxPage/InboxPage.duck'; import ListingPage from './ListingPage/ListingPage.duck'; import SearchPage from './SearchPage/SearchPage.duck'; -export { CheckoutPage, EditListingPage, ListingPage, SearchPage }; +export { CheckoutPage, EditListingPage, InboxPage, ListingPage, SearchPage }; diff --git a/src/routesConfiguration.js b/src/routesConfiguration.js index 2f3b3530..b227aaee 100644 --- a/src/routesConfiguration.js +++ b/src/routesConfiguration.js @@ -10,6 +10,7 @@ import { LandingPage, ListingPage, ManageListingsPage, + NotFoundPage, OrderPage, PasswordChangePage, PasswordForgottenPage, @@ -157,22 +158,16 @@ const routesConfiguration = [ path: '/inbox', auth: true, exact: true, + name: 'InboxBasePage', + component: () => , + }, + { + path: '/inbox/:tab', + auth: true, + exact: true, name: 'InboxPage', - component: () => , - }, - { - path: '/inbox/orders', - auth: true, - exact: true, - name: 'InboxOrdersPage', - component: props => , - }, - { - path: '/inbox/sales', - auth: true, - exact: true, - name: 'InboxSalesPage', - component: props => , + component: props => , + loadData: (params, search) => InboxPage.loadData(params, search), }, { path: '/order/:id', @@ -281,6 +276,12 @@ const routesConfiguration = [ name: 'StyleguideComponentExampleRaw', component: props => , }, + { + path: '/notfound', + exact: true, + name: 'NotFoundPage', + component: props => , + }, ]; export default routesConfiguration; diff --git a/src/translations/en.json b/src/translations/en.json index c34bbcaa..b5204ffe 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6,22 +6,23 @@ "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.requiredDate": "Oops, make sure your date is correct!", + "BookingDatesForm.requiredDate": "Oops, make sure your date is correct!", "BookingDatesForm.youWontBeChargedInfo": "You won't be charged yet", - "BookingInfo.bookingPeriodLabel": "Booking period:", "BookingInfo.bookingPeriod": "{bookingStart} - {bookingEnd}", + "BookingInfo.bookingPeriodLabel": "Booking period:", "BookingInfo.nightCount": "{count, number} {count, plural, one {day} other {days}}", "BookingInfo.pricePerDay":"Price per day:", "BookingInfo.total": "Total:", - "CheckoutPage.title": "Book {listingTitle}", "CheckoutPage.initiateOrderError": "Payment request failed. Please try again.", + "CheckoutPage.paymentInfo": "You'll only be charged if your request is accepted by the provider.", "CheckoutPage.paymentTitle": "Payment", + "CheckoutPage.title": "Book {listingTitle}", "DateInput.clearDate": "Clear Date", "DateInput.closeDatePicker": "Close", "DateInput.defaultPlaceholder": "Date input", "DateInput.screenReaderInputMessage": "Date input", - "CheckoutPage.paymentInfo": "You'll only be charged if your request is accepted by the provider.", "EditListingForm.bankAccountNumberRequired": "You need to add a bank account number.", "EditListingForm.descriptionRequired": "You need to add a description.", "EditListingForm.imageRequired": "You need to add at least one image.", @@ -37,15 +38,18 @@ "HeroSearchForm.search": "Search", "HeroSection.subTitle": "The largest online community to rent music studios", "HeroSection.title": "Book Studiotime anywhere", - "InboxPage.title": "Inbox", - "InboxPage.ordersTitle": "Inbox: Orders", - "InboxPage.salesTitle": "Inbox: Sales", "InboxPage.ordersTabTitle": "Guest", + "InboxPage.ordersTitle": "Inbox: Orders", "InboxPage.salesTabTitle": "Host", - "ModalInMobile.closeModal": "Close modal", + "InboxPage.salesTitle": "Inbox: Sales", + "InboxPage.stateAccepted": "Accepted", + "InboxPage.statePending": "Pending", + "InboxPage.stateRejected": "Rejected", + "InboxPage.title": "Inbox", "ListingPage.ctaButtonMessage": "Book {title}", "ListingPage.loadingListingData": "Loading listing data", "ListingPage.noListingData": "Could not find listing data", + "ModalInMobile.closeModal": "Close modal", "PageLayout.authInfoFailed": "Could not get authentication information.", "PageLayout.logoutFailed": "Logout failed. Please try again.", "SearchPage.foundResults": "{count, number} {count, plural, one {listing} other {listings}} found.",