├── .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 food; 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 |
6 |
7 | 8 | 9 | close 10 |
11 |
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 |
9 | 10 |
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 |
29 | 30 |
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 | 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 | --------------------------------------------------------------------------------