├── .babelrc
├── .editorconfig
├── .gitignore
├── .npmrc
├── .nvmrc
├── .prettierrc
├── Dockerfile
├── README.md
├── index.js
├── package-lock.json
├── package.json
├── src
├── client.tsx
├── components
│ ├── App
│ │ ├── App.css
│ │ └── App.tsx
│ ├── Button
│ │ ├── Button.css
│ │ └── Button.tsx
│ ├── Footer
│ │ ├── Footer.css
│ │ └── Footer.tsx
│ ├── Header
│ │ ├── Header.css
│ │ ├── Header.tsx
│ │ └── logo.jpg
│ ├── Page
│ │ ├── Page.css
│ │ └── Page.tsx
│ ├── PageContainer
│ │ └── PageContainer.tsx
│ ├── PageMeta
│ │ └── PageMeta.tsx
│ ├── Rect
│ │ ├── Rect.css
│ │ └── Rect.tsx
│ ├── Sneakers
│ │ ├── Sneakers.css
│ │ ├── Sneakers.stub.tsx
│ │ └── Sneakers.tsx
│ ├── SneakersList
│ │ ├── SneakersList.css
│ │ ├── SneakersList.stub.tsx
│ │ └── SneakersList.tsx
│ └── index.ts
├── index.html
├── pages
│ ├── 404
│ │ └── 404.tsx
│ ├── Catalog
│ │ ├── Catalog.stub.tsx
│ │ └── Catalog.tsx
│ ├── Home
│ │ ├── Home.stub.tsx
│ │ └── Home.tsx
│ ├── Sneakers
│ │ ├── Sneakers.css
│ │ ├── Sneakers.hook.ts
│ │ ├── Sneakers.stub.tsx
│ │ └── Sneakers.tsx
│ └── Upcoming
│ │ ├── Upcoming.css
│ │ ├── Upcoming.tsx
│ │ └── nike-basketball-roster.jpg
├── routes.ts
├── server-render-middleware.tsx
├── server.ts
├── store
│ ├── ducks
│ │ ├── catalog
│ │ │ ├── actions.ts
│ │ │ ├── mock.json
│ │ │ ├── reducer.ts
│ │ │ ├── saga.ts
│ │ │ ├── selectors.ts
│ │ │ ├── service.ts
│ │ │ └── types.ts
│ │ ├── homepage
│ │ │ ├── actions.ts
│ │ │ ├── mock.json
│ │ │ ├── reducer.ts
│ │ │ ├── saga.ts
│ │ │ ├── selectors.ts
│ │ │ ├── service.ts
│ │ │ └── types.ts
│ │ ├── router
│ │ │ ├── saga.ts
│ │ │ ├── selectors.ts
│ │ │ └── types.ts
│ │ ├── shoes
│ │ │ ├── actions.ts
│ │ │ ├── mock.json
│ │ │ ├── reducer.ts
│ │ │ ├── saga.ts
│ │ │ ├── selectors.ts
│ │ │ ├── service.ts
│ │ │ └── types.ts
│ │ └── timeoutHelper.ts
│ ├── getInitialState.ts
│ ├── rootReducer.ts
│ ├── rootSaga.ts
│ └── rootStore.ts
├── styles
│ ├── base.css
│ └── media.css
└── types
│ ├── index.ts
│ ├── models.ts
│ └── redux.ts
├── static
├── images
│ ├── 104265_131.jpg
│ ├── 487471_100.jpg
│ ├── 532225_006.jpg
│ ├── 554724_050.jpg
│ ├── 555088_081.jpg
│ ├── 624041_800.jpg
│ ├── 749766_408.jpg
│ ├── 917165_120.jpg
│ ├── AA2146_003.jpg
│ ├── AH7241_118.jpg
│ ├── AH7242_118.jpg
│ ├── AH7368_801.jpg
│ ├── AH7369_801.jpg
│ ├── AJ1285_103.jpg
│ ├── AJ2018_004.jpg
│ ├── AJ5898_001.jpg
│ ├── AJ6745_003.jpg
│ ├── AJ6900_001.jpg
│ ├── AJ6900_100.jpg
│ ├── AO0566_440.jpg
│ ├── AO1741_103.jpg
│ ├── AO2409_100.jpg
│ ├── AO2434_101.jpg
│ ├── AO2439_401.jpg
│ ├── AO2582_004.jpg
│ ├── AO2924_008.jpg
│ ├── AO3258_440.jpg
│ ├── AO3266_410.jpg
│ ├── AO3276_410.jpg
│ ├── AO3277_600.jpg
│ ├── AO4436_001.jpg
│ ├── AO6219_100.jpg
│ ├── AQ1289_100.jpg
│ ├── AQ2235_100.jpg
│ ├── AQ3619_400.jpg
│ ├── AQ5707_002.jpg
│ ├── AQ7495_100.jpg
│ ├── AQ8306_600.jpg
│ ├── AQ8741_300.jpg
│ ├── AQ9164_005.jpg
│ ├── AR4229_001.jpg
│ ├── AR6631_007.jpg
│ ├── AR6631_200.jpg
│ ├── BQ7460_102.jpg
│ ├── BQ7496_104.jpg
│ ├── BV1654_002.jpg
│ ├── BV7406_001.jpg
│ ├── CD8238_001.jpg
│ ├── CD9560_106.jpg
│ ├── CI1502_001.jpg
│ ├── CI1503_001.jpg
│ ├── CI1504_100.jpg
│ ├── CI1505_001.jpg
│ ├── CI1506_001.jpg
│ ├── CI1508_400.jpg
│ ├── CI2668_300.jpg
│ ├── CJ0767_400.jpg
│ ├── CJ1436_100.jpg
│ ├── CK6643_100.jpg
│ ├── bs2mikksg5tqynlbq3sl.jpg
│ ├── favicon.png
│ ├── o8pc93ifccwo1qihdpqp.jpg
│ ├── pljue2bfqbmq6ruw0ht1.jpg
│ ├── tqbhkhohrwonshwh7srl.jpg
│ ├── ugc_659925883.tif.jpg
│ ├── ul2yvanoaw2ntfhslqd2.jpg
│ └── ux4wrrodlumtnefucnq8.jpg
├── preview-preload-bundles.gif
└── preview.gif
├── tsconfig.json
├── webpack.config.ts
└── webpack
├── client.config.ts
├── env.ts
├── loaders
├── css.ts
├── file.ts
└── js.ts
└── server.config.ts
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "@babel/preset-typescript",
5 | "@babel/preset-react"
6 | ],
7 | "plugins": [
8 | "react-hot-loader/babel",
9 | "@babel/plugin-proposal-class-properties",
10 | "@babel/plugin-syntax-dynamic-import",
11 | "@loadable/babel-plugin"
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*]
2 | indent_style = space
3 | indent_size = 4
4 | end_of_line = lf
5 | charset = utf-8
6 | trim_trailing_whitespace = true
7 | insert_final_newline = true
8 |
9 | [{*.json,*.yml}]
10 | indent_size = 2
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | dist/
3 | .idea
4 | temp
5 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | save=true
2 | save-exact=true
3 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 12.13.0
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "endOfLine": "lf",
3 | "arrowParens": "avoid",
4 | "semi": true,
5 | "jsxBracketSameLine": false,
6 | "jsxSingleQuote": false,
7 | "bracketSpacing": true,
8 | "singleQuote": true,
9 | "tabWidth": 4,
10 | "printWidth": 80,
11 | "trailingComma": "es5"
12 | }
13 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:12
2 |
3 | COPY node_modules /app/node_modules
4 | COPY dist /app/dist
5 | COPY static /app/static
6 | COPY index.js /app/
7 | COPY package.json /app/
8 | COPY package-lock.json /app/
9 |
10 | WORKDIR /app
11 |
12 | ENV NODE_ENV production
13 | ENV PORT 80
14 |
15 | EXPOSE 80
16 |
17 | CMD node /app/index.js
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Server Side Rendering Tutorial
2 |
3 | > :rocket: A project starter with server side rendering for React application with react-router, redux state, react-helmet, redux-saga and code-splitting. See live demo: [http://bit.do/react-ssr-demo](http://bit.do/react-ssr-demo).
4 |
5 | 
6 |
7 | ### Features
8 |
9 | - **Actual stack** (React / Hooks / Redux / Redux-saga)
10 | - Fully typed by **Typescript**
11 | - **A lot of small improvements**: component to control code status, pages stub, fetch data on server, code splitting by loadable-components and preload bundles by hover:
12 |
13 | 
14 |
15 | ### Step-by-step branches
16 |
17 | You can choose a specific branch at a specific development step:
18 |
19 | - [client-side-version](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/tree/client-side-version) - Basic version of app without SSR
20 | - [01-prepare-webpack-and-express-server](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/tree/01-prepare-webpack-and-express-server) - Prepare webpack config for server bundle and setup express server
21 | - [02-add-redux-and-react-router](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/tree/) - Integrate redux and react-router with SSR
22 | - [03-add-react-helmet](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/tree/03-add-react-helmet) - Add React-helmet for metatags
23 | - [04-add-redux-saga-and-async-data](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/tree/04-add-redux-saga-and-async-data) - Run Redux-saga and fetch data on server side
24 | - [05-add-code-splitting](https://github.com/noveogroup-amorgunov/react-ssr-tutorial/tree/05-add-code-splitting) - Add cofe-splitting and lazy loading
25 |
26 | ### Getting started
27 |
28 | Install dependencies:
29 |
30 | ```
31 | npm install
32 | ```
33 |
34 | Run development mode:
35 |
36 | ```
37 | npm start
38 | ```
39 |
40 | Now application is available in [http://localhost:9001](http://localhost:9001).
41 |
42 | Build production bundle:
43 |
44 | ```
45 | npm run build
46 | ```
47 |
48 | Or run inside docker container:
49 |
50 | ```
51 | npm run build
52 | npm run docker
53 | ```
54 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const { app } = require('./dist/server.js');
2 |
3 | const port = process.env.PORT || 9001;
4 |
5 | app.listen(port, () => {
6 | console.log(`Application is started on localhost:${port}`);
7 | });
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-ssr-tutorial",
3 | "private": true,
4 | "engines": {
5 | "node": "12"
6 | },
7 | "scripts": {
8 | "clean": "rimraf dist",
9 | "build": "NODE_ENV=production NODE_PATH=. npm run clean && webpack --mode production",
10 | "format": "prettier --write \"src/**/*.{ts,tsx}\" \"webpack/**/*.ts\"",
11 | "check-types": "tsc --noEmit",
12 | "start:webpack": "webpack --mode=development --watch",
13 | "start:server": "nodemon index.js --watch dist/server.js",
14 | "start": "NODE_ENV=development npm-run-all --print-label --parallel start:*",
15 | "docker:build": "docker build -t $npm_package_name .",
16 | "docker:run": "docker run -p 9001:80 $npm_package_name",
17 | "docker": "npm-run-all docker:*",
18 | "heroku:login": "heroku container:login",
19 | "heroku:push": "heroku container:push web",
20 | "heroku:release": "heroku container:release web",
21 | "heroku:open": "heroku open",
22 | "heroku": "npm-run-all heroku:*",
23 | "heroku-init": "heroku login && heroku create $npm_package_name"
24 | },
25 | "dependencies": {
26 | "@hot-loader/react-dom": "16.8.4",
27 | "@loadable/component": "5.14.1",
28 | "@loadable/server": "5.14.0",
29 | "b_": "1.3.4",
30 | "babel-polyfill": "6.26.0",
31 | "compression": "1.7.4",
32 | "connected-react-router": "6.3.2",
33 | "express": "4.17.1",
34 | "history": "4.9.0",
35 | "immer": "6.0.3",
36 | "react": "17.0.1",
37 | "react-dom": "17.0.1",
38 | "react-helmet": "6.1.0",
39 | "react-hot-loader": "4.8.3",
40 | "react-redux": "7.2.0",
41 | "react-router-dom": "5.1.2",
42 | "react-slick": "0.23.2",
43 | "redux": "4.0.1",
44 | "redux-saga": "1.0.2",
45 | "slick-carousel": "1.8.1",
46 | "webpack-dev-middleware": "3.7.2",
47 | "webpack-hot-middleware": "2.24.3"
48 | },
49 | "devDependencies": {
50 | "@babel/core": "7.3.4",
51 | "@babel/plugin-proposal-class-properties": "7.4.0",
52 | "@babel/plugin-syntax-dynamic-import": "7.2.0",
53 | "@babel/preset-env": "7.3.4",
54 | "@babel/preset-react": "7.0.0",
55 | "@babel/preset-typescript": "7.9.0",
56 | "@babel/register": "7.4.0",
57 | "@loadable/babel-plugin": "5.13.2",
58 | "@loadable/webpack-plugin": "5.14.0",
59 | "@types/b_": "1.3.1",
60 | "@types/compression": "1.7.0",
61 | "@types/compression-webpack-plugin": "2.0.1",
62 | "@types/copy-webpack-plugin": "6.0.0",
63 | "@types/express": "4.17.4",
64 | "@types/html-webpack-plugin": "3.2.3",
65 | "@types/loadable__component": "5.13.1",
66 | "@types/loadable__server": "5.12.3",
67 | "@types/loadable__webpack-plugin": "5.7.1",
68 | "@types/mini-css-extract-plugin": "0.9.1",
69 | "@types/node": "13.11.0",
70 | "@types/react": "16.9.52",
71 | "@types/react-dom": "16.9.8",
72 | "@types/react-helmet": "6.1.0",
73 | "@types/react-redux": "7.1.7",
74 | "@types/react-router-dom": "5.1.3",
75 | "@types/react-slick": "0.23.4",
76 | "@types/redux": "3.6.0",
77 | "@types/webpack": "4.41.22",
78 | "@types/webpack-dev-middleware": "3.7.0",
79 | "@types/webpack-dev-server": "3.11.0",
80 | "@types/webpack-hot-middleware": "2.25.0",
81 | "@types/webpack-node-externals": "2.5.0",
82 | "autoprefixer": "9.4.10",
83 | "babel-loader": "8.0.5",
84 | "compression-webpack-plugin": "2.0.0",
85 | "copy-webpack-plugin": "5.1.1",
86 | "css-hot-loader": "1.4.4",
87 | "css-loader": "2.1.1",
88 | "cssnano": "4.1.10",
89 | "file-loader": "3.0.1",
90 | "html-webpack-plugin": "3.2.0",
91 | "husky": "1.3.1",
92 | "mini-css-extract-plugin": "0.5.0",
93 | "nodemon": "2.0.5",
94 | "npm-run-all": "4.1.5",
95 | "null-loader": "4.0.1",
96 | "postcss": "7.0.14",
97 | "postcss-custom-media": "7.0.7",
98 | "postcss-import": "12.0.1",
99 | "postcss-import-alias-resolver": "0.1.1",
100 | "postcss-load-config": "2.0.0",
101 | "postcss-loader": "3.0.0",
102 | "postcss-modules": "1.4.1",
103 | "postcss-nested": "4.1.2",
104 | "prettier": "2.0.4",
105 | "pretty-quick": "3.1.0",
106 | "rimraf": "2.6.3",
107 | "source-map-loader": "0.2.4",
108 | "style-loader": "0.23.1",
109 | "ts-loader": "6.2.2",
110 | "tsconfig-paths-webpack-plugin": "3.2.0",
111 | "type-fest": "0.17.0",
112 | "typescript": "4.0.3",
113 | "url-loader": "1.1.2",
114 | "webpack": "4.30.0",
115 | "webpack-cli": "4.0.0",
116 | "webpack-dev-server": "3.11.0",
117 | "webpack-node-externals": "2.5.2",
118 | "write-file-webpack-plugin": "4.5.0"
119 | },
120 | "browserslist": [
121 | "last 2 versions"
122 | ],
123 | "husky": {
124 | "hooks": {
125 | "pre-commit": "npm run check-types && pretty-quick --staged"
126 | }
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/src/client.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { hydrate } from 'react-dom';
3 | import { ConnectedRouter } from 'connected-react-router';
4 | import { Provider as ReduxProvider } from 'react-redux';
5 | import { loadableReady } from '@loadable/component';
6 | import 'babel-polyfill';
7 |
8 | import { App } from 'components';
9 | import { State } from 'types';
10 | import { configureStore } from './store/rootStore';
11 |
12 | const { store, history } = configureStore(window.__INITIAL_STATE__);
13 |
14 | // global redeclared types
15 | declare global {
16 | interface Window {
17 | __INITIAL_STATE__: State;
18 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: Function;
19 | }
20 | }
21 |
22 | loadableReady(() => {
23 | hydrate(
24 |
25 |
26 |
27 |
28 | ,
29 | document.getElementById('mount')
30 | );
31 | });
32 |
--------------------------------------------------------------------------------
/src/components/App/App.css:
--------------------------------------------------------------------------------
1 | @import '~styles/base.css';
2 |
3 | .app {
4 | width: 100%;
5 | color: #000;
6 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen-Sans, Ubuntu, Cantarell, Helvetica Neue, sans-serif;
7 |
8 | display: flex;
9 |
10 | flex-direction: column;
11 | flex: 1 1;
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/App/App.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Switch, Route } from 'react-router-dom';
3 | import { hot } from 'react-hot-loader/root';
4 | import { Header, Footer } from 'components';
5 | import routes from 'routes';
6 |
7 | import './App.css';
8 |
9 | function App() {
10 | return (
11 |
12 |
13 |
14 | {routes.map(({ fetchData, ...routeProps }) => (
15 |
16 | ))}
17 |
18 |
19 |
20 | );
21 | }
22 |
23 | const Component = hot(App);
24 |
25 | export { Component as App };
26 |
--------------------------------------------------------------------------------
/src/components/Button/Button.css:
--------------------------------------------------------------------------------
1 | .button {
2 | display: inline-block;
3 | border: 3px solid #c4c4c4;
4 | border-radius: 20px;
5 | padding: 10px 20px;
6 | color: #000;
7 | cursor: pointer;
8 | background: transparent;
9 | font-size: 16px;
10 | margin-bottom: 5px;
11 | transition: all .1s ease;
12 |
13 | &:hover {
14 | border: 3px solid #ff5c28;
15 | }
16 |
17 | &_size_xl {
18 | font-size: 26px;
19 | padding: 15px 30px;
20 | border-radius: 30px;
21 | }
22 |
23 | &_size_xs {
24 | font-size: 12px;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/Button/Button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as bem from 'b_';
3 |
4 | import './Button.css';
5 |
6 | export enum ButtonSizes {
7 | L = 'l',
8 | M = 'm',
9 | S = 's',
10 | }
11 |
12 | type Props = {
13 | size: ButtonSizes;
14 | type: 'submit' | 'button' | 'reset';
15 | children: React.ReactNode;
16 | };
17 |
18 | const b = bem.with('button');
19 |
20 | const Button = (props: Props) => {
21 | const { size, type, children } = props;
22 |
23 | return (
24 |
27 | );
28 | };
29 |
30 | Button.defaultProps = {
31 | size: ButtonSizes.M,
32 | type: 'button',
33 | children: null,
34 | };
35 |
36 | export { Button };
37 |
--------------------------------------------------------------------------------
/src/components/Footer/Footer.css:
--------------------------------------------------------------------------------
1 | .footer {
2 | padding: 50px;
3 | text-align: center;
4 | }
5 |
--------------------------------------------------------------------------------
/src/components/Footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as bem from 'b_';
3 |
4 | import './Footer.css';
5 |
6 | const b = bem.with('footer');
7 |
8 | export function Footer() {
9 | const currentYear = new Date().getFullYear();
10 |
11 | return (
12 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/Header/Header.css:
--------------------------------------------------------------------------------
1 | @import '~styles/media.css';
2 |
3 | .header {
4 | max-width: 800px;
5 | margin: 0 auto;
6 | padding: 35px;
7 | align-items: center;
8 | justify-content: center;
9 | text-align: center;
10 | }
11 |
12 | .header__logo {
13 | background-image: url("./logo.jpg");
14 | width: 100px;
15 | height: 120px;
16 | background-position: center;
17 | background-size: cover;
18 | margin: 0 auto;
19 | margin-bottom: 25px;
20 | }
21 |
22 | .header__nav {
23 | }
24 |
25 | .header__nav-item {
26 | margin-right: 15px;
27 | font-weight: bold;
28 | color: #000;
29 | font-size: 16px;
30 | text-decoration: none;
31 | text-transform: uppercase;
32 | font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
33 |
34 | &:hover {
35 | color: #aaa;
36 | }
37 |
38 | &_active {
39 | color: #aaa;
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/Header/Header.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { NavLink } from 'react-router-dom';
3 | import loadable from '@loadable/component';
4 | import * as bem from 'b_';
5 |
6 | import './Header.css';
7 |
8 | enum PageName {
9 | Home = 'Home',
10 | Catalog = 'Catalog',
11 | Upcoming = 'Upcoming',
12 | }
13 |
14 | const menu = [
15 | { to: '/', exact: true, page: PageName.Home },
16 | { to: '/catalog', exact: true, page: PageName.Catalog },
17 | { to: '/upcoming', exact: true, page: PageName.Upcoming },
18 | ];
19 |
20 | const preloadPage = (pageName: string) =>
21 | loadable(() => import(`../../pages/${pageName}/${pageName}`));
22 |
23 | const b = bem.with('header');
24 |
25 | export function Header() {
26 | return (
27 |
28 |
29 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/Header/logo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/src/components/Header/logo.jpg
--------------------------------------------------------------------------------
/src/components/Page/Page.css:
--------------------------------------------------------------------------------
1 | @import '~styles/media.css';
2 |
3 | .page {
4 | flex: 1 0 auto;
5 | flex-grow: 1;
6 |
7 | &_align_center {
8 | text-align: center;
9 | }
10 |
11 | &__container {
12 | max-width: 760px;
13 | margin: 25px auto 80px;
14 |
15 | @media (--viewport-mobile) {
16 | padding: 0 15px;
17 | }
18 |
19 | &_clear::after {
20 | content: "";
21 | clear: both;
22 | display: table;
23 | }
24 | }
25 |
26 | &__container-more {
27 | margin-top: 40px;
28 | }
29 |
30 | &__container h2 {
31 | text-align: left;
32 | padding-left: 10px;
33 |
34 | @media (--viewport-mobile) {
35 | text-align: center;
36 | }
37 |
38 | > .rect {
39 | @media (--viewport-mobile) {
40 | margin: 0 auto;
41 | }
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/Page/Page.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as bem from 'b_';
3 |
4 | import './Page.css';
5 |
6 | type Props = {
7 | children: React.ReactNode[] | React.ReactNode;
8 | align: string;
9 | mix?: string;
10 | };
11 |
12 | const b = bem.with('page');
13 |
14 | function Page(props: Props) {
15 | const { align, children, mix } = props;
16 | const cls = `${b({ align })} ${mix}`;
17 |
18 | return {children}
;
19 | }
20 |
21 | Page.defaultProps = {
22 | align: 'center',
23 | mix: '',
24 | };
25 |
26 | export { Page };
27 |
--------------------------------------------------------------------------------
/src/components/PageContainer/PageContainer.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as bem from 'b_';
3 | import { Link } from 'react-router-dom';
4 | import { Button, Page } from '..';
5 |
6 | type Props = {
7 | children: React.ReactNode[] | React.ReactNode;
8 | btn?: {
9 | to: string;
10 | text: string;
11 | };
12 | mix?: string;
13 | };
14 |
15 | const b = bem.with('page');
16 |
17 | function PageContainer(props: Props) {
18 | const { children, btn, mix } = props;
19 | const cls = `${b('container')} ${mix}`;
20 |
21 | return (
22 |
23 | {children}
24 | {btn && (
25 |
26 |
27 |
28 |
29 |
30 | )}
31 |
32 | );
33 | }
34 |
35 | PageContainer.defaultProps = {
36 | mix: '',
37 | };
38 |
39 | export { PageContainer };
40 |
--------------------------------------------------------------------------------
/src/components/PageMeta/PageMeta.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Helmet from 'react-helmet';
3 |
4 | type Props = {
5 | title?: string;
6 | description?: string;
7 | image?: string;
8 | };
9 |
10 | const cutTags = (text: string = '') => {
11 | return text.replace(/<\/?.+?>/gi, '');
12 | };
13 |
14 | const prepareData = ({ title, description, image }: Props) => {
15 | return {
16 | title: cutTags(title),
17 | description: cutTags(description).substr(0, 250),
18 | image,
19 | };
20 | };
21 |
22 | function PageMeta(props: Props) {
23 | const { title, description, image } = prepareData(props);
24 |
25 | return (
26 |
27 | {title}
28 |
29 |
30 | {Boolean(description) && (
31 |
32 | )}
33 | {Boolean(description) && (
34 |
35 | )}
36 | {Boolean(description) && (
37 |
38 | )}
39 | {Boolean(image) && }
40 |
41 | );
42 | }
43 |
44 | PageMeta.defaultProps = {
45 | title: 'Site',
46 | description: null,
47 | image: null,
48 | };
49 |
50 | export { PageMeta };
51 |
--------------------------------------------------------------------------------
/src/components/Rect/Rect.css:
--------------------------------------------------------------------------------
1 | .rect
2 | {
3 | &_type
4 | {
5 | &_default
6 | {
7 | background: #e6e6e6;
8 | }
9 |
10 | &_black
11 | {
12 | background: #aaa;
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/Rect/Rect.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as bem from 'b_';
3 |
4 | import './Rect.css';
5 |
6 | type Props = {
7 | type?: string;
8 | width?: string;
9 | height?: string;
10 | className?: string;
11 | };
12 |
13 | const b = bem.with('rect');
14 |
15 | function Rect({ width, height, type, className }: Props) {
16 | const classes = className ? `${b({ type })} ${className}` : b({ type });
17 |
18 | return ;
19 | }
20 |
21 | Rect.defaultProps = {
22 | type: 'default',
23 | width: null,
24 | height: null,
25 | className: null,
26 | };
27 |
28 | export { Rect };
29 |
--------------------------------------------------------------------------------
/src/components/Sneakers/Sneakers.css:
--------------------------------------------------------------------------------
1 | @import '~styles/media.css';
2 |
3 | .sneakers a {
4 | color: inherit;
5 | text-decoration: none;
6 | }
7 |
8 | .sneakers {
9 | text-align: left;
10 | box-sizing: border-box;
11 | padding: 10px 0;
12 | margin: 10px;
13 | position: relative;
14 | width: 220px;
15 | height: 280px;
16 | display: inline-block;
17 |
18 | @media (--viewport-mobile) {
19 | margin: 5px auto;
20 | }
21 | }
22 |
23 | .sneakers__image {
24 | height: 210px;
25 | width: 220px;
26 | margin-bottom: 10px;
27 | }
28 |
29 | .sneakers__title {
30 | font-weight: bold;
31 | font-size: 18px;
32 | }
33 |
34 | .sneakers__category {
35 | font-size: 14px;
36 | padding-top: 5px;
37 | color: #999;
38 | }
39 |
40 | .sneakers-stub {
41 | padding: 0;
42 | margin: 10px 15px 0;
43 | display: inline-block;
44 | }
45 |
46 | .sneakers-stub .rect {
47 | margin-bottom: 10px;
48 |
49 | @media (--viewport-mobile) {
50 | margin: 10px auto;
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/components/Sneakers/Sneakers.stub.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Rect } from 'components';
3 | import './Sneakers.css';
4 |
5 | export function SneakersStub() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/src/components/Sneakers/Sneakers.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as bem from 'b_';
3 | import { Link } from 'react-router-dom';
4 |
5 | import './Sneakers.css';
6 |
7 | const b = bem.with('sneakers');
8 |
9 | type Props = {
10 | title: string;
11 | image: string;
12 | subtitle: string;
13 | price: string;
14 | url: string;
15 | };
16 |
17 | export function Sneakers(props: Props) {
18 | const { title, image, subtitle, price, url } = props;
19 |
20 | return (
21 |
22 |
23 |
27 |
28 |
29 | {title}
30 |
31 |
32 | {subtitle}
33 | ,
34 | {price}
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/components/SneakersList/SneakersList.css:
--------------------------------------------------------------------------------
1 | @import '~styles/media.css';
2 |
3 | .sneakers-list-stub {
4 | padding-top: 8px;
5 | }
6 |
7 | .sneakers-list {
8 | display: flex;
9 | flex-wrap: wrap;
10 | justify-content: space-between;
11 |
12 | @media (--viewport-mobile) {
13 | justify-content: space-around;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/SneakersList/SneakersList.stub.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { SneakersStub } from 'components';
3 | import './SneakersList.css';
4 |
5 | type Props = {
6 | count: number;
7 | };
8 |
9 | export function SneakersListStub(props: Props) {
10 | return (
11 |
12 | {Array(props.count)
13 | .fill(0)
14 | .map((_, idx: number) => (
15 |
16 | ))}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/SneakersList/SneakersList.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import * as bem from 'b_';
3 | import { Sneakers } from '../Sneakers/Sneakers';
4 | import { Sneakers as SneakersType } from '../../types';
5 |
6 | import './SneakersList.css';
7 |
8 | const b = bem.with('sneakers-list');
9 |
10 | type Props = {
11 | items: SneakersType[];
12 | };
13 |
14 | export function SneakersList(props: Props) {
15 | const { items } = props;
16 |
17 | return (
18 |
19 | {items.map(sneakers => (
20 |
21 | ))}
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export { App } from './App/App';
2 |
3 | export { Button, ButtonSizes } from './Button/Button';
4 | export { Header } from './Header/Header';
5 | export { Footer } from './Footer/Footer';
6 | export { Rect } from './Rect/Rect';
7 | export { Page } from './Page/Page';
8 | export { PageContainer } from './PageContainer/PageContainer';
9 | export { PageMeta } from './PageMeta/PageMeta';
10 |
11 | export { Sneakers } from './Sneakers/Sneakers';
12 | export { SneakersStub } from './Sneakers/Sneakers.stub';
13 | export { SneakersList } from './SneakersList/SneakersList';
14 | export { SneakersListStub } from './SneakersList/SneakersList.stub';
15 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Sneakers shop
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/pages/404/404.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Route, RouteComponentProps } from 'react-router-dom';
3 | import { Page, PageContainer } from 'components';
4 |
5 | type Props = {
6 | code: number;
7 | children: React.ReactNode;
8 | };
9 |
10 | // Component is used for passing http status for server side rendering;
11 | // For example, if page isn't found, client give page with 404 status code
12 | const Status = ({ code, children }: Props) => {
13 | const render = ({ staticContext }: RouteComponentProps) => {
14 | if (staticContext) {
15 | staticContext.statusCode = code;
16 | }
17 |
18 | return children;
19 | };
20 |
21 | return ;
22 | };
23 |
24 | export default function NotFoundPage() {
25 | return (
26 |
27 |
28 |
29 | Page not found
30 |
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/pages/Catalog/Catalog.stub.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Page, PageContainer, Rect, SneakersListStub } from 'components';
3 |
4 | export function CatalogStub() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/pages/Catalog/Catalog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { connect } from 'react-redux';
3 | import { State, Sneakers } from 'types';
4 | import { fetchCatalog } from 'store/ducks/catalog/actions';
5 | import { getCatalog, isLoading } from 'store/ducks/catalog/selectors';
6 | import { SneakersList, PageMeta, PageContainer, Page } from 'components';
7 | import { CatalogStub } from './Catalog.stub';
8 |
9 | type Props = {
10 | data: Sneakers[];
11 | fetchCatalog: () => void;
12 | isLoading: boolean;
13 | };
14 |
15 | function Catalog(props: Props) {
16 | const { isLoading, data, fetchCatalog } = props;
17 |
18 | React.useEffect(() => {
19 | if (!data.length) {
20 | fetchCatalog();
21 | }
22 | }, []);
23 |
24 | if (isLoading || !data.length) {
25 | return ;
26 | }
27 |
28 | return (
29 |
30 |
34 |
35 | Catalog
36 |
37 |
38 |
39 | );
40 | }
41 |
42 | const mapStateToProps = (state: State) => ({
43 | data: getCatalog(state),
44 | isLoading: isLoading(state),
45 | });
46 | const mapDispatchToProps = { fetchCatalog };
47 |
48 | export default connect(
49 | mapStateToProps,
50 | mapDispatchToProps
51 | )(Catalog) as React.ComponentType;
52 |
--------------------------------------------------------------------------------
/src/pages/Home/Home.stub.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Page, PageContainer, Rect, SneakersListStub } from 'components';
3 |
4 | export function HomeStub() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/src/pages/Home/Home.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { connect } from 'react-redux';
3 | import * as bem from 'b_';
4 | import { State, Sneakers as SneakersType } from 'types';
5 | import { PageMeta, SneakersList, Page, PageContainer } from 'components';
6 | import { fetchHomepage } from 'store/ducks/homepage/actions';
7 | import { getHomepage, isLoading } from 'store/ducks/homepage/selectors';
8 | import { HomeStub } from './Home.stub';
9 |
10 | type Props = {
11 | data: {
12 | popular: SneakersType[];
13 | newest: SneakersType[];
14 | };
15 | fetchHomepage: () => void;
16 | isLoading: boolean;
17 | };
18 |
19 | const b = bem.with('page');
20 |
21 | function Home(props: Props) {
22 | const { isLoading, data, fetchHomepage } = props;
23 |
24 | React.useEffect(() => {
25 | if (!data.popular.length) {
26 | fetchHomepage();
27 | }
28 | }, []);
29 |
30 | if (isLoading) {
31 | return ;
32 | }
33 |
34 | return (
35 |
36 |
37 |
38 | Popular
39 |
40 |
41 |
42 | Newest
43 |
44 |
45 |
46 | );
47 | }
48 |
49 | const mapStateToProps = (state: State) => ({
50 | data: getHomepage(state),
51 | isLoading: isLoading(state),
52 | });
53 | const mapDispatchToProps = { fetchHomepage };
54 |
55 | export default connect(
56 | mapStateToProps,
57 | mapDispatchToProps
58 | )(Home) as React.ComponentType;
59 |
--------------------------------------------------------------------------------
/src/pages/Sneakers/Sneakers.css:
--------------------------------------------------------------------------------
1 | .sneakers-page__slider {
2 | height: 230px;
3 | }
4 |
5 | .sneakers-page__category {
6 | margin-bottom: -15px;
7 | color: #777;
8 | font-weight: 500;
9 | }
10 |
11 | .sneakers-page__image {
12 | width: 230px;
13 | height: 230px;
14 | margin: 30px 15px;
15 | }
16 |
17 | .sneakers-page__description {
18 | color: #777;
19 | padding: 40px 0;
20 | }
21 |
22 | .sneakers-page .slick-prev:before, .sneakers-page .slick-next:before {
23 | color: #333 !important;
24 | }
25 |
26 | .sneakers-page-stub {
27 | display: flex;
28 | flex-direction: column;
29 | align-items: center;
30 | }
31 |
32 | .sneakers-page-stub > .rect {
33 | margin-bottom: 10px;
34 | }
35 |
--------------------------------------------------------------------------------
/src/pages/Sneakers/Sneakers.hook.ts:
--------------------------------------------------------------------------------
1 | import { useRouteMatch } from 'react-router';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import * as React from 'react';
4 | import {
5 | getShoes,
6 | isLoading as isLoadingSelector,
7 | } from 'store/ducks/shoes/selectors';
8 | import { getHomepage } from 'store/ducks/homepage/selectors';
9 | import { fetchShoes as fetchShoesActionCreator } from 'store/ducks/shoes/actions';
10 | import { fetchHomepage as fetchHomepageActionCreator } from 'store/ducks/homepage/actions';
11 |
12 | export function useSneakers() {
13 | const match = useRouteMatch<{ slug: string }>();
14 |
15 | const { popular } = useSelector(getHomepage);
16 | const isLoading = useSelector(isLoadingSelector);
17 | const data = useSelector(getShoes);
18 | const dispatch = useDispatch();
19 | const fetchShoes = React.useCallback(
20 | (slug: string) => dispatch(fetchShoesActionCreator(slug)),
21 | [dispatch]
22 | );
23 | const fetchHomepage = React.useCallback(
24 | () => dispatch(fetchHomepageActionCreator()),
25 | [dispatch]
26 | );
27 |
28 | return {
29 | match,
30 | popular,
31 | isLoading,
32 | data,
33 | fetchShoes,
34 | fetchHomepage,
35 | };
36 | }
37 |
--------------------------------------------------------------------------------
/src/pages/Sneakers/Sneakers.stub.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Rect, Page, PageContainer } from 'components';
3 |
4 | export function SneakersStub() {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/pages/Sneakers/Sneakers.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Slider from 'react-slick';
3 | import * as bem from 'b_';
4 | import {
5 | Page,
6 | Button,
7 | ButtonSizes,
8 | PageMeta,
9 | SneakersList,
10 | PageContainer,
11 | } from 'components';
12 | import { SneakersStub } from './Sneakers.stub';
13 | import { useSneakers } from './Sneakers.hook';
14 |
15 | import 'slick-carousel/slick/slick.css';
16 | import 'slick-carousel/slick/slick-theme.css';
17 | import './Sneakers.css';
18 |
19 | const b = bem.with('sneakers-page');
20 |
21 | const settings = {
22 | dots: false,
23 | infinite: true,
24 | speed: 500,
25 | slidesToShow: 3,
26 | slidesToScroll: 1,
27 | arrows: true,
28 | className: 'sneakers-page__slider',
29 | responsive: [
30 | {
31 | breakpoint: 480,
32 | settings: {
33 | slidesToShow: 1,
34 | slidesToScroll: 1,
35 | dots: true,
36 | arrows: false,
37 | },
38 | },
39 | ],
40 | };
41 |
42 | function SneakersPage() {
43 | const {
44 | match,
45 | popular,
46 | isLoading,
47 | data,
48 | fetchShoes,
49 | fetchHomepage,
50 | } = useSneakers();
51 |
52 | React.useEffect(() => {
53 | if (!data || data.slug !== match.params.slug) {
54 | fetchShoes(match.params.slug);
55 | }
56 | }, [match]);
57 |
58 | React.useEffect(() => {
59 | if (!popular.length) {
60 | fetchHomepage();
61 | }
62 | }, []);
63 |
64 | if (isLoading || !data) {
65 | return ;
66 | }
67 |
68 | const { title, category, price, images, description } = data;
69 |
70 | return (
71 |
72 |
77 |
78 |
79 | {category}
80 | {title}
81 | {price}
82 |
83 | {images.map(url => (
84 |
85 |

86 |
87 | ))}
88 |
89 | {description}
90 |
91 |
92 | {Boolean(popular.length) && (
93 |
94 | See also
95 |
96 |
97 | )}
98 |
99 | );
100 | }
101 |
102 | export default SneakersPage;
103 |
--------------------------------------------------------------------------------
/src/pages/Upcoming/Upcoming.css:
--------------------------------------------------------------------------------
1 | .upcoming-banner {
2 | background-image: url('./nike-basketball-roster.jpg');
3 | width: 100%;
4 | height: 500px;
5 | background-size: cover;
6 | }
7 |
--------------------------------------------------------------------------------
/src/pages/Upcoming/Upcoming.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { Page, PageContainer, PageMeta } from 'components';
3 | import './Upcoming.css';
4 |
5 | export default function Upcoming() {
6 | return (
7 |
8 |
9 |
10 | Upcoming...
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/pages/Upcoming/nike-basketball-roster.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/src/pages/Upcoming/nike-basketball-roster.jpg
--------------------------------------------------------------------------------
/src/routes.ts:
--------------------------------------------------------------------------------
1 | import loadable from '@loadable/component';
2 | import { fetchCatalog } from 'store/ducks/catalog/actions';
3 | import { fetchHomepage } from 'store/ducks/homepage/actions';
4 | import { fetchShoes } from 'store/ducks/shoes/actions';
5 | import { RouterFetchDataArgs } from 'types';
6 |
7 | const CatalogPage = loadable(() => import('./pages/Catalog/Catalog'));
8 | const UpcomingPage = loadable(() => import('./pages/Upcoming/Upcoming'));
9 | const SneakersPage = loadable(() => import('./pages/Sneakers/Sneakers'));
10 | const HomePage = loadable(() => import('./pages/Home/Home'));
11 | const NotFoundPage = loadable(() => import('./pages/404/404'));
12 |
13 | /**
14 | * Routes are moved to a separate file,
15 | * so that you can use the asyncFetchData method on the component on the server (by url path)
16 | * which load all the necessary data for rendering the page.
17 | */
18 | export default [
19 | {
20 | path: '/',
21 | component: HomePage,
22 | exact: true,
23 | fetchData({ dispatch }: RouterFetchDataArgs) {
24 | dispatch(fetchHomepage());
25 | },
26 | },
27 | {
28 | path: '/catalog',
29 | component: CatalogPage,
30 | exact: true,
31 | fetchData({ dispatch }: RouterFetchDataArgs) {
32 | dispatch(fetchCatalog());
33 | },
34 | },
35 | {
36 | path: '/sneakers/:slug',
37 | component: SneakersPage,
38 | exact: true,
39 | fetchData({ dispatch, match }: RouterFetchDataArgs) {
40 | dispatch(fetchShoes(match.params.slug));
41 | dispatch(fetchHomepage());
42 | },
43 | },
44 | {
45 | path: '/upcoming',
46 | component: UpcomingPage,
47 | exact: true,
48 | },
49 | {
50 | path: '*',
51 | component: NotFoundPage,
52 | exact: true,
53 | },
54 | ];
55 |
--------------------------------------------------------------------------------
/src/server-render-middleware.tsx:
--------------------------------------------------------------------------------
1 | import url from 'url';
2 | import path from 'path';
3 | import React from 'react';
4 | import { renderToString } from 'react-dom/server';
5 | import { Request, Response } from 'express';
6 | import { StaticRouter, matchPath } from 'react-router-dom';
7 | import { StaticRouterContext } from 'react-router';
8 | import { Provider as ReduxProvider } from 'react-redux';
9 | import Helmet, { HelmetData } from 'react-helmet';
10 | import { ChunkExtractor } from '@loadable/server';
11 | import { App } from './components/App/App';
12 | import { configureStore } from './store/rootStore';
13 | import rootSaga from './store/rootSaga';
14 | import { getInitialState } from './store/getInitialState';
15 | import routes from './routes';
16 |
17 | export default (req: Request, res: Response) => {
18 | const location = req.url;
19 | const context: StaticRouterContext = {};
20 | const { store } = configureStore(getInitialState(location), location);
21 |
22 | function renderApp() {
23 | const statsFile = path.resolve('./dist/loadable-stats.json');
24 | const chunkExtractor = new ChunkExtractor({ statsFile });
25 |
26 | const jsx = chunkExtractor.collectChunks(
27 |
28 |
29 |
30 |
31 |
32 | );
33 | const reactHtml = renderToString(jsx);
34 | const reduxState = store.getState();
35 | const helmetData = Helmet.renderStatic();
36 |
37 | if (context.url) {
38 | res.redirect(context.url);
39 | return;
40 | }
41 |
42 | res.status(context.statusCode || 200).send(
43 | getHtml(reactHtml, reduxState, helmetData, chunkExtractor)
44 | );
45 | }
46 |
47 | store
48 | .runSaga(rootSaga)
49 | .toPromise()
50 | .then(() => renderApp())
51 | .catch(err => {
52 | throw err;
53 | });
54 |
55 | const dataRequirements: (Promise | void)[] = [];
56 |
57 | /**
58 | * Call the fetchData method on the component-page
59 | * that corresponds to the current url (by router).
60 | *
61 | * We use `some` method to simulate working of the routes in react-router-dom
62 | * inside the Switch — selects the first found route.
63 | */
64 | routes.some(route => {
65 | const { fetchData: fetchMethod } = route;
66 | const match = matchPath<{ slug: string }>(
67 | url.parse(location).pathname,
68 | route
69 | );
70 |
71 | if (match && fetchMethod) {
72 | dataRequirements.push(
73 | fetchMethod({
74 | dispatch: store.dispatch,
75 | match,
76 | })
77 | );
78 | }
79 |
80 | return Boolean(match);
81 | });
82 |
83 | // When all async actions will be finished,
84 | // dispatch action END to close saga
85 | return Promise.all(dataRequirements)
86 | .then(() => store.close())
87 | .catch(err => {
88 | throw err;
89 | });
90 | };
91 |
92 | function getHtml(
93 | reactHtml: string,
94 | reduxState = {},
95 | helmetData: HelmetData,
96 | chunkExtractor: ChunkExtractor
97 | ) {
98 | const scriptTags = chunkExtractor.getScriptTags();
99 | const linkTags = chunkExtractor.getLinkTags();
100 | const styleTags = chunkExtractor.getStyleTags();
101 |
102 | return `
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 | ${helmetData.title.toString()}
112 | ${helmetData.meta.toString()}
113 | ${linkTags}
114 | ${styleTags}
115 |
116 |
117 | ${reactHtml}
118 |
121 | ${scriptTags}
122 |
123 |
124 | `;
125 | }
126 |
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import express from 'express';
3 | import compression from 'compression';
4 | import 'babel-polyfill';
5 | import serverRenderMiddleware from './server-render-middleware';
6 |
7 | const app = express();
8 |
9 | // I recommend use it only for development
10 | // In production env you can use Nginx or CDN
11 | app.use(compression())
12 | .use(express.static(path.resolve(__dirname, '../dist')))
13 | .use(express.static(path.resolve(__dirname, '../static')));
14 |
15 | app.get('/*', serverRenderMiddleware);
16 |
17 | export { app };
18 |
--------------------------------------------------------------------------------
/src/store/ducks/catalog/actions.ts:
--------------------------------------------------------------------------------
1 | import { Sneakers } from 'types';
2 | import * as types from './types';
3 |
4 | // action creators
5 | export function fetchCatalog(): types.FetchCatalogAction {
6 | return { type: types.FETCH_CATALOG };
7 | }
8 |
9 | export function fetchCatalogSuccess(
10 | data: Sneakers[]
11 | ): types.FetchCatalogSuccessAction {
12 | return { type: types.FETCH_CATALOG_SUCCESS, payload: data };
13 | }
14 |
15 | export function fetchCatalogError(
16 | error: string
17 | ): types.FetchCatalogFailureAction {
18 | return { type: types.FETCH_CATALOG_FAILURE, payload: error };
19 | }
20 |
--------------------------------------------------------------------------------
/src/store/ducks/catalog/reducer.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CatalogActionTypes,
3 | FETCH_CATALOG,
4 | FETCH_CATALOG_FAILURE,
5 | FETCH_CATALOG_SUCCESS,
6 | } from './types';
7 | import { Sneakers } from 'types';
8 | import produce, { Draft } from 'immer';
9 |
10 | export interface CatalogState {
11 | readonly data: Sneakers[];
12 | readonly isLoading: boolean;
13 | readonly error?: string;
14 | }
15 |
16 | export const initialState: CatalogState = {
17 | data: [],
18 | isLoading: false,
19 | error: undefined,
20 | };
21 |
22 | export default produce(
23 | (draft: Draft = initialState, action: CatalogActionTypes) => {
24 | switch (action.type) {
25 | case FETCH_CATALOG:
26 | draft.isLoading = true;
27 | draft.error = undefined;
28 | return;
29 | case FETCH_CATALOG_SUCCESS:
30 | draft.data = action.payload;
31 | draft.isLoading = false;
32 | draft.error = undefined;
33 | return;
34 | case FETCH_CATALOG_FAILURE:
35 | draft.data = initialState.data;
36 | draft.isLoading = false;
37 | draft.error = action.payload;
38 | return;
39 | }
40 | return draft;
41 | }
42 | );
43 |
--------------------------------------------------------------------------------
/src/store/ducks/catalog/saga.ts:
--------------------------------------------------------------------------------
1 | import { put, takeLatest, call } from 'redux-saga/effects';
2 |
3 | import * as actions from './actions';
4 | import * as types from './types';
5 | import * as service from './service';
6 |
7 | function* fetchCatalog() {
8 | try {
9 | const data = yield call(service.fetchCatalog);
10 |
11 | yield put(actions.fetchCatalogSuccess(data));
12 | } catch (error) {
13 | yield put(actions.fetchCatalogError(error.message));
14 | }
15 | }
16 |
17 | export function* catalogSaga() {
18 | yield takeLatest(types.FETCH_CATALOG, fetchCatalog);
19 | }
20 |
--------------------------------------------------------------------------------
/src/store/ducks/catalog/selectors.ts:
--------------------------------------------------------------------------------
1 | import { State } from 'types';
2 |
3 | export function getCatalog(state: State) {
4 | return state.catalog.data;
5 | }
6 |
7 | export function isLoading(state: State) {
8 | return state.catalog.isLoading;
9 | }
10 |
--------------------------------------------------------------------------------
/src/store/ducks/catalog/service.ts:
--------------------------------------------------------------------------------
1 | import { serializer as shoesSerializer } from '../shoes/service';
2 | import { timeout } from '../timeoutHelper';
3 |
4 | // Emulate api request
5 | export const fetchCatalog = () =>
6 | timeout(500)
7 | .then(() => import('./mock.json'))
8 | .then(loaded =>
9 | loaded.sections[0].items.slice(30).map(shoesSerializer)
10 | );
11 |
--------------------------------------------------------------------------------
/src/store/ducks/catalog/types.ts:
--------------------------------------------------------------------------------
1 | import { Sneakers, ReduxAction } from 'types';
2 |
3 | // types
4 | export const FETCH_CATALOG = '@@catalog/FETCH_CATALOG';
5 | export const FETCH_CATALOG_SUCCESS = '@@catalog/FETCH_CATALOG_SUCCESS';
6 | export const FETCH_CATALOG_FAILURE = '@@catalog/FETCH_CATALOG_FAILURE';
7 |
8 | // action types
9 | export type FetchCatalogAction = ReduxAction;
10 | export type FetchCatalogSuccessAction = ReduxAction<
11 | typeof FETCH_CATALOG_SUCCESS,
12 | Sneakers[]
13 | >;
14 | export type FetchCatalogFailureAction = ReduxAction<
15 | typeof FETCH_CATALOG_FAILURE,
16 | string
17 | >;
18 |
19 | export type CatalogActionTypes =
20 | | FetchCatalogAction
21 | | FetchCatalogSuccessAction
22 | | FetchCatalogFailureAction;
23 |
--------------------------------------------------------------------------------
/src/store/ducks/homepage/actions.ts:
--------------------------------------------------------------------------------
1 | import { Sneakers } from 'types';
2 | import * as types from './types';
3 |
4 | // action creators
5 | export function fetchHomepage(): types.FetchHomepageAction {
6 | return { type: types.FETCH_HOMEPAGE };
7 | }
8 |
9 | export function fetchHomepageSuccess(data: {
10 | popular: Sneakers[];
11 | newest: Sneakers[];
12 | }): types.FetchHomepageSuccessAction {
13 | return { type: types.FETCH_HOMEPAGE_SUCCESS, payload: data };
14 | }
15 |
16 | export function fetchHomepageError(
17 | error: string
18 | ): types.FetchHomepageFailureAction {
19 | return { type: types.FETCH_HOMEPAGE_FAILURE, payload: error };
20 | }
21 |
--------------------------------------------------------------------------------
/src/store/ducks/homepage/reducer.ts:
--------------------------------------------------------------------------------
1 | import {
2 | HomepageActionTypes,
3 | FETCH_HOMEPAGE,
4 | FETCH_HOMEPAGE_FAILURE,
5 | FETCH_HOMEPAGE_SUCCESS,
6 | } from './types';
7 | import { Sneakers } from 'types';
8 | import produce, { Draft } from 'immer';
9 |
10 | export interface HomepageState {
11 | readonly data: {
12 | popular: Sneakers[];
13 | newest: Sneakers[];
14 | };
15 | readonly isLoading: boolean;
16 | readonly error?: string;
17 | }
18 |
19 | export const initialState: HomepageState = {
20 | data: {
21 | popular: [],
22 | newest: [],
23 | },
24 | isLoading: false,
25 | error: undefined,
26 | };
27 |
28 | export default produce(
29 | (
30 | draft: Draft = initialState,
31 | action: HomepageActionTypes
32 | ) => {
33 | switch (action.type) {
34 | case FETCH_HOMEPAGE:
35 | draft.isLoading = true;
36 | draft.error = undefined;
37 | return;
38 | case FETCH_HOMEPAGE_SUCCESS:
39 | draft.data = action.payload;
40 | draft.isLoading = false;
41 | draft.error = undefined;
42 | return;
43 | case FETCH_HOMEPAGE_FAILURE:
44 | draft.data = initialState.data;
45 | draft.isLoading = false;
46 | draft.error = action.payload;
47 | return;
48 | }
49 | return draft;
50 | }
51 | );
52 |
--------------------------------------------------------------------------------
/src/store/ducks/homepage/saga.ts:
--------------------------------------------------------------------------------
1 | import { put, takeLatest, call } from 'redux-saga/effects';
2 |
3 | import * as actions from './actions';
4 | import * as types from './types';
5 | import * as service from './service';
6 |
7 | function* fetchHomepage() {
8 | try {
9 | const data = yield call(service.fetchHomepage);
10 |
11 | yield put(actions.fetchHomepageSuccess(data));
12 | } catch (error) {
13 | yield put(actions.fetchHomepageError(error.message));
14 | }
15 | }
16 |
17 | export function* homepageSaga() {
18 | yield takeLatest(types.FETCH_HOMEPAGE, fetchHomepage);
19 | }
20 |
--------------------------------------------------------------------------------
/src/store/ducks/homepage/selectors.ts:
--------------------------------------------------------------------------------
1 | import { State } from 'types';
2 |
3 | export function getHomepage(state: State) {
4 | return state.homepage.data;
5 | }
6 |
7 | export function isLoading(state: State) {
8 | return state.homepage.isLoading;
9 | }
10 |
--------------------------------------------------------------------------------
/src/store/ducks/homepage/service.ts:
--------------------------------------------------------------------------------
1 | import { serializer as shoesSerializer } from '../shoes/service';
2 | import { timeout } from '../timeoutHelper';
3 |
4 | // Emulate api request
5 | export const fetchHomepage = () =>
6 | timeout(500)
7 | .then(() => import('./mock.json'))
8 | .then(loaded => ({
9 | newest: loaded.sections[0].items.slice(0, 6).map(shoesSerializer),
10 | popular: loaded.sections[0].items.slice(6, 9).map(shoesSerializer),
11 | }));
12 |
--------------------------------------------------------------------------------
/src/store/ducks/homepage/types.ts:
--------------------------------------------------------------------------------
1 | import { Sneakers, ReduxAction } from 'types';
2 |
3 | // types
4 | export const FETCH_HOMEPAGE = '@@homepage/FETCH_HOMEPAGE';
5 | export const FETCH_HOMEPAGE_SUCCESS = '@@homepage/FETCH_HOMEPAGE_SUCCESS';
6 | export const FETCH_HOMEPAGE_FAILURE = '@@homepage/FETCH_HOMEPAGE_FAILURE';
7 |
8 | // action types
9 | export type FetchHomepageAction = ReduxAction;
10 | export type FetchHomepageSuccessAction = ReduxAction<
11 | typeof FETCH_HOMEPAGE_SUCCESS,
12 | { popular: Sneakers[]; newest: Sneakers[] }
13 | >;
14 | export type FetchHomepageFailureAction = ReduxAction<
15 | typeof FETCH_HOMEPAGE_FAILURE,
16 | string
17 | >;
18 |
19 | export type HomepageActionTypes =
20 | | FetchHomepageAction
21 | | FetchHomepageSuccessAction
22 | | FetchHomepageFailureAction;
23 |
--------------------------------------------------------------------------------
/src/store/ducks/router/saga.ts:
--------------------------------------------------------------------------------
1 | import { takeEvery, call } from 'redux-saga/effects';
2 | import * as types from './types';
3 |
4 | export function* changeLocation() {
5 | // After changing page, scroll to top
6 | yield call(window.scrollTo.bind(window), 0, 0);
7 | }
8 |
9 | export function* routerSaga() {
10 | yield takeEvery(types.LOCATION_CHANGE, changeLocation);
11 | }
12 |
--------------------------------------------------------------------------------
/src/store/ducks/router/selectors.ts:
--------------------------------------------------------------------------------
1 | import { State } from 'types';
2 |
3 | export function getCurrentPathname(state: State) {
4 | return state.router.location.pathname;
5 | }
6 |
--------------------------------------------------------------------------------
/src/store/ducks/router/types.ts:
--------------------------------------------------------------------------------
1 | export const LOCATION_CHANGE = '@@router/LOCATION_CHANGE';
2 |
--------------------------------------------------------------------------------
/src/store/ducks/shoes/actions.ts:
--------------------------------------------------------------------------------
1 | import { Sneakers } from 'types';
2 | import * as types from './types';
3 |
4 | // action creators
5 | export function fetchShoes(slug: string): types.FetchShoesAction {
6 | return { type: types.FETCH_SHOES, payload: slug };
7 | }
8 |
9 | export function fetchShoesSuccess(
10 | data: Sneakers
11 | ): types.FetchShoesSuccessAction {
12 | return { type: types.FETCH_SHOES_SUCCESS, payload: data };
13 | }
14 |
15 | export function fetchShoesError(error: string): types.FetchShoesFailureAction {
16 | return { type: types.FETCH_SHOES_FAILURE, payload: error };
17 | }
18 |
--------------------------------------------------------------------------------
/src/store/ducks/shoes/mock.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "Nike Air Max 97 On Air Jasmine Lasode",
3 | "slug": "AO2582-004",
4 | "localPrice": "$200",
5 | "images": [
6 | "/images/ux4wrrodlumtnefucnq8.jpg",
7 | "/images/tqbhkhohrwonshwh7srl.jpg",
8 | "/images/o8pc93ifccwo1qihdpqp.jpg",
9 | "/images/pljue2bfqbmq6ruw0ht1.jpg",
10 | "/images/ul2yvanoaw2ntfhslqd2.jpg",
11 | "/images/bs2mikksg5tqynlbq3sl.jpg"
12 | ],
13 | "description": "Although the Air Jordan VI brought MJ his first of many championships, it also marked the end of Nike branding and visible Air windows on a Jordan silhouette. Taking inspiration from luxury sports cars with the heel tab mimicking a rear spoiler, this latest colorway features a dynamic pattern on the mesh overlays while the rubber outsole provides traction from the classroom to the playground.",
14 | "subtitle": "BIG KIDS\" AIR JORDAN VI",
15 | "spriteSheet": "https://images.nike.com/is/image/DotCom/pwp_sheet2?$NIKE_PWPx3$&$img0=CJ1436_100",
16 | "pdpUrl": "https://www.nike.com/launch/r/CJ1436-100"
17 | }
18 |
--------------------------------------------------------------------------------
/src/store/ducks/shoes/reducer.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ShoesActionTypes,
3 | FETCH_SHOES,
4 | FETCH_SHOES_FAILURE,
5 | FETCH_SHOES_SUCCESS,
6 | } from './types';
7 | import { Sneakers } from 'types';
8 | import produce, { Draft } from 'immer';
9 |
10 | export interface ShoesState {
11 | readonly data?: Sneakers;
12 | readonly isLoading: boolean;
13 | readonly error?: string;
14 | }
15 |
16 | export const initialState: ShoesState = {
17 | data: undefined,
18 | isLoading: false,
19 | error: undefined,
20 | };
21 |
22 | export default produce(
23 | (draft: Draft = initialState, action: ShoesActionTypes) => {
24 | switch (action.type) {
25 | case FETCH_SHOES:
26 | draft.isLoading = true;
27 | draft.error = undefined;
28 | return;
29 | case FETCH_SHOES_SUCCESS:
30 | draft.data = action.payload;
31 | draft.isLoading = false;
32 | draft.error = undefined;
33 | return;
34 | case FETCH_SHOES_FAILURE:
35 | draft.data = initialState.data;
36 | draft.isLoading = false;
37 | draft.error = action.payload;
38 | return;
39 | }
40 | return draft;
41 | }
42 | );
43 |
--------------------------------------------------------------------------------
/src/store/ducks/shoes/saga.ts:
--------------------------------------------------------------------------------
1 | import { put, takeLatest, call } from 'redux-saga/effects';
2 |
3 | import * as actions from './actions';
4 | import * as types from './types';
5 | import * as service from './service';
6 |
7 | function* fetchShoes(action: types.FetchShoesAction) {
8 | try {
9 | const slug = action.payload;
10 | const data = yield call(service.fetchShoes, slug);
11 |
12 | yield put(actions.fetchShoesSuccess(data));
13 | } catch (error) {
14 | yield put(actions.fetchShoesError(error.message));
15 | }
16 | }
17 |
18 | export function* shoesSaga() {
19 | yield takeLatest(types.FETCH_SHOES, fetchShoes);
20 | }
21 |
--------------------------------------------------------------------------------
/src/store/ducks/shoes/selectors.ts:
--------------------------------------------------------------------------------
1 | import { State } from 'types';
2 |
3 | export function getShoes(state: State) {
4 | return state.shoes.data;
5 | }
6 |
7 | export function isLoading(state: State) {
8 | return state.shoes.isLoading;
9 | }
10 |
--------------------------------------------------------------------------------
/src/store/ducks/shoes/service.ts:
--------------------------------------------------------------------------------
1 | import { timeout } from '../timeoutHelper';
2 | // @ts-ignore
3 | import mock from './mock.json';
4 |
5 | export const serializer = (data: any) => {
6 | const [, image] = (data.spriteSheet || '').match(/img0=([^&]+)&?/);
7 | const slug = data.slug || (data.pdpUrl.split('/') || ['']).pop();
8 |
9 | return {
10 | slug,
11 | image: `/images/${image.replace('/', '_')}.jpg`,
12 | images: data.images,
13 | title: data.title,
14 | subtitle: data.subtitle,
15 | price: data.localPrice,
16 | description: data.description,
17 | url: `/sneakers/${slug}`,
18 | };
19 | };
20 |
21 | // Emulate api request
22 | export const fetchShoes = (slug: string) =>
23 | timeout(500).then(() => serializer({ ...mock, slug }));
24 |
--------------------------------------------------------------------------------
/src/store/ducks/shoes/types.ts:
--------------------------------------------------------------------------------
1 | import { Sneakers, ReduxAction } from 'types';
2 |
3 | // types
4 | export const FETCH_SHOES = '@@shoes/FETCH_SHOES';
5 | export const FETCH_SHOES_SUCCESS = '@@shoes/FETCH_SHOES_SUCCESS';
6 | export const FETCH_SHOES_FAILURE = '@@shoes/FETCH_SHOES_FAILURE';
7 |
8 | // action types
9 | export type FetchShoesAction = ReduxAction;
10 | export type FetchShoesSuccessAction = ReduxAction<
11 | typeof FETCH_SHOES_SUCCESS,
12 | Sneakers
13 | >;
14 | export type FetchShoesFailureAction = ReduxAction<
15 | typeof FETCH_SHOES_FAILURE,
16 | string
17 | >;
18 |
19 | export type ShoesActionTypes =
20 | | FetchShoesAction
21 | | FetchShoesSuccessAction
22 | | FetchShoesFailureAction;
23 |
--------------------------------------------------------------------------------
/src/store/ducks/timeoutHelper.ts:
--------------------------------------------------------------------------------
1 | export const timeout = (ms: number) =>
2 | new Promise(resolve => {
3 | setTimeout(() => {
4 | resolve();
5 | }, ms);
6 | });
7 |
--------------------------------------------------------------------------------
/src/store/getInitialState.ts:
--------------------------------------------------------------------------------
1 | import { RouterState } from 'connected-react-router';
2 | import { State } from 'types';
3 | import { initialState as homepage } from './ducks/homepage/reducer';
4 | import { initialState as catalog } from './ducks/catalog/reducer';
5 | import { initialState as shoes } from './ducks/shoes/reducer';
6 |
7 | export const getInitialState = (pathname: string = '/'): State => {
8 | return {
9 | homepage,
10 | catalog,
11 | shoes,
12 | router: {
13 | location: { pathname, search: '', hash: '', key: '' },
14 | action: 'POP',
15 | } as RouterState,
16 | };
17 | };
18 |
--------------------------------------------------------------------------------
/src/store/rootReducer.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import { connectRouter } from 'connected-react-router';
3 | import { History } from 'history';
4 |
5 | import homepage from './ducks/homepage/reducer';
6 | import catalog from './ducks/catalog/reducer';
7 | import shoes from './ducks/shoes/reducer';
8 | import { State } from 'types';
9 |
10 | export default (history: History) =>
11 | combineReducers({
12 | homepage,
13 | catalog,
14 | shoes,
15 | router: connectRouter(history),
16 | });
17 |
--------------------------------------------------------------------------------
/src/store/rootSaga.ts:
--------------------------------------------------------------------------------
1 | import { fork, all } from 'redux-saga/effects';
2 |
3 | import { catalogSaga } from './ducks/catalog/saga';
4 | import { shoesSaga } from './ducks/shoes/saga';
5 | import { homepageSaga } from './ducks/homepage/saga';
6 | import { routerSaga } from './ducks/router/saga';
7 |
8 | export default function* rootSaga() {
9 | yield all([
10 | fork(homepageSaga),
11 | fork(routerSaga),
12 | fork(catalogSaga),
13 | fork(shoesSaga),
14 | ]);
15 | }
16 |
--------------------------------------------------------------------------------
/src/store/rootStore.ts:
--------------------------------------------------------------------------------
1 | import { createStore, compose, applyMiddleware, Store } from 'redux';
2 | import createSagaMiddleware, { END, SagaMiddleware } from 'redux-saga';
3 | import { routerMiddleware } from 'connected-react-router';
4 | import { createBrowserHistory, createMemoryHistory } from 'history';
5 | import { AppStore, State } from 'types';
6 | import createRootReducer from './rootReducer';
7 | import rootSaga from './rootSaga';
8 |
9 | function getComposeEnhancers() {
10 | if (process.env.NODE_ENV !== 'production' && !isServer) {
11 | return window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
12 | }
13 |
14 | return compose;
15 | }
16 |
17 | export const isServer = !(
18 | typeof window !== 'undefined' &&
19 | window.document &&
20 | window.document.createElement
21 | );
22 |
23 | export function configureStore(initialState: State, url = '/') {
24 | const history = isServer
25 | ? createMemoryHistory({ initialEntries: [url] })
26 | : createBrowserHistory();
27 |
28 | const sagaMiddleware = createSagaMiddleware();
29 | const composeEnhancers = getComposeEnhancers();
30 | const middlewares = [routerMiddleware(history), sagaMiddleware];
31 |
32 | const store = createStore(
33 | createRootReducer(history),
34 | initialState,
35 | composeEnhancers(applyMiddleware(...middlewares))
36 | ) as AppStore;
37 |
38 | // Add methods to use in the server
39 | store.runSaga = sagaMiddleware.run;
40 | store.close = () => store.dispatch(END);
41 |
42 | if (!isServer) {
43 | sagaMiddleware.run(rootSaga);
44 | }
45 |
46 | return { store, history };
47 | }
48 |
--------------------------------------------------------------------------------
/src/styles/base.css:
--------------------------------------------------------------------------------
1 | html, body, #mount {
2 | padding: 0;
3 | margin: 0;
4 |
5 | display: flex;
6 | width: 100%;
7 | min-height: 100%;
8 | }
9 |
10 | html {
11 | overflow-y: scroll;
12 | }
13 |
14 | a {
15 | color: #777;
16 |
17 | &:hover {
18 | color: #ff5c28;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/styles/media.css:
--------------------------------------------------------------------------------
1 | @custom-media --viewport-mobile (max-width: 980px);
2 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch } from 'react';
2 | import { match } from 'react-router';
3 | import { State, AppStore, ReduxAction } from './redux';
4 | import { Sneakers } from './models';
5 |
6 | export type RouterFetchDataArgs = {
7 | dispatch: Dispatch;
8 | match: match<{ slug: string }>;
9 | };
10 |
11 | export { Sneakers, State, AppStore, ReduxAction };
12 |
--------------------------------------------------------------------------------
/src/types/models.ts:
--------------------------------------------------------------------------------
1 | // Models
2 | export type Sneakers = {
3 | id: string;
4 | slug: string;
5 | title: string;
6 | subtitle: string;
7 | category: string;
8 | description: string;
9 | image: string;
10 | images: string[];
11 | price: string;
12 | url: string;
13 | text: string;
14 | date?: string;
15 | };
16 |
--------------------------------------------------------------------------------
/src/types/redux.ts:
--------------------------------------------------------------------------------
1 | import { Action, Store } from 'redux';
2 | import { SagaMiddleware } from '@redux-saga/core';
3 | import { RouterState } from 'connected-react-router';
4 | import { HomepageState } from 'store/ducks/homepage/reducer';
5 | import { CatalogState } from 'store/ducks/catalog/reducer';
6 | import { ShoesState } from 'store/ducks/shoes/reducer';
7 |
8 | // Redux types
9 | export interface ReduxAction extends Action {
10 | type: T;
11 | payload?: P;
12 | }
13 |
14 | export type AppStore = Store & {
15 | runSaga: SagaMiddleware['run'];
16 | close: () => void;
17 | };
18 |
19 | export interface State {
20 | readonly homepage: HomepageState;
21 | readonly catalog: CatalogState;
22 | readonly shoes: ShoesState;
23 | readonly router: RouterState;
24 | }
25 |
--------------------------------------------------------------------------------
/static/images/104265_131.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/104265_131.jpg
--------------------------------------------------------------------------------
/static/images/487471_100.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/487471_100.jpg
--------------------------------------------------------------------------------
/static/images/532225_006.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/532225_006.jpg
--------------------------------------------------------------------------------
/static/images/554724_050.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/554724_050.jpg
--------------------------------------------------------------------------------
/static/images/555088_081.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/555088_081.jpg
--------------------------------------------------------------------------------
/static/images/624041_800.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/624041_800.jpg
--------------------------------------------------------------------------------
/static/images/749766_408.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/749766_408.jpg
--------------------------------------------------------------------------------
/static/images/917165_120.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/917165_120.jpg
--------------------------------------------------------------------------------
/static/images/AA2146_003.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AA2146_003.jpg
--------------------------------------------------------------------------------
/static/images/AH7241_118.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AH7241_118.jpg
--------------------------------------------------------------------------------
/static/images/AH7242_118.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AH7242_118.jpg
--------------------------------------------------------------------------------
/static/images/AH7368_801.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AH7368_801.jpg
--------------------------------------------------------------------------------
/static/images/AH7369_801.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AH7369_801.jpg
--------------------------------------------------------------------------------
/static/images/AJ1285_103.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AJ1285_103.jpg
--------------------------------------------------------------------------------
/static/images/AJ2018_004.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AJ2018_004.jpg
--------------------------------------------------------------------------------
/static/images/AJ5898_001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AJ5898_001.jpg
--------------------------------------------------------------------------------
/static/images/AJ6745_003.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AJ6745_003.jpg
--------------------------------------------------------------------------------
/static/images/AJ6900_001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AJ6900_001.jpg
--------------------------------------------------------------------------------
/static/images/AJ6900_100.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AJ6900_100.jpg
--------------------------------------------------------------------------------
/static/images/AO0566_440.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO0566_440.jpg
--------------------------------------------------------------------------------
/static/images/AO1741_103.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO1741_103.jpg
--------------------------------------------------------------------------------
/static/images/AO2409_100.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO2409_100.jpg
--------------------------------------------------------------------------------
/static/images/AO2434_101.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO2434_101.jpg
--------------------------------------------------------------------------------
/static/images/AO2439_401.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO2439_401.jpg
--------------------------------------------------------------------------------
/static/images/AO2582_004.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO2582_004.jpg
--------------------------------------------------------------------------------
/static/images/AO2924_008.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO2924_008.jpg
--------------------------------------------------------------------------------
/static/images/AO3258_440.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO3258_440.jpg
--------------------------------------------------------------------------------
/static/images/AO3266_410.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO3266_410.jpg
--------------------------------------------------------------------------------
/static/images/AO3276_410.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO3276_410.jpg
--------------------------------------------------------------------------------
/static/images/AO3277_600.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO3277_600.jpg
--------------------------------------------------------------------------------
/static/images/AO4436_001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO4436_001.jpg
--------------------------------------------------------------------------------
/static/images/AO6219_100.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AO6219_100.jpg
--------------------------------------------------------------------------------
/static/images/AQ1289_100.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ1289_100.jpg
--------------------------------------------------------------------------------
/static/images/AQ2235_100.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ2235_100.jpg
--------------------------------------------------------------------------------
/static/images/AQ3619_400.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ3619_400.jpg
--------------------------------------------------------------------------------
/static/images/AQ5707_002.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ5707_002.jpg
--------------------------------------------------------------------------------
/static/images/AQ7495_100.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ7495_100.jpg
--------------------------------------------------------------------------------
/static/images/AQ8306_600.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ8306_600.jpg
--------------------------------------------------------------------------------
/static/images/AQ8741_300.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ8741_300.jpg
--------------------------------------------------------------------------------
/static/images/AQ9164_005.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AQ9164_005.jpg
--------------------------------------------------------------------------------
/static/images/AR4229_001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AR4229_001.jpg
--------------------------------------------------------------------------------
/static/images/AR6631_007.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AR6631_007.jpg
--------------------------------------------------------------------------------
/static/images/AR6631_200.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/AR6631_200.jpg
--------------------------------------------------------------------------------
/static/images/BQ7460_102.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/BQ7460_102.jpg
--------------------------------------------------------------------------------
/static/images/BQ7496_104.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/BQ7496_104.jpg
--------------------------------------------------------------------------------
/static/images/BV1654_002.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/BV1654_002.jpg
--------------------------------------------------------------------------------
/static/images/BV7406_001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/BV7406_001.jpg
--------------------------------------------------------------------------------
/static/images/CD8238_001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CD8238_001.jpg
--------------------------------------------------------------------------------
/static/images/CD9560_106.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CD9560_106.jpg
--------------------------------------------------------------------------------
/static/images/CI1502_001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI1502_001.jpg
--------------------------------------------------------------------------------
/static/images/CI1503_001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI1503_001.jpg
--------------------------------------------------------------------------------
/static/images/CI1504_100.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI1504_100.jpg
--------------------------------------------------------------------------------
/static/images/CI1505_001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI1505_001.jpg
--------------------------------------------------------------------------------
/static/images/CI1506_001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI1506_001.jpg
--------------------------------------------------------------------------------
/static/images/CI1508_400.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI1508_400.jpg
--------------------------------------------------------------------------------
/static/images/CI2668_300.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CI2668_300.jpg
--------------------------------------------------------------------------------
/static/images/CJ0767_400.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CJ0767_400.jpg
--------------------------------------------------------------------------------
/static/images/CJ1436_100.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CJ1436_100.jpg
--------------------------------------------------------------------------------
/static/images/CK6643_100.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/CK6643_100.jpg
--------------------------------------------------------------------------------
/static/images/bs2mikksg5tqynlbq3sl.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/bs2mikksg5tqynlbq3sl.jpg
--------------------------------------------------------------------------------
/static/images/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/favicon.png
--------------------------------------------------------------------------------
/static/images/o8pc93ifccwo1qihdpqp.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/o8pc93ifccwo1qihdpqp.jpg
--------------------------------------------------------------------------------
/static/images/pljue2bfqbmq6ruw0ht1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/pljue2bfqbmq6ruw0ht1.jpg
--------------------------------------------------------------------------------
/static/images/tqbhkhohrwonshwh7srl.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/tqbhkhohrwonshwh7srl.jpg
--------------------------------------------------------------------------------
/static/images/ugc_659925883.tif.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/ugc_659925883.tif.jpg
--------------------------------------------------------------------------------
/static/images/ul2yvanoaw2ntfhslqd2.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/ul2yvanoaw2ntfhslqd2.jpg
--------------------------------------------------------------------------------
/static/images/ux4wrrodlumtnefucnq8.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/images/ux4wrrodlumtnefucnq8.jpg
--------------------------------------------------------------------------------
/static/preview-preload-bundles.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/preview-preload-bundles.gif
--------------------------------------------------------------------------------
/static/preview.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/noveogroup-amorgunov/react-ssr-tutorial/e4dc214bf8058f593da72b90641d578a51e14ea6/static/preview.gif
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist/",
4 | "sourceMap": true,
5 | "noImplicitAny": true,
6 | "noImplicitThis": true,
7 | "resolveJsonModule": true,
8 | "allowSyntheticDefaultImports": true,
9 | "module": "commonjs",
10 | "target": "es5",
11 | "jsx": "react",
12 | "baseUrl": "src"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/webpack.config.ts:
--------------------------------------------------------------------------------
1 | import clientConfig from './webpack/client.config';
2 | import serverConfig from './webpack/server.config';
3 |
4 | export default [
5 | clientConfig,
6 | serverConfig
7 | ];
8 |
--------------------------------------------------------------------------------
/webpack/client.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { Configuration, Plugin, Entry } from 'webpack';
3 | import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
4 | import MiniCssExtractPlugin from 'mini-css-extract-plugin';
5 | import CompressionPlugin from 'compression-webpack-plugin';
6 | import LoadablePlugin from '@loadable/webpack-plugin';
7 |
8 | import { IS_DEV, DIST_DIR, SRC_DIR } from './env';
9 | import fileLoader from './loaders/file';
10 | import cssLoader from './loaders/css';
11 | import jsLoader from './loaders/js';
12 |
13 | const config: Configuration = {
14 | entry: ([
15 | IS_DEV && 'react-hot-loader/patch',
16 | // IS_DEV && 'webpack-hot-middleware/client',
17 | IS_DEV && 'css-hot-loader/hotModuleReplacement',
18 | path.join(SRC_DIR, 'client'),
19 | ].filter(Boolean) as unknown) as Entry,
20 | module: {
21 | rules: [fileLoader.client, cssLoader.client, jsLoader.client],
22 | },
23 | output: {
24 | path: DIST_DIR,
25 | filename: '[name].js',
26 | publicPath: '/',
27 | },
28 | resolve: {
29 | modules: ['src', 'node_modules'],
30 | alias: { 'react-dom': '@hot-loader/react-dom' },
31 | extensions: ['*', '.js', '.jsx', '.json', '.ts', '.tsx'],
32 | plugins: [new TsconfigPathsPlugin({ configFile: './tsconfig.json' })],
33 | },
34 | plugins: [
35 | new MiniCssExtractPlugin({ filename: '[name].css' }),
36 | !IS_DEV && new CompressionPlugin(),
37 | new LoadablePlugin(),
38 | ].filter(Boolean) as Plugin[],
39 |
40 | devtool: 'source-map',
41 |
42 | performance: {
43 | hints: IS_DEV ? false : 'warning',
44 | },
45 | };
46 |
47 | export default config;
48 |
--------------------------------------------------------------------------------
/webpack/env.ts:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | const IS_DEV = process.env.NODE_ENV !== 'production';
4 | const SRC_DIR = path.join(__dirname, '../src');
5 | const DIST_DIR = path.join(__dirname, '../dist');
6 |
7 | export { IS_DEV, SRC_DIR, DIST_DIR };
8 |
--------------------------------------------------------------------------------
/webpack/loaders/css.ts:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const postcssNested = require('postcss-nested');
3 | const postcssCustomMedia = require('postcss-custom-media');
4 | const postcssImport = require('postcss-import');
5 | const postcssImportAliasResolver = require('postcss-import-alias-resolver');
6 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
7 | const cssnano = require('cssnano');
8 |
9 | const { IS_DEV } = require('../env');
10 |
11 | const resolverOptions = {
12 | alias: { styles: path.resolve('src/styles') },
13 | mergeExtensions: 'extend',
14 | };
15 |
16 | export default {
17 | client: {
18 | test: /\.css$/,
19 | use: [
20 | IS_DEV && 'css-hot-loader',
21 | MiniCssExtractPlugin.loader,
22 | 'css-loader',
23 | {
24 | loader: 'postcss-loader',
25 | options: {
26 | plugins: [
27 | postcssImport({
28 | resolve: postcssImportAliasResolver(
29 | resolverOptions
30 | ),
31 | }),
32 | postcssCustomMedia(),
33 | postcssNested(),
34 | !IS_DEV && cssnano({ preset: 'default' }),
35 | ].filter(Boolean),
36 | },
37 | },
38 | ].filter(Boolean),
39 | },
40 | server: {
41 | test: /\.css$/,
42 | loader: 'null-loader',
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/webpack/loaders/file.ts:
--------------------------------------------------------------------------------
1 | const fileRegex = /^(?!.*\.inline).*\.(svg|jpe?g|png|gif|eot|woff2?|ttf)$/;
2 |
3 | export default {
4 | client: {
5 | loader: 'url-loader',
6 | test: fileRegex,
7 | },
8 | server: {
9 | loader: 'null-loader',
10 | test: fileRegex,
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/webpack/loaders/js.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | client: {
3 | test: /\.ts(x?)$/,
4 | exclude: /node_modules/,
5 | use: { loader: 'babel-loader' },
6 | },
7 | server: {
8 | test: /\.ts(x?)$/,
9 | exclude: /node_modules/,
10 | use: { loader: 'babel-loader' },
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/webpack/server.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { Configuration } from 'webpack';
3 | import nodeExternals from 'webpack-node-externals';
4 | import { TsconfigPathsPlugin } from 'tsconfig-paths-webpack-plugin';
5 |
6 | import { IS_DEV, DIST_DIR, SRC_DIR } from './env';
7 | import fileLoader from './loaders/file';
8 | import cssLoader from './loaders/css';
9 | import jsLoader from './loaders/js';
10 |
11 | const config: Configuration = {
12 | name: 'server',
13 | target: 'node',
14 | node: { __dirname: false },
15 | entry: path.join(SRC_DIR, 'server'),
16 | module: {
17 | rules: [fileLoader.server, cssLoader.server, jsLoader.server],
18 | },
19 | output: {
20 | filename: 'server.js',
21 | libraryTarget: 'commonjs2',
22 | path: DIST_DIR,
23 | publicPath: '/static/',
24 | },
25 | resolve: {
26 | modules: ['src', 'node_modules'],
27 | extensions: ['*', '.js', '.jsx', '.json', '.ts', '.tsx'],
28 | plugins: [new TsconfigPathsPlugin({ configFile: './tsconfig.json' })],
29 | },
30 |
31 | devtool: 'source-map',
32 |
33 | performance: {
34 | hints: IS_DEV ? false : 'warning',
35 | },
36 |
37 | externals: [nodeExternals({ allowlist: [/\.(?!(?:tsx?|json)$).{1,5}$/i] })],
38 |
39 | optimization: { nodeEnv: false },
40 | };
41 |
42 | export default config;
43 |
--------------------------------------------------------------------------------