mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-26 06:47:17 +10:00
commit
0e71c2d41a
14 changed files with 346 additions and 351 deletions
|
|
@ -23,7 +23,8 @@
|
|||
"redux-saga": "^0.14.3",
|
||||
"sanitize.css": "^4.1.0",
|
||||
"sharetribe-scripts": "0.8.6",
|
||||
"source-map-support": "^0.4.11"
|
||||
"source-map-support": "^0.4.11",
|
||||
"url": "^0.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^1.11.0",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<!--!title-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<script><!--!preloadedStateScript--></script>
|
||||
<!--!preloadedStateScript-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"><!--!body--></div>
|
||||
|
|
|
|||
35
server/fakeSDK.js
Normal file
35
server/fakeSDK.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/* eslint-disable import/prefer-default-export, no-unused-vars */
|
||||
const fakeListings = [
|
||||
{
|
||||
id: 123,
|
||||
title: 'Banyan Studios',
|
||||
price: '55\u20AC / day',
|
||||
description: 'Organic Music Production in a Sustainable, Ethical and Professional Studio.',
|
||||
location: 'New York, NY \u2022 40mi away',
|
||||
review: { count: '8 reviews', rating: '4' },
|
||||
author: {
|
||||
name: 'The Stardust Collective',
|
||||
avatar: 'http://placehold.it/44x44',
|
||||
review: { rating: '4' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 1234,
|
||||
title: 'Pienix Studio',
|
||||
price: '80\u20AC / day',
|
||||
description: 'Pienix Studio specializes in music mixing and mastering production.',
|
||||
location: 'New York, NY \u2022 6mi away',
|
||||
review: { count: '7 reviews', rating: '4' },
|
||||
author: { name: 'Juhan', avatar: 'http://placehold.it/44x44', review: { rating: '4' } },
|
||||
},
|
||||
];
|
||||
/* eslint-enable import/prefer-default-export */
|
||||
|
||||
exports.fetchListings = () => {
|
||||
const randomTime = Math.random() * 2000;
|
||||
const fakeResponseTime = 1000 + randomTime;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => resolve(fakeListings), fakeResponseTime);
|
||||
});
|
||||
};
|
||||
|
|
@ -22,10 +22,13 @@ const compression = require('compression');
|
|||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const qs = require('qs');
|
||||
const url = require('url');
|
||||
const _ = require('lodash');
|
||||
const React = require('react');
|
||||
const { createServerRenderContext } = require('react-router');
|
||||
const sagaEffects = require('redux-saga/effects');
|
||||
const auth = require('./auth');
|
||||
const sdk = require('./fakeSDK');
|
||||
|
||||
// Construct the bundle path where the server side rendering function
|
||||
// can be imported.
|
||||
|
|
@ -33,7 +36,10 @@ const buildPath = path.resolve(__dirname, '..', 'build');
|
|||
const manifestPath = path.join(buildPath, 'asset-manifest.json');
|
||||
const manifest = require(manifestPath);
|
||||
const mainJsPath = path.join(buildPath, manifest['main.js']);
|
||||
const renderApp = require(mainJsPath).default;
|
||||
const mainJs = require(mainJsPath);
|
||||
const renderApp = mainJs.default;
|
||||
const matchPathname = mainJs.matchPathname;
|
||||
const configureStore = mainJs.configureStore;
|
||||
|
||||
// The HTML build file is generated from the `public/index.html` file
|
||||
// and used as a template for server side rendering. The application
|
||||
|
|
@ -66,15 +72,52 @@ const template = _.template(indexHtml, {
|
|||
escape: reNoMatch,
|
||||
});
|
||||
|
||||
function render(url, context, preloadedState) {
|
||||
const { head, body } = renderApp(url, context, preloadedState);
|
||||
function fetchInitialState(requestUrl) {
|
||||
const pathname = url.parse(requestUrl).pathname;
|
||||
const { matchedRoutes, params } = matchPathname(pathname);
|
||||
|
||||
// pathname may match with several routes (if they don't have exactly=true)
|
||||
// We filter all the components form matched routes that have `loadData`
|
||||
const initialFetches = _
|
||||
.chain(matchedRoutes)
|
||||
.filter(r => r.loadData)
|
||||
.map(r => sagaEffects.fork(r.loadData, sdk))
|
||||
.value();
|
||||
|
||||
// We need to combine different onload sagas under one yield
|
||||
const fetchInitialData = function* fetchInitialData() {
|
||||
yield initialFetches;
|
||||
};
|
||||
|
||||
// runSaga (if necessary) and return initial store state after loadData fetches.
|
||||
if (initialFetches.length > 0) {
|
||||
const fetchPromise = new Promise((resolve, reject) => {
|
||||
const store = configureStore({});
|
||||
store.runSaga(fetchInitialData).done
|
||||
.then(() => {
|
||||
resolve(store.getState());
|
||||
})
|
||||
.catch(e => {
|
||||
reject(e);
|
||||
});
|
||||
// Close temporary store's saga middleware (which was used only for fetching initial store state)
|
||||
store.closeSagaMiddleware();
|
||||
});
|
||||
return fetchPromise;
|
||||
}
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
function render(requestUrl, context, preloadedState) {
|
||||
const { head, body } = renderApp(requestUrl, context, preloadedState);
|
||||
|
||||
// Preloaded state needs to be passed for client side too.
|
||||
// For security reasons we ensure that preloaded state is considered as a string
|
||||
// by replacing '<' character with its unicode equivalent.
|
||||
// http://redux.js.org/docs/recipes/ServerRendering.html#security-considerations
|
||||
const serializedState = JSON.stringify(preloadedState).replace(/</g, '\\u003c');
|
||||
const preloadedStateScript = `
|
||||
window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\u003c')};
|
||||
<script>window.__PRELOADED_STATE__ = ${serializedState};</script>
|
||||
`;
|
||||
|
||||
return template({ title: head.title.toString(), preloadedStateScript, body });
|
||||
|
|
@ -104,21 +147,26 @@ app.get('*', (req, res) => {
|
|||
const filters = qs.parse(req.query);
|
||||
|
||||
// TODO fetch this asynchronously
|
||||
const preloadedState = { search: { filters } };
|
||||
fetchInitialState(req.url)
|
||||
.then(preloadedState => {
|
||||
const html = render(req.url, context, preloadedState);
|
||||
const result = context.getResult();
|
||||
|
||||
const html = render(req.url, context, preloadedState);
|
||||
const result = context.getResult();
|
||||
|
||||
if (result.redirect) {
|
||||
res.redirect(result.redirect.pathname);
|
||||
} else if (result.missed) {
|
||||
// Do a second render pass with the context to clue <Miss>
|
||||
// components into rendering this time.
|
||||
// See: https://react-router.now.sh/ServerRouter
|
||||
res.status(404).send(render(req.url, context, preloadedState));
|
||||
} else {
|
||||
res.send(html);
|
||||
}
|
||||
if (result.redirect) {
|
||||
res.redirect(result.redirect.pathname);
|
||||
} else if (result.missed) {
|
||||
// Do a second render pass with the context to clue <Miss>
|
||||
// components into rendering this time.
|
||||
// See: https://react-router.now.sh/ServerRouter
|
||||
res.status(404).send(render(req.url, context, preloadedState));
|
||||
} else {
|
||||
res.send(html);
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e.message);
|
||||
res.status(500).send(e.message);
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
|
|
|
|||
|
|
@ -4,22 +4,78 @@
|
|||
* https://github.com/erikras/ducks-modular-redux
|
||||
*/
|
||||
import { unionWith, isEqual } from 'lodash';
|
||||
import { call, put, takeEvery } from 'redux-saga/effects';
|
||||
import { createRequestTypes } from '../../util/sagaHelpers';
|
||||
|
||||
// ================ Action types ================ //
|
||||
|
||||
// Actions
|
||||
export const ADD_FILTER = 'app/SearchPage/ADD_FILTER';
|
||||
|
||||
// Reducer
|
||||
export default function reducer(state = {}, action = {}) {
|
||||
// async actions use request - success / failure pattern
|
||||
export const LOAD_LISTINGS = createRequestTypes('app/SearchPage/LOAD_LISTINGS');
|
||||
|
||||
// ================ Reducer ================ //
|
||||
|
||||
export const initialState = {
|
||||
filters: [],
|
||||
initialListingsLoaded: false,
|
||||
listings: [],
|
||||
loadingListings: false,
|
||||
};
|
||||
|
||||
export default function reducer(state = initialState, action = {}) {
|
||||
const { type, payload } = action;
|
||||
|
||||
switch (type) {
|
||||
case ADD_FILTER: {
|
||||
const stateFilters = state.filters || [];
|
||||
return { ...state, ...{ filters: unionWith(stateFilters, [payload], isEqual) } };
|
||||
}
|
||||
case LOAD_LISTINGS.REQUEST:
|
||||
case LOAD_LISTINGS.SUCCESS:
|
||||
case LOAD_LISTINGS.FAILURE:
|
||||
return { ...state, ...payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Action Creators
|
||||
// ================ Action creators ================ //
|
||||
|
||||
export const addFilter = (key, value) => ({ type: ADD_FILTER, payload: { [key]: value } });
|
||||
|
||||
// async actions use request - success - failure pattern
|
||||
const action = (type, payload = {}) => ({ type, ...payload });
|
||||
|
||||
export const loadListings = {
|
||||
request: () =>
|
||||
action(LOAD_LISTINGS.REQUEST, {
|
||||
payload: { loadingListings: true, initialListingsLoaded: false },
|
||||
}),
|
||||
success: listings =>
|
||||
action(LOAD_LISTINGS.SUCCESS, {
|
||||
payload: { loadingListings: false, initialListingsLoaded: true, listings },
|
||||
}),
|
||||
failure: error =>
|
||||
action(LOAD_LISTINGS.FAILURE, {
|
||||
payload: { loadingListings: false, loadListingError: error, error: true },
|
||||
}),
|
||||
};
|
||||
|
||||
// ================ Worker sagas ================ //
|
||||
|
||||
export function* callFetchListings(sdk) {
|
||||
try {
|
||||
const response = yield call(sdk.fetchListings);
|
||||
yield put(loadListings.success(response));
|
||||
} catch(error) {
|
||||
yield put(loadListings.failure(error));
|
||||
}
|
||||
}
|
||||
|
||||
// ================ Watcher sagas ================ //
|
||||
|
||||
export function* watchLoadListings(sdk) {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
yield takeEvery(LOAD_LISTINGS.REQUEST, callFetchListings, sdk);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import React, { PropTypes } from 'react';
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { connect } from 'react-redux';
|
||||
import { includes } from 'lodash';
|
||||
import { addFlashNotification } from '../../ducks/FlashNotification.ducks';
|
||||
import { addFilter } from './SearchPage.ducks';
|
||||
import { addFilter, callFetchListings, loadListings } from './SearchPage.ducks';
|
||||
import css from './SearchPage.css';
|
||||
import {
|
||||
FilterPanel,
|
||||
|
|
@ -14,74 +14,72 @@ import {
|
|||
SearchResultsPanel,
|
||||
} from '../../components';
|
||||
|
||||
const fakeListings = [
|
||||
{
|
||||
id: 123,
|
||||
title: 'Banyan Studios',
|
||||
price: '55\u20AC / day',
|
||||
description: 'Organic Music Production in a Sustainable, Ethical and Professional Studio.',
|
||||
location: 'New York, NY \u2022 40mi away',
|
||||
review: { count: '8 reviews', rating: '4' },
|
||||
author: {
|
||||
name: 'The Stardust Collective',
|
||||
avatar: 'http://placehold.it/44x44',
|
||||
review: { rating: '4' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 1234,
|
||||
title: 'Pienix Studio',
|
||||
price: '80\u20AC day',
|
||||
description: 'Pienix Studio specializes in music mixing and mastering production.',
|
||||
location: 'New York, NY \u2022 6mi away',
|
||||
review: { count: '7 reviews', rating: '4' },
|
||||
author: { name: 'Juhan', avatar: 'http://placehold.it/44x44', review: { rating: '4' } },
|
||||
},
|
||||
];
|
||||
export class SearchPageComponent extends Component {
|
||||
componentDidMount() {
|
||||
// TODO: This should be moved to Router
|
||||
const { SearchPage } = this.props;
|
||||
if (!SearchPage.initialListingsLoaded) {
|
||||
this.props.onLoadListings();
|
||||
}
|
||||
}
|
||||
|
||||
export const SearchPageComponent = props => {
|
||||
const { tab } = props;
|
||||
const selectedTab = includes(['filters','listings', 'map'], tab)
|
||||
? tab
|
||||
: 'listings';
|
||||
render() {
|
||||
const { tab, SearchPage } = this.props;
|
||||
const { listings } = SearchPage;
|
||||
const fakeListings = listings || [];
|
||||
const selectedTab = includes(['filters','listings', 'map'], tab)
|
||||
? tab
|
||||
: 'listings';
|
||||
|
||||
const filtersClassName = classNames(css.filters, {
|
||||
[css.open]: selectedTab === 'filters',
|
||||
});
|
||||
const listingsClassName = classNames(css.filters, {
|
||||
[css.open]: selectedTab === 'listings',
|
||||
});
|
||||
const mapClassName = classNames(css.filters, {
|
||||
[css.open]: selectedTab === 'map',
|
||||
});
|
||||
const filtersClassName = classNames(css.filters, {
|
||||
[css.open]: selectedTab === 'filters',
|
||||
});
|
||||
const listingsClassName = classNames(css.listings, {
|
||||
[css.open]: selectedTab === 'listings',
|
||||
});
|
||||
const mapClassName = classNames(css.map, {
|
||||
[css.open]: selectedTab === 'map',
|
||||
});
|
||||
|
||||
return (
|
||||
<PageLayout title="Search page">
|
||||
<div className={css.container}>
|
||||
<div className={filtersClassName}>
|
||||
<FilterPanel />
|
||||
return (
|
||||
<PageLayout title="Search page">
|
||||
<div className={css.container}>
|
||||
<div className={filtersClassName}>
|
||||
<FilterPanel />
|
||||
</div>
|
||||
<div className={listingsClassName}>
|
||||
<SearchResultsPanel>
|
||||
{fakeListings.map(l => <ListingCard key={l.id} {...l} />)}
|
||||
</SearchResultsPanel>
|
||||
</div>
|
||||
<div className={mapClassName}>
|
||||
<MapPanel>
|
||||
{fakeListings.map(l => <ListingCardSmall key={l.id} {...l} />)}
|
||||
</MapPanel>
|
||||
</div>
|
||||
</div>
|
||||
<div className={listingsClassName}>
|
||||
<SearchResultsPanel>
|
||||
{fakeListings.map(l => <ListingCard key={l.id} {...l} />)}
|
||||
</SearchResultsPanel>
|
||||
</div>
|
||||
<div className={mapClassName}>
|
||||
<MapPanel>
|
||||
{fakeListings.map(l => <ListingCardSmall key={l.id} {...l} />)}
|
||||
</MapPanel>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SearchPageComponent.loadData = callFetchListings;
|
||||
|
||||
SearchPageComponent.defaultProps = { SearchPage: {}, tab: 'listings' };
|
||||
|
||||
const { array, bool, func, shape, string } = PropTypes;
|
||||
|
||||
SearchPageComponent.propTypes = {
|
||||
onLoadListings: func.isRequired,
|
||||
SearchPage: shape({
|
||||
filters: array,
|
||||
initialListingsLoaded: bool,
|
||||
listings: array,
|
||||
loadingListings: bool,
|
||||
}),
|
||||
tab: string,
|
||||
};
|
||||
|
||||
SearchPageComponent.defaultProps = { tab: 'listings' };
|
||||
|
||||
const { string } = PropTypes;
|
||||
|
||||
SearchPageComponent.propTypes = { tab: string };
|
||||
|
||||
/**
|
||||
* Container functions.
|
||||
* Since we add this to global store state with combineReducers, this will only get partial state
|
||||
|
|
@ -95,6 +93,7 @@ const mapDispatchToProps = function mapDispatchToProps(dispatch) {
|
|||
return {
|
||||
onAddNotice: msg => dispatch(addFlashNotification('notice', msg)),
|
||||
onAddFilter: (k, v) => dispatch(addFilter(k, v)),
|
||||
onLoadListings: () => dispatch(loadListings.request()),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import renderer from 'react-test-renderer';
|
||||
import { TestProvider } from '../../util/test-helpers';
|
||||
import { SearchPageComponent } from './SearchPage';
|
||||
import reducer, { ADD_FILTER, addFilter } from './SearchPage.ducks';
|
||||
import reducer, { ADD_FILTER, addFilter, initialState } from './SearchPage.ducks';
|
||||
import { RoutesProvider } from '../../components';
|
||||
import routesConfiguration from '../../routesConfiguration';
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ describe('SearchPageComponent', () => {
|
|||
const component = renderer.create(
|
||||
<TestProvider>
|
||||
<RoutesProvider routes={routesConfiguration}>
|
||||
<SearchPageComponent />
|
||||
<SearchPageComponent onLoadListings={v => v} />
|
||||
</RoutesProvider>
|
||||
</TestProvider>,
|
||||
);
|
||||
|
|
@ -31,7 +31,7 @@ describe('SearchPageDucs', () => {
|
|||
describe('reducer', () => {
|
||||
it('should return the initial state', () => {
|
||||
const initial = reducer(undefined, {});
|
||||
expect(initial).toEqual({});
|
||||
expect(initial).toEqual(initialState);
|
||||
});
|
||||
|
||||
it('should handle ADD_FILTER', () => {
|
||||
|
|
|
|||
|
|
@ -155,174 +155,6 @@ exports[`SearchPageComponent matches snapshot 1`] = `
|
|||
▼
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
<img
|
||||
alt="Listing Title"
|
||||
className={undefined}
|
||||
src="http://placehold.it/400x300" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
<a
|
||||
className=""
|
||||
href="/l/Banyan-Studios/123"
|
||||
onClick={[Function]}
|
||||
style={Object {}}>
|
||||
Banyan Studios
|
||||
</a>
|
||||
<div
|
||||
className={undefined}>
|
||||
55€ / day
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
Organic Music Production in a Sustainable, Ethical and Professional Studio.
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
New York, NY • 40mi away
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
(
|
||||
<span>
|
||||
4
|
||||
</span>
|
||||
<span>
|
||||
/5
|
||||
</span>
|
||||
)
|
||||
|
||||
<span>
|
||||
8 reviews
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
<img
|
||||
alt="The Stardust Collective"
|
||||
className={undefined}
|
||||
src="http://placehold.it/44x44" />
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
<span
|
||||
className={undefined}>
|
||||
The Stardust Collective
|
||||
</span>
|
||||
<div
|
||||
className={undefined}>
|
||||
review:
|
||||
<span>
|
||||
4
|
||||
</span>
|
||||
<span>
|
||||
/5
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
<img
|
||||
alt="Listing Title"
|
||||
className={undefined}
|
||||
src="http://placehold.it/400x300" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
<a
|
||||
className=""
|
||||
href="/l/Pienix-Studio/1234"
|
||||
onClick={[Function]}
|
||||
style={Object {}}>
|
||||
Pienix Studio
|
||||
</a>
|
||||
<div
|
||||
className={undefined}>
|
||||
80€ day
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
Pienix Studio specializes in music mixing and mastering production.
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
New York, NY • 6mi away
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
(
|
||||
<span>
|
||||
4
|
||||
</span>
|
||||
<span>
|
||||
/5
|
||||
</span>
|
||||
)
|
||||
|
||||
<span>
|
||||
7 reviews
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
<img
|
||||
alt="Juhan"
|
||||
className={undefined}
|
||||
src="http://placehold.it/44x44" />
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
<span
|
||||
className={undefined}>
|
||||
Juhan
|
||||
</span>
|
||||
<div
|
||||
className={undefined}>
|
||||
review:
|
||||
<span>
|
||||
4
|
||||
</span>
|
||||
<span>
|
||||
/5
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
<a
|
||||
|
|
@ -350,92 +182,7 @@ exports[`SearchPageComponent matches snapshot 1`] = `
|
|||
Map
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
<img
|
||||
alt="Listing Title"
|
||||
className={undefined}
|
||||
src="http://placehold.it/200x150" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
<a
|
||||
className=""
|
||||
href="/l/Banyan-Studios/123"
|
||||
onClick={[Function]}
|
||||
style={Object {}}>
|
||||
Banyan Studios
|
||||
</a>
|
||||
<div
|
||||
className={undefined}>
|
||||
(
|
||||
<span>
|
||||
4
|
||||
</span>
|
||||
<span>
|
||||
/5
|
||||
</span>
|
||||
)
|
||||
|
||||
<span>
|
||||
8 reviews
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
55€ / day
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
<div
|
||||
className={undefined}>
|
||||
<img
|
||||
alt="Listing Title"
|
||||
className={undefined}
|
||||
src="http://placehold.it/200x150" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
<a
|
||||
className=""
|
||||
href="/l/Pienix-Studio/1234"
|
||||
onClick={[Function]}
|
||||
style={Object {}}>
|
||||
Pienix Studio
|
||||
</a>
|
||||
<div
|
||||
className={undefined}>
|
||||
(
|
||||
<span>
|
||||
4
|
||||
</span>
|
||||
<span>
|
||||
/5
|
||||
</span>
|
||||
)
|
||||
|
||||
<span>
|
||||
7 reviews
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={undefined}>
|
||||
80€ day
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
className={undefined} />
|
||||
<a
|
||||
className=""
|
||||
href="/s/filters"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import React from 'react';
|
|||
import ReactDOM from 'react-dom';
|
||||
import { ClientApp, renderApp } from './app';
|
||||
import configureStore from './store';
|
||||
import { matchPathname } from './routesConfiguration';
|
||||
import rootSaga from './sagas';
|
||||
|
||||
import './index.css';
|
||||
|
||||
|
|
@ -23,9 +25,15 @@ if (typeof window !== 'undefined') {
|
|||
// eslint-disable-next-line no-underscore-dangle
|
||||
const preloadedState = window.__PRELOADED_STATE__ || {};
|
||||
const store = configureStore(preloadedState);
|
||||
store.runSaga(rootSaga);
|
||||
|
||||
ReactDOM.render(<ClientApp store={store} />, document.getElementById('root'));
|
||||
}
|
||||
|
||||
// Export the function for server side rendering.
|
||||
export default renderApp;
|
||||
|
||||
// exporting matchPathname and configureStore for server side rendering.
|
||||
// matchPathname helps to figure out which route is called and if it has preloading needs
|
||||
// configureStore is used for creating initial store state for Redux after preloading
|
||||
export { matchPathname, configureStore };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import React from 'react';
|
||||
import { find } from 'lodash';
|
||||
import { Redirect } from 'react-router';
|
||||
|
||||
// This will change to `matchPath` soonish
|
||||
import matchPattern from 'react-router/matchPattern';
|
||||
|
||||
import pathToRegexp from 'path-to-regexp';
|
||||
import {
|
||||
AuthenticationPage,
|
||||
|
|
@ -33,24 +37,28 @@ const routesConfiguration = [
|
|||
exactly: true,
|
||||
name: 'SearchPage',
|
||||
component: SearchPage,
|
||||
loadData: SearchPage ? SearchPage.loadData : null,
|
||||
routes: [
|
||||
{
|
||||
pattern: '/s/filters',
|
||||
exactly: true,
|
||||
name: 'SearchFiltersPage',
|
||||
component: props => <SearchPage {...props} tab="filters" />,
|
||||
loadData: SearchPage ? SearchPage.loadData : null,
|
||||
},
|
||||
{
|
||||
pattern: '/s/listings',
|
||||
exactly: true,
|
||||
name: 'SearchListingsPage',
|
||||
component: props => <SearchPage {...props} tab="listings" />,
|
||||
loadData: SearchPage ? SearchPage.loadData : null,
|
||||
},
|
||||
{
|
||||
pattern: '/s/map',
|
||||
exactly: true,
|
||||
name: 'SearchMapPage',
|
||||
component: props => <SearchPage {...props} tab="map" />,
|
||||
loadData: SearchPage ? SearchPage.loadData : null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -269,7 +277,45 @@ const toPathByRouteName = (nameToFind, routes) => {
|
|||
const pathByRouteName = (nameToFind, routes, params = {}) =>
|
||||
toPathByRouteName(nameToFind, routes)(params);
|
||||
|
||||
/**
|
||||
* matchRoutesToLocation helps to figure out which routes are related to given location.
|
||||
*/
|
||||
const matchRoutesToLocation = (routes, location, matchedRoutes = [], params = {}) => {
|
||||
let parameters = { ...params };
|
||||
let matched = [...matchedRoutes];
|
||||
|
||||
routes.forEach(route => {
|
||||
const { exactly = false } = route;
|
||||
const match = !route.pattern ? true : matchPattern(route.pattern, location, exactly);
|
||||
|
||||
if (match) {
|
||||
matched.push(route);
|
||||
|
||||
if (match.params) {
|
||||
parameters = { ...parameters, ...match.params };
|
||||
}
|
||||
}
|
||||
|
||||
if (route.routes) {
|
||||
const { matchedRoutes: subRouteMatches, params: subRouteParams } = matchRoutesToLocation(route.routes, location, matched, parameters);
|
||||
matched = matched.concat(subRouteMatches);
|
||||
parameters = { ...parameters, ...subRouteParams };
|
||||
}
|
||||
});
|
||||
|
||||
return { matchedRoutes: matched, params: parameters };
|
||||
};
|
||||
|
||||
const matchPathnameCreator = routes =>
|
||||
(location, matchedRoutes = [], params = {}) =>
|
||||
matchRoutesToLocation(routes, { pathname: location }, matchedRoutes, params);
|
||||
|
||||
/**
|
||||
* matchRoutesToLocation helps to figure out which routes are related to given location.
|
||||
*/
|
||||
const matchPathname = matchPathnameCreator(routesConfiguration);
|
||||
|
||||
// Exported helpers
|
||||
export { findRouteByName, flattenRoutes, toPathByRouteName, pathByRouteName };
|
||||
export { findRouteByName, flattenRoutes, matchPathname, toPathByRouteName, pathByRouteName };
|
||||
|
||||
export default routesConfiguration;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { watchLogin, watchLogout } from './ducks/Auth.ducks';
|
||||
import { watchLoadListings } from './containers/SearchPage/SearchPage.ducks';
|
||||
import sdk from './util/fakeSDK';
|
||||
|
||||
export default function* rootSaga() {
|
||||
yield [watchLogin(), watchLogout()];
|
||||
yield [watchLogin(), watchLogout(), watchLoadListings(sdk)];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
/* eslint-disable no-underscore-dangle */
|
||||
import { createStore, applyMiddleware, compose } from 'redux';
|
||||
import createSagaMiddleware from 'redux-saga';
|
||||
import createSagaMiddleware, { END } from 'redux-saga';
|
||||
import createReducer from './reducers';
|
||||
import rootSaga from './sagas';
|
||||
|
||||
/**
|
||||
* Create a new store with the given initial state. Adds Redux
|
||||
|
|
@ -21,9 +20,10 @@ export default function configureStore(initialState = {}) {
|
|||
: compose;
|
||||
|
||||
const enhancer = composeEnhancers(applyMiddleware(...middlewares));
|
||||
const store = createStore(createReducer(), initialState, enhancer);
|
||||
|
||||
sagaMiddleware.run(rootSaga);
|
||||
const store = createStore(createReducer(), initialState, enhancer);
|
||||
store.runSaga = sagaMiddleware.run;
|
||||
store.closeSagaMiddleware = () => store.dispatch(END);
|
||||
|
||||
return store;
|
||||
}
|
||||
|
|
|
|||
39
src/util/fakeSDK.js
Normal file
39
src/util/fakeSDK.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/* eslint-disable import/prefer-default-export, no-unused-vars */
|
||||
export const fakeListings = [
|
||||
{
|
||||
id: 123,
|
||||
title: 'Banyan Studios',
|
||||
price: '55\u20AC / day',
|
||||
description: 'Organic Music Production in a Sustainable, Ethical and Professional Studio.',
|
||||
location: 'New York, NY \u2022 40mi away',
|
||||
review: { count: '8 reviews', rating: '4' },
|
||||
author: {
|
||||
name: 'The Stardust Collective',
|
||||
avatar: 'http://placehold.it/44x44',
|
||||
review: { rating: '4' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 1234,
|
||||
title: 'Pienix Studio',
|
||||
price: '80\u20AC / day',
|
||||
description: 'Pienix Studio specializes in music mixing and mastering production.',
|
||||
location: 'New York, NY \u2022 6mi away',
|
||||
review: { count: '7 reviews', rating: '4' },
|
||||
author: { name: 'Juhan', avatar: 'http://placehold.it/44x44', review: { rating: '4' } },
|
||||
},
|
||||
];
|
||||
/* eslint-enable import/prefer-default-export */
|
||||
|
||||
const fakeSDK = {
|
||||
fetchListings: () => {
|
||||
const randomTime = Math.random() * 2000;
|
||||
const fakeResponseTime = 1000 + randomTime;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => resolve(fakeListings), fakeResponseTime);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default fakeSDK;
|
||||
14
src/util/sagaHelpers.js
Normal file
14
src/util/sagaHelpers.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
// async actions use request - success / failure pattern
|
||||
const REQUEST = 'REQUEST';
|
||||
const SUCCESS = 'SUCCESS';
|
||||
const FAILURE = 'FAILURE';
|
||||
|
||||
// response format: { REQUEST: 'baseStr.REQUEST', SUCCESS: 'baseStr.SUCCESS', FAILURE: 'baseStr.FAILURE' }
|
||||
export const createRequestTypes = base => [REQUEST, SUCCESS, FAILURE].reduce((acc, type) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
acc[type] = `${base}.${type}`;
|
||||
return acc;
|
||||
}, {});
|
||||
/* eslint-enable import/prefer-default-export */
|
||||
Loading…
Add table
Reference in a new issue