├── .babelrc
├── .env
├── .eslintrc.json
├── .gitignore
├── README.md
├── index.js
├── package.json
├── public
├── favicon.ico
├── index.html
└── manifest.json
├── src
├── App.jsx
├── App.test.js
├── actions
│ ├── api.js
│ └── landingPages.js
├── components
│ ├── 01_atom
│ │ ├── Button
│ │ │ └── Button.jsx
│ │ └── Picture
│ │ │ └── Picture.jsx
│ ├── 02_molecule
│ │ ├── Logo
│ │ │ └── Logo.jsx
│ │ ├── Navigation
│ │ │ └── Navigation.jsx
│ │ ├── Search
│ │ │ └── Search.jsx
│ │ └── Teaser
│ │ │ └── Teaser.jsx
│ ├── 03_organism
│ │ ├── PageFooter
│ │ │ └── PageFooter.jsx
│ │ ├── PageHeader
│ │ │ └── PageHeader.jsx
│ │ ├── TeaserFeatured
│ │ │ └── TeaserFeatured.jsx
│ │ └── TeaserList
│ │ │ └── TeaserList.jsx
│ ├── 04_template
│ │ ├── Home
│ │ │ └── Home.jsx
│ │ └── RecipeLanding
│ │ │ └── RecipeLanding.jsx
│ └── 05_page
│ │ └── Default
│ │ └── Default.jsx
├── index.js
├── reducers
│ ├── api.js
│ ├── index.js
│ └── landingPages.js
├── routes.js
├── styles
│ ├── breakpoints.js
│ └── grid.js
└── transforms
│ ├── category.js
│ ├── file.js
│ └── recipe.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["node6", "react-app"]
3 | }
4 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | PORT=3000
2 | REACT_APP_JSONAPI=https://dev-contentacms.pantheonsite.io/api
3 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb",
3 | "env": {
4 | "browser": true,
5 | "node": true
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .idea
15 | .env.local
16 | .env.development.local
17 | .env.test.local
18 | .env.production.local
19 |
20 | npm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Contenta React Demo
2 | with Redux, Aphrodite, and server-side rendering
3 |
4 | ```bash
5 | yarn install
6 | yarn start
7 | ```
8 |
9 | ## Available Scripts
10 |
11 | In the project directory, you can run:
12 |
13 | ### `yarn build && yarn start`
14 |
15 | Builds the app for production to the `build` folder.
16 | It correctly bundles React in production mode and optimizes the build for the best performance.
17 |
18 | The build is minified and the filenames include the hashes.
19 | Your app is ready to be deployed!
20 |
21 | See the section about [deployment](#deployment) for more information.
22 |
23 | After running ```yarn run build```, starts the app in production mode, which will include a server-side render of the initial page load.
24 |
25 | Visit [http://localhost:3000](http://localhost:3000) and try disabling JavaScript!
26 |
27 | ### `yarn run start:dev`
28 |
29 | Runs the app in the development mode.
30 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
31 |
32 | The page will reload if you make edits.
33 | You will also see any lint errors in the console.
34 |
35 |
36 | ## This project was bootstrapped with [Create React App](https://github.com/facebookincubator/create-react-app).
37 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import compression from 'compression';
3 | import path from 'path';
4 | import fs from 'fs';
5 | import React from 'react';
6 | import ReactDOMServer from 'react-dom/server';
7 | import { StyleSheetServer } from 'aphrodite'
8 | import { StaticRouter, matchPath } from 'react-router';
9 | import { createStore, combineReducers, applyMiddleware, compose } from 'redux';
10 | import { Provider } from 'react-redux';
11 | import thunkMiddleware from 'redux-thunk';
12 | import routes from './src/routes';
13 | import reducers from './src/reducers/index';
14 | import App from './src/App';
15 |
16 | const app = express();
17 | app.use(compression());
18 | const store = createStore(
19 | combineReducers(reducers),
20 | compose(
21 | applyMiddleware(thunkMiddleware),
22 | ),
23 | );
24 |
25 | app.use(express.static(path.join(__dirname, 'build')));
26 |
27 | app.get('*', function (req, res) {
28 | let template = fs.readFileSync(path.join(__dirname, 'build', '_index.html')).toString();
29 | const context = {};
30 | let promises = [];
31 |
32 | routes.some(route => {
33 | const match = matchPath(req.url, route);
34 | if (match && typeof route.component.loadData !== 'undefined') {
35 | const loadData = route.component.loadData;
36 | loadData.forEach(item => {
37 | promises.push(store.dispatch(item()));
38 | });
39 | }
40 | return match
41 | });
42 |
43 | Promise.all(promises).then((result) => {
44 | const { html, css } = StyleSheetServer.renderStatic(() => ReactDOMServer.renderToString(
45 |
46 |
50 |
51 |
52 |
53 | ));
54 |
55 | // This is quite a hack because we don't want to deviate from create-react-app and continue using
56 | // the same index.html file. If we add a templating indicator e.g. mustache, then it shows up
57 | // when doing client-side development.
58 | template = template.replace('
', `${html}
`)
59 | .replace('', ``);
60 |
61 | if (context.url) {
62 | res.redirect(301, context.url);
63 | }
64 | else {
65 | res.send(template);
66 | }
67 | });
68 | });
69 |
70 | app.listen(process.env.PORT);
71 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "contenta_react",
3 | "version": "0.1.0",
4 | "engines": {
5 | "yarn": "0.27.5",
6 | "node": "6.x"
7 | },
8 | "private": true,
9 | "dependencies": {
10 | "aphrodite": "1.2.1",
11 | "axios": "0.16.2",
12 | "babel-preset-node6": "11.0.0",
13 | "babel-register": "6.24.1",
14 | "compression": "^1.6.2",
15 | "css-type-base": "1.0.2",
16 | "dotenv": "4.0.0",
17 | "express": "4.15.3",
18 | "ignore-styles": "5.0.1",
19 | "json-api-normalizer": "0.4.1",
20 | "lodash": "4.17.4",
21 | "normalize.css": "7.0.0",
22 | "prop-types": "15.5.10",
23 | "react": "15.5.4",
24 | "react-dom": "15.5.4",
25 | "react-redux": "5.0.5",
26 | "react-redux-loading-bar": "^2.9.2",
27 | "react-router-dom": "4.1.1",
28 | "react-scripts": "1.0.7",
29 | "redux": "3.6.0",
30 | "redux-devtools-extension": "2.13.2",
31 | "redux-thunk": "2.2.0"
32 | },
33 | "devDependencies": {
34 | "eslint": "3.19.0",
35 | "eslint-config-airbnb": "15.0.2",
36 | "eslint-plugin-import": "2.6.1",
37 | "eslint-plugin-jsx-a11y": "5.1.1",
38 | "eslint-plugin-react": "7.1.0",
39 | "prettier": "1.4.4"
40 | },
41 | "scripts": {
42 | "postinstall": "yarn run build",
43 | "start": "NODE_ENV=production node -r dotenv/config -r babel-register -r ignore-styles index.js",
44 | "start:dev": "react-scripts start",
45 | "build": "react-scripts build && mv build/index.html build/_index.html",
46 | "test": "yarn run eslint -- src && react-scripts test --env=jsdom",
47 | "eject": "react-scripts eject"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/contentacms/contenta_react/e5c0ac251ec34d37747be9c2683447bf779956a3/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
17 |
26 | Umami
27 |
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Switch, Route } from 'react-router-dom';
3 | import routes from './routes';
4 | import LoadingBar from 'react-redux-loading-bar';
5 | import 'normalize.css/normalize.css';
6 | import { StyleSheet } from 'aphrodite';
7 |
8 | const globalSelectorHandler = (selector, _, generateSubtreeStyles) => {
9 | if (selector[0] !== "*") {
10 | return null;
11 | }
12 | return generateSubtreeStyles(selector.slice(1));
13 | };
14 | const extendedStylesheet = StyleSheet.extend([{selectorHandler: globalSelectorHandler}]);
15 |
16 | const App = () => (
17 |
18 |
19 |
20 | {routes.map(route =>
21 | ,
28 | )}
29 |
30 |
31 | );
32 |
33 | const styles = extendedStylesheet.StyleSheet.create({
34 | globals: {
35 | '**': {
36 | boxSizing: 'border-box',
37 | ':before': {
38 | boxSizing: 'inherit',
39 | },
40 | ':after': {
41 | boxSizing: 'inherit',
42 | },
43 | },
44 | '*html': {
45 | lineHeight: 1.5,
46 | fontFamily: ["Roboto", "sans-serif"],
47 | fontWeight: 'normal',
48 | },
49 | '*a': {
50 | textDecoration: 'none',
51 | '-webkit-tap-highlight-color': 'transparent',
52 | },
53 | '*h1, h2, h3, h4, h5, h6': {
54 | fontWeight: 400,
55 | lineHeight: 1.1,
56 | },
57 | '*h1 a, h2 a, h3 a, h4 a, h5 a, h6 a': {
58 | fontWeight: 'inherit',
59 | },
60 | '*h1': {
61 | fontSize: '4.2rem',
62 | lineHeight: '110%',
63 | margin: '2.1rem 0 1.68rem 0',
64 | },
65 | '*h2': {
66 | fontSize: '3.56rem',
67 | lineHeight: '110%',
68 | margin: '1.78rem 0 1.424rem 0',
69 | },
70 | '*h3': {
71 | fontSize: '2.9rem',
72 | lineHeight: '110%',
73 | margin: '1.45rem 0 1.16rem 0',
74 | },
75 | '*h4': {
76 | fontSize: '2.28rem',
77 | lineHeight: '110%',
78 | margin: '1.14rem 0 0.912rem 0',
79 | },
80 | '*h5': {
81 | fontSize: '1.64rem',
82 | lineHeight: '110%',
83 | margin: '0.82rem 0 0.656rem 0',
84 | },
85 | '*h6': {
86 | fontSize: '1rem',
87 | lineHeight: '110%',
88 | margin: '0.5rem 0 0.4rem 0',
89 | },
90 | '*em': {
91 | fontStyle: 'italic',
92 | },
93 | '*strong': {
94 | fontWeight: 500,
95 | },
96 | '*small': {
97 | fontSize: '75%',
98 | },
99 | '*img': {
100 | maxWidth: '100%',
101 | }
102 | },
103 | loadingBar: {
104 | position: 'absolute',
105 | height: '3px',
106 | backgroundColor: '#77b6ff',
107 | },
108 | });
109 |
110 | export default App;
111 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render(, div);
8 | });
9 |
--------------------------------------------------------------------------------
/src/actions/api.js:
--------------------------------------------------------------------------------
1 | import normalize from 'json-api-normalizer';
2 |
3 | export const STORE_CATEGORY = 'STORE_CATEGORY';
4 | export function storeCategory(category) {
5 | return {
6 | type: STORE_CATEGORY,
7 | payload: {
8 | category,
9 | },
10 | };
11 | }
12 |
13 | export const STORE_FILE = 'STORE_FILE';
14 | export function storeFile(file) {
15 | return {
16 | type: STORE_FILE,
17 | payload: {
18 | file,
19 | },
20 | };
21 | }
22 |
23 | export const STORE_RECIPE = 'STORE_RECIPE';
24 | export function storeRecipe(recipe, images, files) {
25 | return {
26 | type: STORE_RECIPE,
27 | payload: {
28 | recipe,
29 | images,
30 | files,
31 | },
32 | };
33 | }
34 |
35 | export function storeAPIData(data) {
36 | return function (dispatch) {
37 | const normalized = normalize(data);
38 | if (typeof normalized.categories !== 'undefined') {
39 | Object.keys(normalized.categories).forEach((categoryId) => {
40 | dispatch(storeCategory(normalized.categories[categoryId]));
41 | });
42 | }
43 |
44 | if (typeof normalized.files !== 'undefined') {
45 | Object.keys(normalized.files).forEach((fileId) => {
46 | dispatch(storeFile(normalized.files[fileId]));
47 | });
48 | }
49 |
50 | if (typeof normalized.recipes !== 'undefined') {
51 | Object.keys(normalized.recipes).forEach((recipeId) => {
52 | dispatch(storeRecipe(normalized.recipes[recipeId], normalized.images));
53 | });
54 | }
55 |
56 | return Promise.resolve();
57 | };
58 | }
59 |
--------------------------------------------------------------------------------
/src/actions/landingPages.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import * as apiActions from './api';
3 |
4 | const api = process.env.REACT_APP_JSONAPI;
5 |
6 | export const STORE_RECIPE_LANDING_PAGE = 'LOAD_RECIPE_LANDING_PAGE';
7 | export function storeRecipeLandingPage(categories, recipesByCategory) {
8 | return {
9 | type: STORE_RECIPE_LANDING_PAGE,
10 | payload: {
11 | categories,
12 | recipesByCategory,
13 | },
14 | };
15 | }
16 |
17 | export function loadRecipeLandingPage() {
18 | return function (dispatch) {
19 | let pageCategories = [];
20 | return axios(`${api}/categories`)
21 | .then((result) => {
22 | dispatch(apiActions.storeAPIData(result.data));
23 | return result.data.data;
24 | })
25 | .then(categories => categories.map(category => category.id))
26 | .then((categories) => {
27 | pageCategories = categories;
28 | return Promise.all(categories.map(category =>
29 | axios(`${api}/recipes`, {
30 | params: {
31 | 'filter[category.uuid][value]': category,
32 | 'page[limit]': 4,
33 | sort: 'created',
34 | include: 'image,image.thumbnail',
35 | isPromoted: true,
36 | },
37 | }),
38 | ));
39 | })
40 | .then((result) => {
41 | result.forEach((recipesInCategory) => {
42 | dispatch(apiActions.storeAPIData(recipesInCategory.data));
43 | });
44 |
45 | dispatch(storeRecipeLandingPage(pageCategories, result));
46 | });
47 | };
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/01_atom/Button/Button.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | const Button = (props) => (
5 |
6 | );
7 |
8 | Button.propTypes = {
9 | children: PropTypes.oneOfType([
10 | PropTypes.string,
11 | PropTypes.element
12 | ]).isRequired,
13 | };
14 |
15 | export default Button;
16 |
--------------------------------------------------------------------------------
/src/components/01_atom/Picture/Picture.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const Picture = (props) => {
4 | return
;
5 | };
6 |
7 | export default Picture;
8 |
--------------------------------------------------------------------------------
/src/components/02_molecule/Logo/Logo.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { StyleSheet, css } from 'aphrodite';
3 |
4 | const Logo = () => Umami
;
5 |
6 | const styles = StyleSheet.create({
7 | logo: {
8 | // color: Colors.shades.white,
9 | fontFamily: 'Helvetica',
10 | fontSize: '2.1rem',
11 | fontWeight: 'bold',
12 | },
13 | });
14 |
15 | export default Logo;
16 |
--------------------------------------------------------------------------------
/src/components/02_molecule/Navigation/Navigation.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { NavLink } from 'react-router-dom'
3 | import { StyleSheet, css } from 'aphrodite';
4 | import breakpoints from '../../../styles/breakpoints';
5 |
6 | const links = [
7 | ['Home', '/', 'home'],
8 | // ['Features', '/features', 'features'],
9 | ['Recipes', '/recipes', 'recipes'],
10 | // ['Magazine', '/magazine', 'magazine'],
11 | ];
12 |
13 | const Navigation = () => (
14 |
31 | );
32 |
33 | const styles = StyleSheet.create({
34 | ul: {
35 | listStyleType: 'none',
36 | padding: 0,
37 | },
38 | li: {
39 | margin: '0 0 1rem 0',
40 | [breakpoints.echoAndUp]: {
41 | display: 'inline-block',
42 | margin: '0 1rem 0 0',
43 | },
44 | },
45 | active: {
46 | borderBottom: '1px solid black',
47 | },
48 | });
49 |
50 | export default Navigation;
51 |
--------------------------------------------------------------------------------
/src/components/02_molecule/Search/Search.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { StyleSheet, css } from 'aphrodite';
3 |
4 | const Search = () => (
5 |
12 | );
13 |
14 | const styles = StyleSheet.create({
15 | form: {
16 | height: '100%',
17 | },
18 | inputField: {
19 | height: '100%',
20 | fontSize: '1.2rem',
21 | border: 'none',
22 | paddingLeft: '2rem',
23 | ':focus': {
24 | border: 'none',
25 | boxShadow: 'none',
26 | },
27 | },
28 | label: {
29 | top: 0,
30 | left: 0,
31 | },
32 | i: {
33 | color: 'rgba(255, 255, 255, 0.7)',
34 | transition: 'color .3s',
35 | },
36 | });
37 |
38 | export default Search;
39 |
--------------------------------------------------------------------------------
/src/components/02_molecule/Teaser/Teaser.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Picture from '../../01_atom/Picture/Picture';
3 | import { StyleSheet, css } from 'aphrodite';
4 |
5 | const Teaser = (props) => (
6 |
7 | {!props.flipped &&
8 |
11 | }
12 |
13 |
14 | {props.surtitle &&
15 |
16 | {props.surtitle}
17 |
18 | }
19 |
20 | {props.title}
21 |
22 |
23 | {props.subtitle}
24 |
25 |
26 |
27 | {props.flipped &&
28 |
31 | }
32 |
33 | );
34 |
35 | const styles = StyleSheet.create({
36 | container: {
37 | border: '1px solid #ccc',
38 | },
39 | text: {
40 | height: '6rem',
41 | overflow: 'hidden',
42 | padding: '1rem',
43 | },
44 | picture: {
45 | maxHeight: '150px',
46 | overflow: 'hidden',
47 | },
48 | subtitle: {
49 | fontSize: '0.7rem',
50 | },
51 | });
52 |
53 | export default Teaser;
54 |
--------------------------------------------------------------------------------
/src/components/03_organism/PageFooter/PageFooter.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { StyleSheet, css } from 'aphrodite';
3 | import breakpoints from '../../../styles/breakpoints';
4 | import grid from '../../../styles/grid';
5 |
6 | const PageFooter = () => (
7 |
8 |
9 | Umami Magazine & Umami Publications are purely fictional companies used for illustrative
10 | purposes only.
11 |
12 |
13 | Read more about this theme
14 |
15 |
16 | © Terms & Conditions
17 |
18 |
19 | );
20 |
21 | const styles = StyleSheet.create({
22 | col: {
23 | padding: '0 1rem 1rem 0',
24 | [breakpoints.echoAndUp]: {
25 | ...grid.span(3),
26 | padding: '0 1rem 0 0',
27 | }
28 | },
29 | middleCol: {
30 | [breakpoints.echoAndUp]: {
31 | textAlign: 'right',
32 | }
33 | },
34 | rightCol: {
35 | [breakpoints.echoAndUp]: {
36 | padding: 0,
37 | }
38 | },
39 | });
40 |
41 | export default PageFooter;
42 |
--------------------------------------------------------------------------------
/src/components/03_organism/PageHeader/PageHeader.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Logo from '../../02_molecule/Logo/Logo';
3 | import Navigation from '../../02_molecule/Navigation/Navigation';
4 | import { StyleSheet, css } from 'aphrodite';
5 | import breakpoints from '../../../styles/breakpoints';
6 |
7 | const PageHeader = () => (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | );
17 |
18 | const styles = StyleSheet.create({
19 | block: {
20 | [breakpoints.echoAndUp]: {
21 | display: 'inline-block',
22 | },
23 | },
24 | nav: {
25 | [breakpoints.echoAndUp]: {
26 | float: 'right',
27 | }
28 | },
29 | });
30 |
31 | export default PageHeader;
32 |
--------------------------------------------------------------------------------
/src/components/03_organism/TeaserFeatured/TeaserFeatured.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Link } from 'react-router-dom';
4 | import { StyleSheet, css } from 'aphrodite';
5 | import Button from '../../01_atom/Button/Button';
6 | import grid from '../../../styles/grid';
7 | import breakpoint from '../../../styles/breakpoints';
8 |
9 | const TeaserFeatured = (props) => (
10 |
11 |
12 |
{props.title}
13 |
{props.body}
14 | {props.cta &&
15 |
16 | }
17 |
18 |
19 | );
20 |
21 | const styles = StyleSheet.create({
22 | context: {
23 | ...grid.context
24 | },
25 | span: {
26 | display: 'block',
27 | padding: '1rem',
28 | [breakpoint.echoAndUp]: {
29 | ...grid.span(6),
30 | }
31 | },
32 | right: {
33 | float: 'right',
34 | }
35 | });
36 |
37 | TeaserFeatured.defaultProps = {
38 | textAlignment: 'left',
39 | };
40 |
41 | TeaserFeatured.propTypes = {
42 | textAlignment: PropTypes.string,
43 | title: PropTypes.string.isRequired,
44 | body: PropTypes.string.isRequired,
45 | cta: PropTypes.shape({
46 | title: PropTypes.string,
47 | path: PropTypes.string.isRequired,
48 | }),
49 | };
50 |
51 | export default TeaserFeatured;
52 |
--------------------------------------------------------------------------------
/src/components/03_organism/TeaserList/TeaserList.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Button from '../../01_atom/Button/Button';
3 | import Teaser from '../../02_molecule/Teaser/Teaser';
4 | import { StyleSheet, css } from 'aphrodite';
5 | import grid from '../../../styles/grid';
6 | import breakpoint from '../../../styles/breakpoints';
7 |
8 | const TeaserList = (props) => (
9 |
10 |
11 | {props.teasers.map(teaser => (
12 | -
13 |
14 |
15 | ))}
16 |
17 |
18 |
19 | );
20 |
21 | const styles = StyleSheet.create({
22 | context: grid.context,
23 | column: {
24 | display: 'block',
25 | padding: '1rem',
26 | [breakpoint.echoAndUp]: {
27 | ...grid.span(6),
28 | },
29 | [breakpoint.limaAndUp]: {
30 | ...grid.span(3),
31 | }
32 | },
33 | });
34 |
35 | TeaserList.defaultProps = {
36 | teasers: [],
37 | };
38 |
39 | export default TeaserList;
40 |
--------------------------------------------------------------------------------
/src/components/04_template/Home/Home.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Default from '../../05_page/Default/Default';
3 |
4 | const Home = () => (
5 |
6 |
7 | Hello, world!
8 |
9 |
10 | );
11 |
12 | export default Home;
13 |
--------------------------------------------------------------------------------
/src/components/04_template/RecipeLanding/RecipeLanding.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import * as landingPageActions from '../../../actions/landingPages';
4 | import * as loadingBarActions from 'react-redux-loading-bar';
5 | import Default from '../../05_page/Default/Default';
6 | import TeaserFeatured from '../../03_organism/TeaserFeatured/TeaserFeatured';
7 | import TeaserList from '../../03_organism/TeaserList/TeaserList';
8 |
9 | class RecipeLanding extends Component {
10 | componentDidMount() {
11 | if (!this.props.landingPageCategories.length) {
12 | this.props.showLoading();
13 | this.props.loadRecipeLandingPage();
14 | }
15 | }
16 | componentWillReceiveProps(nextProps) {
17 | if (nextProps.landingPageCategories.length) {
18 | this.props.hideLoading();
19 | }
20 | }
21 | render() {
22 | if (this.props.landingPageCategories.length) {
23 | return (
24 |
25 |
26 |
34 | {this.props.landingPageCategories.map(category => (
35 |
36 |
{this.props.categories[category.id].title}
37 | ({
38 | id: recipe,
39 | title: this.props.recipes[recipe].title,
40 | subtitle: this.props.recipes[recipe].time > 0 ? `${this.props.recipes[recipe].time}m` : '',
41 | image: this.props.files[this.props.recipes[recipe].image].uri,
42 | }))}/>
43 |
44 | ))}
45 |
54 |
55 |
56 | );
57 | }
58 | return null;
59 | }
60 | }
61 |
62 | RecipeLanding.defaultProps = {
63 | categories: {},
64 | files: {},
65 | landingPageCategories: {},
66 | recipes: {},
67 | };
68 |
69 | RecipeLanding.loadData = [landingPageActions.loadRecipeLandingPage];
70 |
71 | export default connect((state) => ({
72 | categories: state.api.categories,
73 | files: state.api.files,
74 | landingPageCategories: state.landingPages.categories,
75 | recipes: state.api.recipes,
76 | }), { ...landingPageActions, ...loadingBarActions })(RecipeLanding);
77 |
--------------------------------------------------------------------------------
/src/components/05_page/Default/Default.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import PageHeader from '../../03_organism/PageHeader/PageHeader';
4 | import PageFooter from '../../03_organism/PageFooter/PageFooter';
5 |
6 | const Default = (props) => (
7 |
8 |
9 |
10 | {props.children}
11 |
12 |
13 |
14 | );
15 |
16 | Default.propTypes = {
17 | children: PropTypes.element.isRequired,
18 | };
19 |
20 | export default Default;
21 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import { createStore, combineReducers, applyMiddleware } from 'redux';
5 | import { Provider } from 'react-redux';
6 | import thunkMiddleware from 'redux-thunk';
7 | import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
8 | import { loadingBarReducer as loadingBar } from 'react-redux-loading-bar';
9 | import reducers from './reducers/index';
10 | import App from './App';
11 |
12 | /* eslint-disable no-underscore-dangle */
13 | const preloadedState = typeof window.__PRELOADED_STATE__ !== 'undefined'
14 | ? window.__PRELOADED_STATE__
15 | : {};
16 | delete window.__PRELOADED_STATE__;
17 | /* eslint-enable no-underscore-dangle */
18 |
19 | const store = createStore(
20 | combineReducers({ ...reducers, loadingBar }),
21 | preloadedState,
22 | composeWithDevTools(
23 | applyMiddleware(thunkMiddleware),
24 | ),
25 | );
26 |
27 | ReactDOM.render(
28 |
29 |
30 |
31 |
32 | ,
33 | document.getElementById('root'),
34 | );
35 | // registerServiceWorker();
36 |
--------------------------------------------------------------------------------
/src/reducers/api.js:
--------------------------------------------------------------------------------
1 | import {
2 | STORE_CATEGORY,
3 | STORE_FILE,
4 | STORE_RECIPE,
5 | } from '../actions/api';
6 | import categoryTransform from '../transforms/category';
7 | import fileTransform from '../transforms/file';
8 | import recipeTransform from '../transforms/recipe';
9 |
10 | const initialState = {
11 | categories: {},
12 | files: {},
13 | recipes: {},
14 | };
15 |
16 | export default (state = initialState, action) => {
17 | switch (action.type) {
18 | case STORE_CATEGORY: {
19 | const category = categoryTransform(action.payload.category);
20 | return {
21 | ...state,
22 | categories: {
23 | ...state.categories,
24 | [category.id]: category,
25 | },
26 | };
27 | }
28 | case STORE_FILE: {
29 | const file = fileTransform(action.payload.file);
30 | return {
31 | ...state,
32 | files: {
33 | ...state.files,
34 | [file.id]: file,
35 | },
36 | };
37 | }
38 | case STORE_RECIPE: {
39 | const recipe = recipeTransform(action.payload.recipe, action.payload.images);
40 | return {
41 | ...state,
42 | recipes: {
43 | ...state.recipes,
44 | [recipe.id]: recipe,
45 | },
46 | };
47 | }
48 | default:
49 | return state;
50 | }
51 | };
52 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import api from './api';
2 | import landingPages from './landingPages';
3 |
4 | export default { api, landingPages };
5 |
--------------------------------------------------------------------------------
/src/reducers/landingPages.js:
--------------------------------------------------------------------------------
1 | import {
2 | STORE_RECIPE_LANDING_PAGE,
3 | } from '../actions/landingPages';
4 |
5 | const initialState = {
6 | categories: [],
7 | };
8 |
9 | export default (state = initialState, action) => {
10 | switch (action.type) {
11 | case STORE_RECIPE_LANDING_PAGE: {
12 | return {
13 | ...state,
14 | categories: action.payload.categories.map((category, index) => ({
15 | id: category,
16 | recipes: action.payload.recipesByCategory[index].data.data.map(recipe => recipe.id),
17 | })),
18 | };
19 | }
20 | default:
21 | return state;
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/src/routes.js:
--------------------------------------------------------------------------------
1 | /**
2 | * React Router compatible routes.
3 | *
4 | * The loadData function will allow data to be loaded on the server before being rendered.
5 | * It returns an array of Redux Thunks.
6 | */
7 | import Home from './components/04_template/Home/Home';
8 | import RecipeLanding from './components/04_template/RecipeLanding/RecipeLanding';
9 |
10 | const routes = [
11 | {
12 | path: '/',
13 | component: Home,
14 | exact: true,
15 | strict: true,
16 | },
17 | {
18 | path: '/recipes',
19 | component: RecipeLanding,
20 | exact: true,
21 | strict: true,
22 | },
23 | ];
24 |
25 | export default routes;
26 |
--------------------------------------------------------------------------------
/src/styles/breakpoints.js:
--------------------------------------------------------------------------------
1 | const screenSize = {
2 | echo: 600,
3 | lima: 992,
4 | tango: 1200,
5 | };
6 |
7 | const breakpoints = {
8 | echoAndUp: `@media (min-width : ${screenSize.echo}px)`,
9 | limaAndUp: `@media (min-width : ${screenSize.lima}px)`,
10 | tangoAndUp: `@media (min-width : ${screenSize.tango}px)`,
11 | };
12 |
13 | export default breakpoints;
14 |
--------------------------------------------------------------------------------
/src/styles/grid.js:
--------------------------------------------------------------------------------
1 | const grid = {
2 | context: {
3 | ':after': {
4 | content: '""',
5 | display: 'table',
6 | clear: 'both',
7 | },
8 | },
9 | span: (span, columns = 12) => ({
10 | float: 'left',
11 | width: `${(span / columns) * 100}%`,
12 | }),
13 | };
14 |
15 | export default grid;
16 |
--------------------------------------------------------------------------------
/src/transforms/category.js:
--------------------------------------------------------------------------------
1 | import { get } from 'lodash';
2 |
3 | const transform = category => ({
4 | id: category.id,
5 | title: get(category, 'attributes.name'),
6 | });
7 |
8 | export default transform;
9 |
--------------------------------------------------------------------------------
/src/transforms/file.js:
--------------------------------------------------------------------------------
1 | import { get } from 'lodash';
2 |
3 | const transform = file => ({
4 | id: file.id,
5 | uri: get(file, 'attributes.uri'),
6 | });
7 |
8 | export default transform;
9 |
--------------------------------------------------------------------------------
/src/transforms/recipe.js:
--------------------------------------------------------------------------------
1 | import { get } from 'lodash';
2 |
3 | export default function (recipe, images) {
4 | const imageId = get(recipe, 'relationships.image.data.id');
5 | const fileId = images[imageId].relationships.imageFile.data.id;
6 | return {
7 | id: recipe.id,
8 | title: get(recipe, 'attributes.title'),
9 | promoted: get(recipe, 'isPromoted'),
10 | image: fileId,
11 | time: get(recipe, 'attributes.totalTime'),
12 | };
13 | }
14 |
--------------------------------------------------------------------------------