├── .nvmrc ├── .yarnrc ├── .gitattributes ├── nodemon.json ├── postcss.config.js ├── .gitignore ├── src ├── constants │ ├── KeyboardKeyEnum.ts │ └── RequestMethodEnum.ts ├── assets │ ├── media │ │ └── favicon.ico │ └── styles │ │ ├── base │ │ └── elements.scss │ │ ├── screen.scss │ │ ├── helpers │ │ ├── easing.scss │ │ ├── vars.scss │ │ └── utils.scss │ │ ├── components │ │ ├── inputField.scss │ │ ├── modalForm.scss │ │ └── modal.scss │ │ └── styles.scss ├── stores │ ├── meta │ │ ├── IMetaReducerState.ts │ │ ├── models │ │ │ └── ITitleDescription.ts │ │ ├── MetaAction.ts │ │ └── MetaReducer.ts │ ├── modal │ │ ├── IModalReducerState.ts │ │ ├── ModalAction.ts │ │ └── ModalReducer.ts │ ├── ISagaStore.ts │ ├── user │ │ ├── IUserReducerState.ts │ │ ├── UserSaga.ts │ │ ├── models │ │ │ ├── IdModel.ts │ │ │ ├── DobModel.ts │ │ │ ├── PictureModel.ts │ │ │ ├── NameModel.ts │ │ │ ├── RandomUserResponseModel.ts │ │ │ └── UserModel.ts │ │ ├── UserAction.ts │ │ ├── UserService.ts │ │ └── UserReducer.ts │ ├── render │ │ ├── IRenderReducerState.ts │ │ └── RenderReducer.ts │ ├── IAction.ts │ ├── rootSaga.ts │ ├── IStore.ts │ └── rootReducer.ts ├── environments │ ├── production.ts │ ├── local.ts │ └── staging.ts ├── server │ ├── controllers │ │ ├── IController.ts │ │ ├── AssetsController.ts │ │ └── ReactController.tsx │ ├── utilities │ │ └── ServerUtility.ts │ ├── plugin │ │ └── HapiWebpackHotPlugin.ts │ └── ServerManager.ts ├── views │ ├── contact │ │ ├── IContactForm.ts │ │ ├── CustomField.ts │ │ ├── Contact.tsx │ │ └── ContactForm.tsx │ ├── about │ │ ├── AboutAsync.tsx │ │ └── About.tsx │ ├── landmarks │ │ ├── FooterAsync.tsx │ │ ├── Footer.tsx │ │ └── Header.tsx │ ├── modals │ │ ├── GenericModalAsync.tsx │ │ ├── ExampleFormModalAsync.tsx │ │ ├── ModalHub.tsx │ │ ├── BaseModal.tsx │ │ ├── GenericModal.tsx │ │ └── ExampleFormModal.tsx │ ├── errors │ │ ├── NotFoundAsync.tsx │ │ └── NotFound.tsx │ ├── components │ │ └── InputField.tsx │ └── home │ │ └── Home.tsx ├── index.html ├── utilities │ ├── ServerUtility.ts │ ├── ProviderUtility.ts │ └── HttpUtility.ts ├── typings.d.ts ├── server.ts ├── RouterWrapper.tsx └── client.tsx ├── .editorconfig ├── .stylelintrc ├── .babelrc ├── tsconfig.json ├── tsconfig.server.json ├── LICENSE ├── README.md ├── tslint.json ├── package.json └── webpack.config.js /.nvmrc: -------------------------------------------------------------------------------- 1 | 10.11.0 2 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | save-prefix "" 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.sh eol=lf 3 | *.cmd eol=crlf 4 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": false, 3 | "verbose": false 4 | } 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | dist 5 | *.swp 6 | .yarnclean 7 | .idea 8 | *.log 9 | -------------------------------------------------------------------------------- /src/constants/KeyboardKeyEnum.ts: -------------------------------------------------------------------------------- 1 | enum KeyboardKeyEnum { 2 | ESCAPE = 'Escape', 3 | } 4 | 5 | export default KeyboardKeyEnum; 6 | -------------------------------------------------------------------------------- /src/assets/media/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeBelt/typescript-hapi-react-hot-loader-example/HEAD/src/assets/media/favicon.ico -------------------------------------------------------------------------------- /src/stores/meta/IMetaReducerState.ts: -------------------------------------------------------------------------------- 1 | export default interface IMetaReducerState { 2 | readonly title: string; 3 | readonly description?: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/styles/base/elements.scss: -------------------------------------------------------------------------------- 1 | form:not(.u-validate) input:invalid { 2 | background-color: #fafad2; 3 | border: 1px solid $COLOR_BRAND_PRIMARY; 4 | } 5 | -------------------------------------------------------------------------------- /src/environments/production.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | endpointUrl: { 3 | randomuser: 'https://randomuser.me/api', 4 | }, 5 | isProduction: true, 6 | }; 7 | -------------------------------------------------------------------------------- /src/server/controllers/IController.ts: -------------------------------------------------------------------------------- 1 | import * as Hapi from 'hapi'; 2 | 3 | export default interface IController { 4 | mapRoutes(server: Hapi.Server): void; 5 | } 6 | -------------------------------------------------------------------------------- /src/stores/meta/models/ITitleDescription.ts: -------------------------------------------------------------------------------- 1 | export default interface ITitleDescription { 2 | readonly description?: string; 3 | readonly title: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/stores/modal/IModalReducerState.ts: -------------------------------------------------------------------------------- 1 | export default interface IModalReducerState { 2 | readonly currentModal: JSX.Element; 3 | readonly modals: JSX.Element[]; 4 | } 5 | -------------------------------------------------------------------------------- /src/stores/ISagaStore.ts: -------------------------------------------------------------------------------- 1 | import IStore from './IStore'; 2 | 3 | export default interface ISagaStore extends IStore { 4 | runSaga: any; // TODO: figure out type 5 | endSaga: any; // TODO: figure out type 6 | } 7 | -------------------------------------------------------------------------------- /src/stores/user/IUserReducerState.ts: -------------------------------------------------------------------------------- 1 | import UserModel from './models/UserModel'; 2 | 3 | export default interface IUserReducerState { 4 | readonly currentUser: UserModel; 5 | readonly isLoadingUser: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /src/constants/RequestMethodEnum.ts: -------------------------------------------------------------------------------- 1 | enum RequestMethodEnum { 2 | Get = 'GET', 3 | Post = 'POST', 4 | Put = 'PUT', 5 | Patch = 'PATCH', 6 | Delete = 'DELETE', 7 | } 8 | 9 | export default RequestMethodEnum; 10 | -------------------------------------------------------------------------------- /src/views/contact/IContactForm.ts: -------------------------------------------------------------------------------- 1 | export default interface IContactForm { 2 | name: string; 3 | email: string; 4 | message: string; 5 | exampleSelect1: string; 6 | codeQualityRadio: string; 7 | starred: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/environments/local.ts: -------------------------------------------------------------------------------- 1 | import environment from './production'; 2 | 3 | export default { 4 | endpointUrl: { 5 | ...environment.endpointUrl, 6 | // override any endpoints 7 | }, 8 | isProduction: false, 9 | }; 10 | -------------------------------------------------------------------------------- /src/environments/staging.ts: -------------------------------------------------------------------------------- 1 | import environment from './production'; 2 | 3 | export default { 4 | endpointUrl: { 5 | ...environment.endpointUrl, 6 | // override any endpoints 7 | }, 8 | isProduction: false, 9 | }; 10 | -------------------------------------------------------------------------------- /src/assets/styles/screen.scss: -------------------------------------------------------------------------------- 1 | @import "helpers/vars"; 2 | @import "helpers/utils"; 3 | @import "helpers/easing"; 4 | @import "base/elements"; 5 | @import "components/inputField"; 6 | @import "components/modal"; 7 | @import "components/modalForm"; 8 | @import "styles"; 9 | -------------------------------------------------------------------------------- /src/stores/render/IRenderReducerState.ts: -------------------------------------------------------------------------------- 1 | import {IServerLocation} from '../../server/utilities/ServerUtility'; 2 | 3 | export default interface IRenderReducerState { 4 | readonly isServerSide: boolean; 5 | readonly serverSideLocation: IServerLocation; 6 | } 7 | -------------------------------------------------------------------------------- /src/assets/styles/helpers/easing.scss: -------------------------------------------------------------------------------- 1 | /* stylelint-disable number-no-trailing-zeros */ 2 | $EASE_IN_QUAD: cubic-bezier(0.550, 0.085, 0.680, 0.530); 3 | $EASE_OUT_QUAD: cubic-bezier(0.250, 0.460, 0.450, 0.940); 4 | $EASE_OUT_CUBIC: cubic-bezier(0.215, 0.610, 0.355, 1.000); 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 4 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [{.*rc,*.json,*.yml}] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /src/stores/IAction.ts: -------------------------------------------------------------------------------- 1 | import {Action} from 'redux'; 2 | 3 | /** 4 | * https://github.com/acdlite/flux-standard-action 5 | */ 6 | export default interface IAction extends Action { 7 | type: string; 8 | payload?: T; 9 | error?: boolean; 10 | meta?: any; 11 | } 12 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "plugins": [ 4 | "stylelint-order" 5 | ], 6 | "rules": { 7 | "indentation": 4, 8 | "color-hex-length": "long", 9 | "order/properties-alphabetical-order": true, 10 | "at-rule-no-unknown": null 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/assets/styles/helpers/vars.scss: -------------------------------------------------------------------------------- 1 | /* vars 2 | ------------------------------------------------- */ 3 | $COLOR_PRIMARY: rgb(0, 200, 230); 4 | $COLOR_SECONDARY: rgb(0, 60, 230); 5 | $COLOR_BRAND_PRIMARY: rgb(255, 0, 0); 6 | $COLOR_DARKGRAY_MEDIUM: rgb(112, 112, 112); 7 | 8 | $XS: 32.5rem; 9 | $SM: 48rem; 10 | $MD: 60rem; 11 | -------------------------------------------------------------------------------- /src/views/contact/CustomField.ts: -------------------------------------------------------------------------------- 1 | import {Field} from 'redux-form'; 2 | 3 | const CustomField = Field as new () => Field<{ 4 | label?: string; 5 | placeholder?: string; 6 | type?: string; 7 | option?: string; 8 | checked?: boolean; 9 | disabled?: boolean; 10 | }>; 11 | 12 | export default CustomField; 13 | -------------------------------------------------------------------------------- /src/views/about/AboutAsync.tsx: -------------------------------------------------------------------------------- 1 | import {asyncComponent} from 'react-async-component'; 2 | 3 | const AboutAsync = asyncComponent({ 4 | name: 'AboutAsync', 5 | serverMode: 'resolve', 6 | resolve: () => { 7 | return import(/* webpackChunkName: "About" */ './About'); 8 | }, 9 | }); 10 | 11 | export default AboutAsync; 12 | -------------------------------------------------------------------------------- /src/views/landmarks/FooterAsync.tsx: -------------------------------------------------------------------------------- 1 | import {asyncComponent} from 'react-async-component'; 2 | 3 | const FooterAsync = asyncComponent({ 4 | name: 'FooterAsync', 5 | serverMode: 'defer', 6 | resolve: () => { 7 | return import(/* webpackChunkName: "Footer" */ './Footer'); 8 | }, 9 | }); 10 | 11 | export default FooterAsync; 12 | -------------------------------------------------------------------------------- /src/views/modals/GenericModalAsync.tsx: -------------------------------------------------------------------------------- 1 | import {asyncComponent} from 'react-async-component'; 2 | 3 | const GenericModalAsync = asyncComponent({ 4 | name: 'GenericModalAsync', 5 | serverMode: 'defer', 6 | resolve: () => { 7 | return import(/* webpackChunkName: "GenericModal" */ './GenericModal'); 8 | }, 9 | }); 10 | 11 | export default GenericModalAsync; 12 | -------------------------------------------------------------------------------- /src/views/modals/ExampleFormModalAsync.tsx: -------------------------------------------------------------------------------- 1 | import {asyncComponent} from 'react-async-component'; 2 | 3 | const ExampleFormModalAsync = asyncComponent({ 4 | name: 'ExampleFormModalAsync', 5 | serverMode: 'defer', 6 | resolve: () => { 7 | return import(/* webpackChunkName: "ExampleFormModal" */ './ExampleFormModal'); 8 | }, 9 | }); 10 | 11 | export default ExampleFormModalAsync; 12 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "production": { 4 | "plugins": ["transform-react-remove-prop-types"] 5 | } 6 | }, 7 | "presets": [ 8 | "@babel/env", 9 | "@babel/typescript", 10 | "@babel/react" 11 | ], 12 | "plugins": [ 13 | "@babel/plugin-syntax-dynamic-import", 14 | "@babel/proposal-class-properties", 15 | "@babel/proposal-object-rest-spread", 16 | "@babel/plugin-transform-runtime" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/views/errors/NotFoundAsync.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {asyncComponent} from 'react-async-component'; 3 | 4 | const NotFoundAsync = asyncComponent({ 5 | LoadingComponent: () =>
Loading...
, 6 | name: 'NotFoundAsync', 7 | serverMode: 'resolve', 8 | resolve: () => { 9 | return import(/* webpackChunkName: "NotFound" */ './NotFound'); 10 | }, 11 | }); 12 | 13 | export default NotFoundAsync; 14 | -------------------------------------------------------------------------------- /src/assets/styles/helpers/utils.scss: -------------------------------------------------------------------------------- 1 | /* utils 2 | ------------------------------------------------- */ 3 | @mixin isVisuallyHidden() { 4 | border: 0; 5 | clip: rect(0 0 0 0); 6 | height: 1px; 7 | margin: -1px; 8 | overflow: hidden; 9 | padding: 0; 10 | position: absolute; 11 | width: 1px; 12 | } 13 | 14 | .u-isHidden { 15 | display: none !important; 16 | } 17 | 18 | .u-isVisuallyHidden { 19 | @include isVisuallyHidden; 20 | } 21 | -------------------------------------------------------------------------------- /src/stores/user/UserSaga.ts: -------------------------------------------------------------------------------- 1 | import IAction from '../IAction'; 2 | import UserService from './UserService'; 3 | import UserAction from './UserAction'; 4 | import {put} from 'redux-saga/effects'; 5 | import UserModel from './models/UserModel'; 6 | 7 | export default class UserSaga { 8 | 9 | public static* loadUser(action: IAction = null) { 10 | const userModel: UserModel = yield UserService.loadUser(); 11 | 12 | yield put(UserAction.loadUserSuccess(userModel)); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/views/landmarks/Footer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | interface IProps {} 4 | interface IState {} 5 | 6 | export default class Footer extends React.PureComponent { 7 | 8 | public render(): JSX.Element { 9 | return ( 10 |
11 |

{'This footer is a deferred async component. It does not render server-side. It lazy loads on the client-side.'}

12 |
13 | ); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/stores/render/RenderReducer.ts: -------------------------------------------------------------------------------- 1 | import IRenderReducerState from './IRenderReducerState'; 2 | import IAction from '../IAction'; 3 | 4 | export default class RenderReducer { 5 | 6 | private static readonly _initialState: IRenderReducerState = { 7 | isServerSide: true, 8 | serverSideLocation: null, 9 | }; 10 | 11 | public static reducer(state: IRenderReducerState = RenderReducer._initialState, action: IAction): IRenderReducerState { 12 | return state; 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/stores/user/models/IdModel.ts: -------------------------------------------------------------------------------- 1 | import {BaseModel} from 'sjs-base-model'; 2 | 3 | /* 4 | // Returned Api Data Sample 5 | { 6 | "name": "TFN", 7 | "value": "406524662" 8 | } 9 | */ 10 | export default class IdModel extends BaseModel { 11 | 12 | public readonly name: string = ''; 13 | public readonly value: string = ''; 14 | 15 | constructor(data: Partial) { 16 | super(); 17 | 18 | this.update(data); 19 | } 20 | 21 | public update(data: Partial): void { 22 | super.update(data); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/stores/user/models/DobModel.ts: -------------------------------------------------------------------------------- 1 | import {BaseModel} from 'sjs-base-model'; 2 | 3 | /* 4 | // Returned Api Data Sample 5 | { 6 | "date": "1966-03-19T23:24:27Z", 7 | "age": 52 8 | } 9 | */ 10 | export default class DobModel extends BaseModel { 11 | 12 | public readonly date: string = ''; 13 | public readonly age: number = null; 14 | 15 | constructor(data: Partial) { 16 | super(); 17 | 18 | this.update(data); 19 | } 20 | 21 | public update(data: Partial): void { 22 | super.update(data); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/stores/rootSaga.ts: -------------------------------------------------------------------------------- 1 | import {all, fork, ForkEffect, select, takeLatest} from 'redux-saga/effects'; 2 | import UserSaga from './user/UserSaga'; 3 | import UserAction from './user/UserAction'; 4 | import IStore from './IStore'; 5 | 6 | export default function* rootSaga() { 7 | const store: IStore = yield select(); 8 | const isServerSide = store.renderReducer.isServerSide; 9 | 10 | const filteredSagas: ForkEffect[] = [ 11 | isServerSide ? fork(UserSaga.loadUser) : null, 12 | takeLatest(UserAction.LOAD_USER, UserSaga.loadUser), 13 | ].filter(Boolean); 14 | 15 | yield all(filteredSagas); 16 | } 17 | -------------------------------------------------------------------------------- /src/stores/meta/MetaAction.ts: -------------------------------------------------------------------------------- 1 | import IAction from '../IAction'; 2 | import ITitleDescription from './models/ITitleDescription'; 3 | 4 | export type MetaActionUnion = ITitleDescription; 5 | 6 | export default class MetaAction { 7 | 8 | public static readonly SET_META: string = 'MetaAction.SET_META'; 9 | 10 | public static setMeta(meta: ITitleDescription): IAction { 11 | if (global.document) { 12 | global.document.title = meta.title; 13 | } 14 | 15 | return { 16 | type: MetaAction.SET_META, 17 | payload: meta, 18 | }; 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {title} 6 | 7 | 8 | 12 | 13 | 14 |
{content}
15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/utilities/ServerUtility.ts: -------------------------------------------------------------------------------- 1 | import * as Hapi from 'hapi'; 2 | 3 | export default class ServerUtility { 4 | 5 | public static createLocationObject(request: Hapi.Request): any { 6 | const protocol: string = request.headers['x-forwarded-proto']; 7 | 8 | return { 9 | ...request.url, 10 | host: request.info.host, 11 | hostname: request.info.host.split(':')[0], 12 | href: `${protocol}://${request.info.host}${request.url.path}`, 13 | origin: `${protocol}://${request.info.host}`, 14 | pathname: request.url.path.split('?')[0], 15 | protocol: `${protocol}:`, 16 | }; 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/stores/modal/ModalAction.ts: -------------------------------------------------------------------------------- 1 | import IAction from '../IAction'; 2 | 3 | export type ModalActionUnion = void | JSX.Element; 4 | 5 | export default class ModalAction { 6 | 7 | public static readonly ADD_MODAL: string = 'ModalAction.ADD_MODAL'; 8 | public static readonly REMOVE_MODAL: string = 'ModalAction.REMOVE_MODAL'; 9 | 10 | public static addModal(modal: JSX.Element): IAction { 11 | return { 12 | type: ModalAction.ADD_MODAL, 13 | payload: modal, 14 | }; 15 | } 16 | 17 | public static closeModal(): IAction { 18 | return { 19 | type: ModalAction.REMOVE_MODAL, 20 | }; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/server/controllers/AssetsController.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as Hapi from 'hapi'; 3 | import IController from './IController'; 4 | import RequestMethodEnum from '../../constants/RequestMethodEnum'; 5 | 6 | export default class AssetsController implements IController { 7 | 8 | public mapRoutes(server: Hapi.Server): void { 9 | server.route({ 10 | method: RequestMethodEnum.Get, 11 | path: '/assets/{file*}', 12 | handler: (request: Hapi.Request, h: Hapi.ResponseToolkit): Hapi.ResponseObject => { 13 | return h.file(path.resolve(__dirname, `../../public${request.path}`)); 14 | }, 15 | }); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | __STATE__?: any; 3 | __ASYNC_COMPONENTS_STATE__?: any; 4 | } 5 | 6 | declare namespace NodeJS { 7 | interface Global { 8 | document: Document; 9 | window: Window; 10 | navigator: Navigator; 11 | } 12 | } 13 | 14 | declare module 'form2js'; 15 | declare module 'react-async-bootstrapper'; 16 | declare module 'react-async-component'; 17 | declare module 'hapi-webpack-plugin'; 18 | declare module 'redux-freeze'; 19 | 20 | declare module 'environment' { 21 | const value: { 22 | isProduction: boolean; 23 | endpointUrl: { 24 | randomuser: string; 25 | } 26 | }; 27 | 28 | export default value; 29 | } 30 | -------------------------------------------------------------------------------- /src/stores/IStore.ts: -------------------------------------------------------------------------------- 1 | import {RouterState} from 'connected-react-router'; 2 | import {Store} from 'redux'; 3 | import {FormReducer} from 'redux-form'; 4 | import IMetaReducerState from './meta/IMetaReducerState'; 5 | import IUserReducerState from './user/IUserReducerState'; 6 | import IRenderReducerState from './render/IRenderReducerState'; 7 | import IModalReducerState from './modal/IModalReducerState'; 8 | 9 | export default interface IStore extends Store { 10 | readonly form: FormReducer; 11 | readonly metaReducer: IMetaReducerState; 12 | readonly modalReducer: IModalReducerState; 13 | readonly renderReducer: IRenderReducerState; 14 | readonly userReducer: IUserReducerState; 15 | readonly router: RouterState; 16 | } 17 | -------------------------------------------------------------------------------- /src/stores/user/UserAction.ts: -------------------------------------------------------------------------------- 1 | import IAction from '../IAction'; 2 | import UserModel from './models/UserModel'; 3 | 4 | export type UserActionUnion = void | UserModel; 5 | 6 | export default class UserAction { 7 | 8 | public static readonly LOAD_USER: string = 'UserAction.LOAD_USER'; 9 | public static readonly LOAD_USER_SUCCESS: string = 'UserAction.LOAD_USER_SUCCESS'; 10 | 11 | public static loadUser(): IAction { 12 | return { 13 | type: UserAction.LOAD_USER, 14 | }; 15 | } 16 | 17 | public static loadUserSuccess(model: UserModel): IAction { 18 | return { 19 | payload: model, 20 | type: UserAction.LOAD_USER_SUCCESS, 21 | }; 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/stores/user/UserService.ts: -------------------------------------------------------------------------------- 1 | import HttpUtility from '../../utilities/HttpUtility'; 2 | import {AxiosResponse} from 'axios'; 3 | import UserModel from './models/UserModel'; 4 | import RandomUserResponseModel from './models/RandomUserResponseModel'; 5 | import environment from 'environment'; 6 | 7 | export default class UserService { 8 | 9 | private static _http: HttpUtility = new HttpUtility(); 10 | 11 | public static async loadUser(): Promise { 12 | const endpoint: string = `${environment.endpointUrl.randomuser}?inc=picture,name,email,phone,id,dob`; 13 | const response: AxiosResponse = await UserService._http.get(endpoint); 14 | const randomUser = new RandomUserResponseModel(response.data); 15 | 16 | return randomUser.results[0]; 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "docs": "https://www.typescriptlang.org/docs/handbook/compiler-options.html", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "allowSyntheticDefaultImports": true, 6 | "baseUrl": ".", 7 | "forceConsistentCasingInFileNames": true, 8 | "jsx": "react", 9 | "lib": ["dom", "es2016", "es2017"], 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "noImplicitAny": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "outDir": "build/dist", 17 | "rootDir": "src", 18 | "sourceMap": true, 19 | "strictNullChecks": false, 20 | "suppressImplicitAnyIndexErrors": true, 21 | "target": "es5" 22 | }, 23 | "include": ["src/**/*"], 24 | "exclude": ["node_modules", "build"] 25 | } 26 | -------------------------------------------------------------------------------- /src/stores/user/models/PictureModel.ts: -------------------------------------------------------------------------------- 1 | import {BaseModel} from 'sjs-base-model'; 2 | 3 | /* 4 | // Returned Api Data Sample 5 | { 6 | "large": "https://randomuser.me/api/portraits/women/50.jpg", 7 | "medium": "https://randomuser.me/api/portraits/med/women/50.jpg", 8 | "thumbnail": "https://randomuser.me/api/portraits/thumb/women/50.jpg" 9 | } 10 | */ 11 | export default class PictureModel extends BaseModel { 12 | 13 | public readonly large: string = ''; 14 | public readonly medium: string = ''; 15 | public readonly thumbnail: string = ''; 16 | 17 | constructor(data: Partial) { 18 | super(); 19 | 20 | this.update(data); 21 | } 22 | 23 | public update(data: Partial): void { 24 | super.update(data); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/stores/user/models/NameModel.ts: -------------------------------------------------------------------------------- 1 | import {BaseModel} from 'sjs-base-model'; 2 | 3 | /* 4 | // Returned Api Data Sample 5 | { 6 | "title": "mrs", 7 | "first": "frances", 8 | "last": "reynolds" 9 | } 10 | */ 11 | export default class NameModel extends BaseModel { 12 | 13 | public readonly title: string = ''; 14 | public readonly first: string = ''; 15 | public readonly last: string = ''; 16 | 17 | /* 18 | * Client-Side properties. Not returned from API. 19 | */ 20 | public fullName: string = ''; 21 | 22 | constructor(data: Partial) { 23 | super(); 24 | 25 | this.update(data); 26 | } 27 | 28 | public update(data: Partial): void { 29 | super.update(data); 30 | 31 | this.fullName = `${this.first} ${this.last}`; 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "docs": "https://www.typescriptlang.org/docs/handbook/compiler-options.html", 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "emitDecoratorMetadata": true, 6 | "experimentalDecorators": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "jsx": "react", 9 | "lib": ["dom", "es2016", "es2017"], 10 | "module": "commonjs", 11 | "moduleResolution": "node", 12 | "noImplicitAny": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "outDir": "dist", 17 | "pretty": true, 18 | "removeComments": false, 19 | "sourceMap": false, 20 | "strictNullChecks": false, 21 | "suppressImplicitAnyIndexErrors": true, 22 | "target": "es2016" 23 | }, 24 | "include": ["src/**/*"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /src/stores/rootReducer.ts: -------------------------------------------------------------------------------- 1 | import {combineReducers, Reducer, ReducersMapObject} from 'redux'; 2 | import {connectRouter} from 'connected-react-router'; 3 | import UserReducer from './user/UserReducer'; 4 | import MetaReducer from './meta/MetaReducer'; 5 | import {reducer as formReducer} from 'redux-form'; 6 | import RenderReducer from './render/RenderReducer'; 7 | import IStore from './IStore'; 8 | import ModalReducer from './modal/ModalReducer'; 9 | import {History} from 'history'; 10 | 11 | export default (history: History): Reducer => { 12 | const reducerMap: ReducersMapObject = { 13 | form: formReducer, 14 | metaReducer: MetaReducer.reducer, 15 | modalReducer: ModalReducer.reducer, 16 | renderReducer: RenderReducer.reducer, 17 | userReducer: UserReducer.reducer, 18 | router: connectRouter(history), 19 | }; 20 | 21 | return combineReducers(reducerMap); 22 | }; 23 | -------------------------------------------------------------------------------- /src/views/modals/ModalHub.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {Dispatch} from 'redux'; 4 | import IStore from '../../stores/IStore'; 5 | import IAction from '../../stores/IAction'; 6 | 7 | interface IState {} 8 | interface IProps {} 9 | interface IStateToProps { 10 | readonly currentModal: JSX.Element; 11 | } 12 | interface IDispatchToProps { 13 | dispatch: (action: IAction) => void; 14 | } 15 | 16 | const mapStateToProps = (state: IStore): IStateToProps => ({ 17 | currentModal: state.modalReducer.currentModal, 18 | }); 19 | const mapDispatchToProps = (dispatch: Dispatch>): IDispatchToProps => ({ 20 | dispatch, 21 | }); 22 | 23 | class ModalHub extends React.Component { 24 | 25 | public render(): JSX.Element { 26 | return this.props.currentModal; 27 | } 28 | 29 | } 30 | 31 | export default connect(mapStateToProps, mapDispatchToProps)(ModalHub); 32 | -------------------------------------------------------------------------------- /src/stores/meta/MetaReducer.ts: -------------------------------------------------------------------------------- 1 | import MetaAction, {MetaActionUnion} from './MetaAction'; 2 | import IMetaReducerState from './IMetaReducerState'; 3 | import IAction from '../IAction'; 4 | import ITitleDescription from './models/ITitleDescription'; 5 | 6 | export default class MetaReducer { 7 | 8 | private static readonly _initialState: IMetaReducerState = { 9 | title: 'Robert is cool', 10 | description: '', 11 | }; 12 | 13 | public static reducer(state: IMetaReducerState = MetaReducer._initialState, action: IAction): IMetaReducerState { 14 | switch (action.type) { 15 | case MetaAction.SET_META: 16 | const model: ITitleDescription = action.payload as ITitleDescription; 17 | 18 | return { 19 | ...state, 20 | description: model.description || '', 21 | title: model.title, 22 | }; 23 | default: 24 | return state; 25 | } 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/assets/styles/components/inputField.scss: -------------------------------------------------------------------------------- 1 | .inputField-label { 2 | display: block; 3 | margin-bottom: 5px; 4 | } 5 | 6 | .inputField-input { 7 | appearance: none; 8 | background-color: #ffffff; 9 | border: 1px solid $COLOR_DARKGRAY_MEDIUM; 10 | box-shadow: inset 0 0 0 1px rgba($COLOR_PRIMARY, 0); 11 | font-size: 16px; 12 | max-width: 100%; 13 | padding: 9px; 14 | transition: 15 | border-color 300ms $EASE_IN_QUAD, 16 | box-shadow 300ms $EASE_IN_QUAD; 17 | 18 | &:focus { 19 | border-color: $COLOR_PRIMARY; 20 | box-shadow: inset 0 0 0 1px rgba($COLOR_PRIMARY, 100%); 21 | transition: 22 | border-color 250ms $EASE_OUT_QUAD, 23 | box-shadow 250ms $EASE_OUT_QUAD; 24 | } 25 | } 26 | 27 | .inputField_inline { 28 | align-items: center; 29 | display: flex; 30 | } 31 | 32 | .inputField_inline .inputField-label { 33 | flex: 1 0 auto; 34 | margin-bottom: 0; 35 | margin-right: 12px; 36 | } 37 | 38 | .inputField_noLabel .inputField-label { 39 | @include isVisuallyHidden; 40 | } 41 | -------------------------------------------------------------------------------- /src/stores/user/UserReducer.ts: -------------------------------------------------------------------------------- 1 | import UserAction, {UserActionUnion} from './UserAction'; 2 | import IUserReducerState from './IUserReducerState'; 3 | import IAction from '../IAction'; 4 | import UserModel from './models/UserModel'; 5 | 6 | export default class UserReducer { 7 | 8 | private static readonly _initialState: IUserReducerState = { 9 | currentUser: null, 10 | isLoadingUser: false, 11 | }; 12 | 13 | public static reducer(state: IUserReducerState = UserReducer._initialState, action: IAction): IUserReducerState { 14 | switch (action.type) { 15 | case UserAction.LOAD_USER: 16 | return { 17 | ...state, 18 | isLoadingUser: true, 19 | }; 20 | case UserAction.LOAD_USER_SUCCESS: 21 | return { 22 | ...state, 23 | isLoadingUser: false, 24 | currentUser: action.payload as UserModel, 25 | }; 26 | default: 27 | return state; 28 | } 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Robert S. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * module-alias is used to resolve the environment path for node files. 3 | * 4 | * // import environment from 'environment'; 5 | */ 6 | import * as moduleAlias from 'module-alias'; 7 | moduleAlias.addAliases({ 8 | environment: `${__dirname}/environments/${process.env.NODE_ENV}.js`, 9 | }); 10 | 11 | import 'fetch-everywhere'; 12 | import * as inert from 'inert'; 13 | import AssetsController from './server/controllers/AssetsController'; 14 | import HapiWebpackHotPlugin from './server/plugin/HapiWebpackHotPlugin'; 15 | import ReactController from './server/controllers/ReactController'; 16 | import ServerManager from './server/ServerManager'; 17 | 18 | (async () => { 19 | 20 | const manager = new ServerManager(); 21 | 22 | await manager.registerPlugin(inert); 23 | 24 | if (manager.isDevelopment) { 25 | const hapiWebpackHotPlugin = new HapiWebpackHotPlugin(); 26 | 27 | await manager.registerPlugin(hapiWebpackHotPlugin.plugin); 28 | } 29 | 30 | manager.registerController(new AssetsController()); 31 | manager.registerController(new ReactController()); 32 | 33 | await manager.startServer(); 34 | 35 | })(); 36 | -------------------------------------------------------------------------------- /src/views/errors/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {connect} from 'react-redux'; 3 | import MetaAction from '../../stores/meta/MetaAction'; 4 | import IStore from '../../stores/IStore'; 5 | import {Dispatch} from 'redux'; 6 | import IAction from '../../stores/IAction'; 7 | 8 | interface IState {} 9 | interface IProps {} 10 | interface IStateToProps {} 11 | interface IDispatchToProps { 12 | dispatch: (action: IAction) => void; 13 | } 14 | 15 | const mapStateToProps = (state: IStore): IStateToProps => ({}); 16 | const mapDispatchToProps = (dispatch: Dispatch>): IDispatchToProps => ({ 17 | dispatch, 18 | }); 19 | 20 | class NotFound extends React.Component { 21 | 22 | public componentWillMount(): void { 23 | this.props.dispatch(MetaAction.setMeta({title: '404 Page Not Found'})); 24 | } 25 | 26 | public render() { 27 | return ( 28 |
29 |
30 |

{'404'}

31 |

{'We are sorry but the page you are looking for does not exist.'}

32 |
33 |
34 | ); 35 | } 36 | 37 | } 38 | 39 | export default connect(mapStateToProps, mapDispatchToProps)(NotFound); 40 | -------------------------------------------------------------------------------- /src/server/utilities/ServerUtility.ts: -------------------------------------------------------------------------------- 1 | import * as Hapi from 'hapi'; 2 | import * as url from 'url'; 3 | import * as path from 'path'; 4 | import * as util from 'util'; 5 | import * as fs from 'fs'; 6 | 7 | const readFileAsync = util.promisify(fs.readFile); 8 | 9 | export interface IServerLocation extends url.Url { 10 | host: string; 11 | hostname: string; 12 | href: string; 13 | origin: string; 14 | pathname: string; 15 | protocol: string; 16 | length: number; 17 | } 18 | 19 | export default class ServerUtility { 20 | 21 | public static createLocationObject(request: Hapi.Request): IServerLocation { 22 | const protocol: string = request.headers['x-forwarded-proto'] || request.server.info.protocol; 23 | 24 | return { 25 | ...request.url, 26 | host: request.info.host, 27 | hostname: request.info.host.split(':')[0], 28 | href: `${protocol}://${request.info.host}${request.url.path}`, 29 | origin: `${protocol}://${request.info.host}`, 30 | pathname: request.url.path.split('?')[0], 31 | protocol: `${protocol}:`, 32 | length: null, 33 | }; 34 | } 35 | 36 | public static async loadHtmlFile(): Promise { 37 | const htmlPath = path.resolve(__dirname, '../../public/index.html'); 38 | 39 | return readFileAsync(htmlPath, 'utf8'); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/views/contact/Contact.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {connect} from 'react-redux'; 3 | import MetaAction from '../../stores/meta/MetaAction'; 4 | import {Dispatch} from 'redux'; 5 | import IStore from '../../stores/IStore'; 6 | import ContactForm from './ContactForm'; 7 | import IAction from '../../stores/IAction'; 8 | 9 | interface IState {} 10 | interface IProps {} 11 | interface IStateToProps {} 12 | interface IDispatchToProps { 13 | dispatch: (action: IAction) => void; 14 | } 15 | 16 | const mapStateToProps = (state: IStore): IStateToProps => ({}); 17 | const mapDispatchToProps = (dispatch: Dispatch>): IDispatchToProps => ({ 18 | dispatch, 19 | }); 20 | 21 | class Contact extends React.Component { 22 | 23 | public componentWillMount(): void { 24 | this.props.dispatch(MetaAction.setMeta({title: 'Contact Page'})); 25 | } 26 | 27 | public render(): JSX.Element { 28 | return ( 29 |
30 |
31 |

{'Contact'}

32 |

{'This contact form uses redux-form to do client-side validation.'}

33 |
34 | 35 |
36 | ); 37 | } 38 | 39 | } 40 | 41 | export default connect(mapStateToProps, mapDispatchToProps)(Contact); 42 | -------------------------------------------------------------------------------- /src/assets/styles/components/modalForm.scss: -------------------------------------------------------------------------------- 1 | .modalForm { 2 | text-align: left; 3 | } 4 | 5 | .modalForm-warning { 6 | font-size: 14px; 7 | margin-bottom: 15px; 8 | } 9 | 10 | .modalForm-item { 11 | margin-bottom: 20px; 12 | 13 | @media (--XS) { 14 | align-items: center; 15 | display: flex; 16 | flex-wrap: wrap; 17 | margin-left: -20px; 18 | } 19 | 20 | @media (--SM) { 21 | flex-wrap: nowrap; 22 | margin-bottom: 10px; 23 | } 24 | 25 | &:last-child { 26 | margin-bottom: 0; 27 | } 28 | 29 | & > * { 30 | @media (--XS) { 31 | flex: 0 0 auto; 32 | padding-left: 20px; 33 | } 34 | } 35 | } 36 | 37 | .modalForm-item-label { 38 | display: block; 39 | margin-bottom: 5px; 40 | 41 | @media (--XS) { 42 | width: 100%; 43 | } 44 | 45 | @media (--SM) { 46 | margin-bottom: 0; 47 | text-align: right; 48 | width: 25%; 49 | } 50 | } 51 | 52 | .modalForm-item-input { 53 | @media (--XS) { 54 | width: 55%; 55 | } 56 | 57 | @media (--SM) { 58 | width: 45%; 59 | } 60 | } 61 | 62 | .modalForm-item-input + .modalForm-item-modifier { 63 | margin-top: 10px; 64 | 65 | @media (--XS) { 66 | margin-top: 0; 67 | } 68 | } 69 | 70 | .modalForm-item-modifier { 71 | text-align: right; 72 | 73 | @media (--XS) { 74 | text-align: left; 75 | width: 45%; 76 | } 77 | 78 | @media (--SM) { 79 | width: 30%; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/stores/user/models/RandomUserResponseModel.ts: -------------------------------------------------------------------------------- 1 | import {BaseModel} from 'sjs-base-model'; 2 | import UserModel from './UserModel'; 3 | 4 | /* 5 | // Returned Api Data Sample 6 | { 7 | "results": [ 8 | { 9 | "name": { 10 | "title": "mrs", 11 | "first": "frances", 12 | "last": "reynolds" 13 | }, 14 | "email": "frances.reynolds@example.com", 15 | "dob": { 16 | "date": "1966-03-19T23:24:27Z", 17 | "age": 52 18 | }, 19 | "phone": "01-1488-2236", 20 | "id": { 21 | "name": "TFN", 22 | "value": "406524662" 23 | }, 24 | "picture": { 25 | "large": "https://randomuser.me/api/portraits/women/50.jpg", 26 | "medium": "https://randomuser.me/api/portraits/med/women/50.jpg", 27 | "thumbnail": "https://randomuser.me/api/portraits/thumb/women/50.jpg" 28 | } 29 | } 30 | ], 31 | "info": { 32 | "seed": "cef175b8463ecc54", 33 | "results": 1, 34 | "page": 1, 35 | "version": "1.2" 36 | } 37 | } 38 | */ 39 | export default class RandomUserResponseModel extends BaseModel { 40 | 41 | public readonly results: UserModel[] = [UserModel as any]; 42 | 43 | constructor(data: Partial) { 44 | super(); 45 | 46 | this.update(data); 47 | } 48 | 49 | public update(data: Partial): void { 50 | super.update(data); 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/stores/user/models/UserModel.ts: -------------------------------------------------------------------------------- 1 | import {BaseModel} from 'sjs-base-model'; 2 | import NameModel from './NameModel'; 3 | import IdModel from './IdModel'; 4 | import PictureModel from './PictureModel'; 5 | import DobModel from './DobModel'; 6 | 7 | /* 8 | // Returned Api Data Sample 9 | { 10 | "name": { 11 | "title": "mrs", 12 | "first": "frances", 13 | "last": "reynolds" 14 | }, 15 | "email": "frances.reynolds@example.com", 16 | "dob": { 17 | "date": "1966-03-19T23:24:27Z", 18 | "age": 52 19 | }, 20 | "phone": "01-1488-2236", 21 | "id": { 22 | "name": "TFN", 23 | "value": "406524662" 24 | }, 25 | "picture": { 26 | "large": "https://randomuser.me/api/portraits/women/50.jpg", 27 | "medium": "https://randomuser.me/api/portraits/med/women/50.jpg", 28 | "thumbnail": "https://randomuser.me/api/portraits/thumb/women/50.jpg" 29 | } 30 | } 31 | */ 32 | export default class UserModel extends BaseModel { 33 | 34 | public readonly name: NameModel = NameModel as any; 35 | public readonly email: string = ''; 36 | public readonly dob: DobModel = DobModel as any; 37 | public readonly phone: string = ''; 38 | public readonly id: IdModel = IdModel as any; 39 | public readonly picture: PictureModel = PictureModel as any; 40 | 41 | constructor(data: Partial) { 42 | super(); 43 | 44 | this.update(data); 45 | } 46 | 47 | public update(data: Partial): void { 48 | super.update(data); 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/stores/modal/ModalReducer.ts: -------------------------------------------------------------------------------- 1 | import ModalAction, {ModalActionUnion} from './ModalAction'; 2 | import IModalReducerState from './IModalReducerState'; 3 | import IAction from '../IAction'; 4 | 5 | export default class ModalReducer { 6 | 7 | private static readonly _initialState: IModalReducerState = { 8 | currentModal: null, 9 | modals: [], 10 | }; 11 | 12 | public static reducer(state: IModalReducerState = ModalReducer._initialState, action: IAction): IModalReducerState { 13 | switch (action.type) { 14 | case ModalAction.ADD_MODAL: 15 | const modal: JSX.Element = action.payload as JSX.Element; 16 | 17 | return { 18 | ...state, 19 | currentModal: modal, 20 | modals: [...state.modals, modal], 21 | }; 22 | case ModalAction.REMOVE_MODAL: 23 | const currentModal: JSX.Element = state.currentModal; 24 | const modalIndex: number = state.modals.indexOf(currentModal); 25 | const modals = [ 26 | ...state.modals.slice(0, modalIndex), 27 | ...state.modals.slice(modalIndex + 1), 28 | ]; 29 | const previousModal: JSX.Element = modals[modals.length - 1]; 30 | 31 | return { 32 | ...state, 33 | currentModal: previousModal || null, 34 | modals, 35 | }; 36 | default: 37 | return state; 38 | } 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/utilities/ProviderUtility.ts: -------------------------------------------------------------------------------- 1 | import {applyMiddleware, createStore, Middleware} from 'redux'; 2 | import {routerMiddleware} from 'connected-react-router'; 3 | import {History} from 'history'; 4 | import rootReducer from '../stores/rootReducer'; 5 | import {composeWithDevTools} from 'redux-devtools-extension/logOnlyInProduction'; 6 | import createSagaMiddleware, {END, SagaMiddleware} from 'redux-saga'; 7 | import rootSaga from '../stores/rootSaga'; 8 | import IStore from '../stores/IStore'; 9 | import ISagaStore from '../stores/ISagaStore'; 10 | import reduxFreeze from 'redux-freeze'; 11 | 12 | const isProduction: boolean = process.env.NODE_ENV === 'production'; 13 | 14 | export default class ProviderUtility { 15 | 16 | public static createProviderStore(initialState: Partial = {}, history: History = null, isServerSide: boolean = false): ISagaStore { 17 | const sagaMiddleware: SagaMiddleware = createSagaMiddleware(); 18 | 19 | const middleware: Middleware[] = [ 20 | (isProduction || isServerSide) ? null : reduxFreeze, 21 | routerMiddleware(history), 22 | sagaMiddleware, 23 | ].filter(Boolean); 24 | 25 | const store: any = createStore( 26 | rootReducer(history), 27 | initialState, 28 | composeWithDevTools( 29 | applyMiddleware(...middleware), 30 | ), 31 | ); 32 | 33 | if (isServerSide) { 34 | store.runSaga = sagaMiddleware.run; 35 | store.endSaga = () => store.dispatch(END); 36 | } else { 37 | sagaMiddleware.run(rootSaga); 38 | } 39 | 40 | return store; 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/server/plugin/HapiWebpackHotPlugin.ts: -------------------------------------------------------------------------------- 1 | import * as Webpack from 'webpack'; 2 | import * as HapiWebpackPlugin from 'hapi-webpack-plugin'; 3 | import * as notifier from 'node-notifier'; 4 | import * as Hapi from 'hapi'; 5 | import ServerManager from '../ServerManager'; 6 | 7 | export default class HapiWebpackHotPlugin { 8 | 9 | public get plugin(): Hapi.ServerRegisterPluginObject { 10 | const config: Webpack.Configuration = require('../../../webpack.config.js'); // tslint:disable-line no-require-imports 11 | const compiler: Webpack.Compiler = Webpack(config); 12 | 13 | compiler.hooks.done.tap('BuildStatsPlugin', (stats: any) => this._onDone(stats)); 14 | 15 | const assets = { 16 | // webpack-dev-middleware options - See https://github.com/webpack/webpack-dev-middleware 17 | index: '/public/index.html', 18 | }; 19 | 20 | const hot = { 21 | // webpack-hot-middleware options - See https://github.com/glenjamin/webpack-hot-middleware 22 | }; 23 | 24 | return { 25 | plugin: HapiWebpackPlugin, 26 | options: {compiler, assets, hot}, 27 | }; 28 | } 29 | 30 | private _onDone(stats: any): void { 31 | const pkg = require('../../../package.json'); // tslint:disable-line no-require-imports 32 | const time: string = ((stats.endTime - stats.startTime) / 1000).toFixed(2); 33 | 34 | setTimeout(() => { 35 | ServerManager.log(); 36 | }, 0); 37 | 38 | notifier.notify({ 39 | title: pkg.name, 40 | message: `WebPack is done!\n${stats.compilation.errors.length} errors in ${time}s`, 41 | timeout: 2, 42 | } as any); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/utilities/HttpUtility.ts: -------------------------------------------------------------------------------- 1 | import axios, {AxiosResponse } from 'axios'; 2 | import RequestMethodEnum from '../constants/RequestMethodEnum'; 3 | 4 | // http://httpstat.us 5 | export default class HttpUtility { 6 | 7 | public async get(endpoint: string): Promise> { 8 | const request = new Request(endpoint, { 9 | method: RequestMethodEnum.Get, 10 | }); 11 | 12 | return this._fetch(request); 13 | } 14 | 15 | // TODO: finish setting up 16 | public async post(endpoint: string): Promise> { 17 | const request = new Request(endpoint, { 18 | method: RequestMethodEnum.Post, 19 | }); 20 | 21 | return this._fetch(request); 22 | } 23 | 24 | // TODO: finish setting up 25 | public async put(endpoint: string): Promise> { 26 | const request = new Request(endpoint, { 27 | method: RequestMethodEnum.Put, 28 | }); 29 | 30 | return this._fetch(request); 31 | } 32 | 33 | // TODO: finish setting up 34 | public async delete(endpoint: string): Promise> { 35 | const request = new Request(endpoint, { 36 | method: RequestMethodEnum.Delete, 37 | }); 38 | 39 | return this._fetch(request); 40 | } 41 | 42 | private async _fetch(request: Request, init?: any): Promise> { 43 | try { 44 | return await axios({ 45 | data: init, 46 | method: request.method, 47 | url: request.url, 48 | }); 49 | } catch (error) { 50 | console.error(`error`, error); 51 | 52 | return error; 53 | } 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/assets/styles/styles.scss: -------------------------------------------------------------------------------- 1 | /* I did not write this code */ 2 | 3 | body { 4 | padding-top: 1.5rem; 5 | padding-bottom: 1.5rem; 6 | } 7 | 8 | /* Everything but the jumbotron gets side spacing for mobile first views */ 9 | .header, 10 | .marketing, 11 | .footer { 12 | padding-right: 1rem; 13 | padding-left: 1rem; 14 | } 15 | 16 | /* Custom page header */ 17 | .header { 18 | padding-bottom: 1rem; 19 | border-bottom: .05rem solid #e5e5e5; 20 | } 21 | 22 | /* Make the masthead heading the same height as the navigation */ 23 | .header h3 { 24 | margin-top: 0; 25 | margin-bottom: 0; 26 | line-height: 3rem; 27 | } 28 | 29 | /* Custom page footer */ 30 | .footer { 31 | padding-top: 1.5rem; 32 | color: #777; 33 | border-top: .05rem solid #e5e5e5; 34 | } 35 | 36 | .container-narrow > hr { 37 | margin: 2rem 0; 38 | } 39 | 40 | /* Main marketing message and sign up button */ 41 | .jumbotron { 42 | text-align: center; 43 | border-bottom: .05rem solid #e5e5e5; 44 | } 45 | 46 | .jumbotron .btn { 47 | padding: .75rem 1.5rem; 48 | font-size: 1.5rem; 49 | } 50 | 51 | /* Supporting marketing content */ 52 | .marketing { 53 | margin: 3rem 0; 54 | } 55 | 56 | .marketing p + h4 { 57 | margin-top: 1.5rem; 58 | } 59 | 60 | /* Responsive: Portrait tablets and up */ 61 | @media screen and (min-width: 48em) { 62 | /* Remove the padding we set earlier */ 63 | .header, 64 | .marketing, 65 | .footer { 66 | padding-right: 0; 67 | padding-left: 0; 68 | } 69 | 70 | /* Space out the masthead */ 71 | .header { 72 | margin-bottom: 2rem; 73 | } 74 | 75 | /* Remove the bottom border on the jumbotron for visual effect */ 76 | .jumbotron { 77 | border-bottom: 0; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/server/ServerManager.ts: -------------------------------------------------------------------------------- 1 | import * as Hapi from 'hapi'; 2 | import IController from './controllers/IController'; 3 | 4 | export default class ServerManager { 5 | 6 | public static readonly PORT: number = parseInt(process.env.PORT, 10) || 3000; 7 | public static readonly HOST: string = process.env.HOST || '0.0.0.0'; 8 | public static readonly NODE_ENV: string = process.env.NODE_ENV; 9 | 10 | public isDevelopment: boolean = (ServerManager.NODE_ENV !== 'production'); 11 | 12 | private _server: Hapi.Server = null; 13 | 14 | public get server(): Hapi.Server { 15 | return this._server; 16 | } 17 | 18 | constructor() { 19 | const options: Hapi.ServerOptions = { 20 | host: ServerManager.HOST, 21 | port: ServerManager.PORT, 22 | }; 23 | 24 | this._server = new Hapi.Server(options); 25 | } 26 | 27 | public static log(): void { 28 | console.info(`\n\nServer running in ${ServerManager.NODE_ENV} mode at: http://${ServerManager.HOST}:${ServerManager.PORT}\n`); 29 | } 30 | 31 | public async registerPlugin(pluginConfig: any): Promise { 32 | await this._server.register(pluginConfig); 33 | } 34 | 35 | public registerController(controller: IController): void { 36 | controller.mapRoutes(this._server); 37 | } 38 | 39 | public async startServer(): Promise { 40 | process.on('unhandledRejection', (error: Error) => { 41 | console.error(error); 42 | process.exit(1); 43 | }); 44 | 45 | try { 46 | await this._server.start(); 47 | 48 | if (!this.isDevelopment) { 49 | ServerManager.log(); 50 | } 51 | } catch (err) { 52 | console.error(err); 53 | } 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `typescript-hapi-react-hot-loader-example` 2 | 3 | TypeScript example universal/isomorphic application demonstrating react-hot-loader-4 with webpack-4, react and friends, async code splitting, and hapi. 4 | 5 | > [Check out the JavaScript version here](https://github.com/codeBelt/hapi-react-hot-loader-example#no-longer-maintained-i-will-continue-to-maintain-the-typescript-version) 6 | 7 | > Found this usefull? give it a :star: 8 | 9 | **Windows users see the last section of this README** 10 | 11 | ## get started 12 | 13 | 1. \$ `yarn` 14 | 2. \$ `yarn dev` 15 | 3. http://localhost:3000 16 | 17 | ## dev tasks 18 | 19 | - \$ `yarn lint` (eslint) 20 | - \$ `yarn dev` (local development w/ server) 21 | 22 | > Type `rs` with a carriage return to restart nodemon if you make changes to the `server.js` file or any files within the `server` folder. It's not efficient to automatically restart nodemon on file changes. 23 | 24 | ###### Note: Saga's do not hot load. You will have to reload the browser. [Read more about potential issues](https://github.com/redux-saga/redux-saga/issues/22#issuecomment-218737951) and/or [implement yourself](https://gist.github.com/markerikson/dc6cee36b5b6f8d718f2e24a249e0491). 25 | 26 | ## production tasks 27 | 28 | - \$ `yarn prod` (production build w/ server) 29 | - \$ `yarn prod:build` (production build) 30 | 31 | ## staging tasks 32 | 33 | - \$ `yarn local` (local build w/ server) 34 | - \$ `yarn local:build` (local build) 35 | 36 | --- 37 | 38 | ##### Other features/examples I am working: 39 | 40 | - Jest / Enzyme 41 | 42 | ## Window Users 43 | 44 | Use \$ `yarn devWindows` during development. Note `rs` to restart nodemon will not work on windows. 45 | 46 | If you want `rs` to restart nodemon you will need to run `yarn watchServer` and `yarn devServer` in two separate terminals. 47 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:latest", "tslint-react"], 3 | "rules": { 4 | "arrow-parens": true, 5 | "arrow-return-shorthand": [true], 6 | "comment-format": [true], 7 | "curly": true, 8 | "import-blacklist": [true, "rxjs"], 9 | "interface-over-type-literal": true, 10 | "jsx-no-multiline-js": false, 11 | "max-line-length": [false], 12 | "member-access": true, 13 | "member-ordering": [true, {"order": "fields-first"}], 14 | "newline-before-return": true, 15 | "no-any": false, 16 | "no-bitwise": true, 17 | "no-conditional-assignment": true, 18 | "no-console": [true, "log"], 19 | "no-duplicate-variable": true, 20 | "no-empty-interface": false, 21 | "no-inferrable-types": [false], 22 | "no-invalid-this": [true, "check-function-in-method"], 23 | "no-null-keyword": false, 24 | "no-parameter-reassignment": true, 25 | "no-require-imports": true, 26 | "no-submodule-imports": false, 27 | "no-switch-case-fall-through": true, 28 | "no-this-assignment": [true, {"allow-destructuring": true}], 29 | "no-trailing-whitespace": true, 30 | "no-var-keyword": true, 31 | "object-literal-shorthand": true, 32 | "object-literal-sort-keys": false, 33 | "one-variable-per-declaration": [true], 34 | "only-arrow-functions": [true, "allow-declarations"], 35 | "ordered-imports": [false], 36 | "prefer-conditional-expression": [true, "check-else-if"], 37 | "prefer-method-signature": false, 38 | "prefer-template": [true, "allow-single-concat"], 39 | "quotemark": [true, "single", "jsx-double"], 40 | "semicolon": [true], 41 | "triple-equals": [true, "allow-null-check"], 42 | "typedef": [true,"parameter", "property-declaration"], 43 | "variable-name": [true, "ban-keywords", "check-format", "allow-pascal-case", "allow-leading-underscore"] 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/views/landmarks/Header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {NavLink} from 'react-router-dom'; 3 | 4 | interface IProps {} 5 | interface IState {} 6 | 7 | export default class Header extends React.Component { 8 | 9 | public render(): JSX.Element { 10 | return ( 11 |
12 | 44 |

{'Star My Github Repo!'}

45 |
46 | ); 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/views/components/InputField.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as classNames from 'classnames'; 3 | 4 | interface IProps { 5 | hideLabel?: boolean; 6 | name?: string; 7 | id: string; 8 | isInline?: boolean; 9 | label?: string; 10 | size?: number; 11 | defaultValue?: string; 12 | type?: string; 13 | isRequired?: boolean; 14 | step?: string; 15 | pattern?: string; 16 | onChangeHandler?: (event: React.ChangeEvent) => void; 17 | } 18 | interface IState {} 19 | 20 | export default class InputField extends React.Component { 21 | 22 | public static defaultProps: Partial = { 23 | type: 'text', 24 | }; 25 | 26 | public render(): JSX.Element { 27 | return ( 28 |
29 | {this.props.label && ( 30 | 36 | )} 37 | 49 |
50 | ); 51 | } 52 | 53 | private _buildClassNames(): string { 54 | return classNames({ 55 | inputField: true, 56 | inputField_inline: this.props.isInline, 57 | inputField_noLabel: this.props.hideLabel, 58 | }); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/RouterWrapper.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {Provider} from 'react-redux'; 3 | import {ConnectedRouter} from 'connected-react-router'; 4 | import {History} from 'history'; 5 | import {Redirect, Route, StaticRouter, Switch} from 'react-router-dom'; 6 | import AboutAsync from './views/about/AboutAsync'; 7 | import Home from './views/home/Home'; 8 | import Contact from './views/contact/Contact'; 9 | import FooterAsync from './views/landmarks/FooterAsync'; 10 | import Header from './views/landmarks/Header'; 11 | import NotFoundAsync from './views/errors/NotFoundAsync'; 12 | import ISagaStore from './stores/ISagaStore'; 13 | import ModalHub from './views/modals/ModalHub'; 14 | 15 | interface IProviderWrapperProps { 16 | store: ISagaStore; 17 | isServerSide: boolean; 18 | location?: string; 19 | context?: any; 20 | history?: History; 21 | } 22 | 23 | const RouterWrapper: React.StatelessComponent = (props: IProviderWrapperProps): JSX.Element => { 24 | const Router: any = props.isServerSide ? StaticRouter : ConnectedRouter; 25 | const history: History = props.isServerSide ? null : props.history; 26 | 27 | return ( 28 | 29 | 34 |
35 |
36 | 37 | 42 | 46 | 50 | 54 | 55 | 56 | 57 | 58 |
59 |
60 |
61 | ); 62 | }; 63 | 64 | export default RouterWrapper; 65 | -------------------------------------------------------------------------------- /src/client.tsx: -------------------------------------------------------------------------------- 1 | import 'bootstrap/dist/css/bootstrap.css'; 2 | import './assets/styles/screen.scss'; 3 | 4 | import 'fetch-everywhere'; 5 | import * as bootstrap from 'react-async-bootstrapper'; 6 | import * as React from 'react'; 7 | import * as ReactDOM from 'react-dom'; 8 | import {createBrowserHistory, History} from 'history'; 9 | import {AppContainer as ReactHotLoader} from 'react-hot-loader'; 10 | import {AsyncComponentProvider} from 'react-async-component'; 11 | import RouterWrapper from './RouterWrapper'; 12 | import ProviderUtility from './utilities/ProviderUtility'; 13 | import IStore from './stores/IStore'; 14 | import ISagaStore from './stores/ISagaStore'; 15 | import rootReducer from './stores/rootReducer'; 16 | import UserModel from './stores/user/models/UserModel'; 17 | 18 | (async (window: Window) => { 19 | 20 | const codeSplittingState = window.__ASYNC_COMPONENTS_STATE__; 21 | const serverState: IStore = window.__STATE__; 22 | const initialState: IStore = { 23 | ...serverState, 24 | renderReducer: { 25 | ...serverState.renderReducer, 26 | isServerSide: false, 27 | }, 28 | userReducer: { 29 | ...serverState.userReducer, 30 | currentUser: new UserModel(serverState.userReducer.currentUser), // Fixes propTypes validation issue 31 | }, 32 | }; 33 | 34 | const history: History = createBrowserHistory(); 35 | const store: ISagaStore = ProviderUtility.createProviderStore(initialState, history); 36 | const rootEl: HTMLElement = document.getElementById('root'); 37 | 38 | delete window.__STATE__; 39 | delete window.__ASYNC_COMPONENTS_STATE__; 40 | 41 | const composeApp = (Component: any) => ( 42 | 43 | 44 | 48 | 49 | 50 | ); 51 | 52 | const renderApp = () => { 53 | const routerWrapper = require('./RouterWrapper').default; // tslint:disable-line:no-require-imports 54 | 55 | ReactDOM.hydrate( 56 | composeApp(routerWrapper), 57 | rootEl, 58 | ); 59 | }; 60 | 61 | bootstrap(composeApp(RouterWrapper)).then(renderApp); 62 | 63 | if (module.hot) { 64 | // Reload components 65 | module.hot.accept('./RouterWrapper', renderApp); 66 | 67 | // Reload reducers 68 | module.hot.accept('./stores/rootReducer', () => { 69 | store.replaceReducer(rootReducer(history)); 70 | }); 71 | } 72 | 73 | })(window); 74 | -------------------------------------------------------------------------------- /src/views/modals/BaseModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {Dispatch} from 'redux'; 4 | import IAction from '../../stores/IAction'; 5 | import IStore from '../../stores/IStore'; 6 | import ModalAction from '../../stores/modal/ModalAction'; 7 | import KeyboardKeyEnum from '../../constants/KeyboardKeyEnum'; 8 | import * as classNames from 'classnames'; 9 | 10 | interface IProps { 11 | isRequired?: boolean; 12 | } 13 | interface IState {} 14 | interface IStateToProps {} 15 | interface IDispatchToProps { 16 | dispatch: (action: IAction) => void; 17 | } 18 | 19 | const mapStateToProps = (state: IStore) => ({}); 20 | const mapDispatchToProps = (dispatch: Dispatch>): IDispatchToProps => ({ 21 | dispatch, 22 | }); 23 | 24 | type PropsUnion = IStateToProps & IDispatchToProps & IProps; 25 | 26 | class BaseModal extends React.Component { 27 | 28 | public static defaultProps: Partial = { 29 | isRequired: false, 30 | }; 31 | 32 | public componentDidMount(): void { 33 | if (!this.props.isRequired) { 34 | global.window.addEventListener('keydown', this._onKeyDownModal); 35 | } 36 | } 37 | 38 | public componentWillUnmount(): void { 39 | if (!this.props.isRequired) { 40 | global.window.removeEventListener('keydown', this._onKeyDownModal); 41 | } 42 | } 43 | 44 | public render(): JSX.Element { 45 | return ( 46 |
51 |
55 | {this.props.children} 56 |
57 | ); 58 | } 59 | 60 | private _onClickOverlay = (event: React.MouseEvent): void => { 61 | if (!this.props.isRequired) { 62 | this.props.dispatch(ModalAction.closeModal()); 63 | } 64 | } 65 | 66 | private _onKeyDownModal = (event: KeyboardEvent): void => { 67 | if (event.key === KeyboardKeyEnum.ESCAPE) { 68 | event.preventDefault(); 69 | 70 | this.props.dispatch(ModalAction.closeModal()); 71 | } 72 | } 73 | 74 | private _buildModalOverlayClassNames = (): string => { 75 | return classNames({ 76 | 'modal-overlay': true, 77 | 'modal-overlay_required': this.props.isRequired, 78 | }); 79 | } 80 | 81 | } 82 | 83 | export default connect(mapStateToProps, mapDispatchToProps)(BaseModal); 84 | -------------------------------------------------------------------------------- /src/views/about/About.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {connect} from 'react-redux'; 3 | import MetaAction from '../../stores/meta/MetaAction'; 4 | import IStore from '../../stores/IStore'; 5 | import {Dispatch} from 'redux'; 6 | import IAction from '../../stores/IAction'; 7 | 8 | interface IState {} 9 | interface IProps {} 10 | interface IStateToProps {} 11 | interface IDispatchToProps { 12 | dispatch: (action: IAction) => void; 13 | } 14 | 15 | const mapStateToProps = (state: IStore): IStateToProps => ({}); 16 | const mapDispatchToProps = (dispatch: Dispatch>): IDispatchToProps => ({ 17 | dispatch, 18 | }); 19 | 20 | class About extends React.Component { 21 | 22 | public componentWillMount(): void { 23 | this.props.dispatch(MetaAction.setMeta({title: 'About Page'})); 24 | } 25 | 26 | public render(): JSX.Element { 27 | return ( 28 |
29 |
30 |

{'About'}

31 |

{'This is a React Universal application that uses the libraries below.'}

32 |
33 | 34 |
35 |
36 |

{'Webpack 4'}

37 |

{'Facilitates creating builds for production, staging, and development.'}

38 | 39 |

{'React'}

40 |

{'Library to build user interfaces.'}

41 | 42 |

{'React Router 4'}

43 |

{'Adds routing to React'}

44 | 45 |

{'React Saga'}

46 |

{'Facilitates server side rendering and data fetching.'}

47 |
48 | 49 |
50 |

{'Redux'}

51 |

{'Manages data state in your application.'}

52 | 53 |

{'Redux Form'}

54 |

{'Manages form data in Redux and does validation.'}

55 | 56 |

{'React Hot Loader 4'}

57 |

{'Updates the browser with JavaScript and CSS changes without having to refresh the page.'}

58 | 59 |

{'Hapi'}

60 |

{'A node server framework.'}

61 |
62 |
63 |
64 | ); 65 | } 66 | 67 | } 68 | 69 | export default connect(mapStateToProps, mapDispatchToProps)(About); 70 | -------------------------------------------------------------------------------- /src/views/modals/GenericModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import BaseModal from './BaseModal'; 3 | import {connect} from 'react-redux'; 4 | import IAction from '../../stores/IAction'; 5 | import IStore from '../../stores/IStore'; 6 | import {Dispatch} from 'redux'; 7 | import ModalAction from '../../stores/modal/ModalAction'; 8 | 9 | export interface IProps { 10 | message: string; 11 | rejectLabel?: string; 12 | acceptLabel?: string; 13 | onReject?: (props: IProps) => void; 14 | onAccept?: (props: IProps) => void; 15 | isRequired?: boolean; 16 | data?: any; 17 | } 18 | interface IState {} 19 | interface IStateToProps {} 20 | interface IDispatchToProps { 21 | dispatch: (action: IAction) => void; 22 | } 23 | 24 | const mapStateToProps = (state: IStore) => ({}); 25 | const mapDispatchToProps = (dispatch: Dispatch>): IDispatchToProps => ({ 26 | dispatch, 27 | }); 28 | 29 | type PropsUnion = IStateToProps & IDispatchToProps & IProps; 30 | 31 | class GenericModal extends React.Component { 32 | 33 | public static defaultProps: Partial = { 34 | message: '', 35 | isRequired: false, 36 | }; 37 | 38 | public render(): JSX.Element { 39 | return ( 40 | 41 |
42 |
43 | {this.props.message} 44 |
45 |
46 | {this.props.rejectLabel && ( 47 | 52 | )} 53 | {this.props.acceptLabel && ( 54 | 59 | )} 60 |
61 |
62 |
63 | ); 64 | } 65 | 66 | private _onClickReject = (): void => { 67 | this.props.onReject(this.props); 68 | } 69 | 70 | private _onClickAccept = (): void => { 71 | this.props.onAccept(this.props); 72 | } 73 | 74 | private _onClickClose = (): void => { 75 | this.props.dispatch(ModalAction.closeModal()); 76 | } 77 | 78 | } 79 | 80 | export default connect(mapStateToProps, mapDispatchToProps)(GenericModal); 81 | -------------------------------------------------------------------------------- /src/assets/styles/components/modal.scss: -------------------------------------------------------------------------------- 1 | .modal { 2 | display: block; 3 | bottom: 0; 4 | left: 0; 5 | padding-top: 5px; 6 | position: fixed; 7 | right: 0; 8 | top: 0; 9 | z-index: 6; 10 | } 11 | 12 | .modal-overlay { 13 | animation: modalOverlayFadeIn 300ms var(--EASE_OUT_CUBIC) forwards; 14 | background-color: rgba(0, 0, 0, 0.4); 15 | bottom: 0; 16 | cursor: pointer; 17 | left: 0; 18 | position: fixed; 19 | right: 0; 20 | top: 0; 21 | z-index: 7; 22 | } 23 | 24 | .modal-overlay_required { 25 | cursor: default; 26 | } 27 | 28 | .modal-content { 29 | animation: modalDropIn 300ms var(--EASE_OUT_CUBIC) forwards; 30 | background-color: #fefefe; 31 | border: 1px solid #888888; 32 | border-radius: 10px; 33 | box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); 34 | display: flex; 35 | flex-direction: column; 36 | font-size: 16px; 37 | margin: auto auto 5px; 38 | max-width: 800px; 39 | min-height: 250px; 40 | overflow: hidden; 41 | padding: 20px; 42 | position: relative; 43 | transform: translateY(-5px); 44 | width: calc(100% - 10px); 45 | z-index: 8; 46 | } 47 | 48 | .modal-content_sm { 49 | max-width: 440px; 50 | } 51 | 52 | .modal-content_md { 53 | max-width: 600px; 54 | } 55 | 56 | .modal-close { 57 | color: #000000; 58 | font-size: 28px; 59 | font-weight: bold; 60 | width: 28px; 61 | } 62 | 63 | .modal-close:active, 64 | .modal-close:hover, 65 | .modal-close:focus { 66 | color: #000000; 67 | cursor: pointer; 68 | text-decoration: none; 69 | } 70 | 71 | .modal-header { 72 | align-items: center; 73 | display: flex; 74 | justify-content: flex-end; 75 | margin-bottom: 10px; 76 | padding: 2px 16px; 77 | } 78 | 79 | .modal-header-icon { 80 | margin-right: 5px; 81 | width: 16px; 82 | } 83 | 84 | .modal-header_left { 85 | justify-content: flex-start; 86 | padding: 2px 16px; 87 | } 88 | 89 | .modal-header_center { 90 | justify-content: center; 91 | } 92 | 93 | .modal-body { 94 | flex: 1 0 auto; 95 | min-height: 160px; 96 | padding: 2px 16px; 97 | text-align: center; 98 | 99 | & p strong { 100 | font-weight: 700; 101 | } 102 | } 103 | 104 | .modal-body + .modal-footer { 105 | margin-top: 30px; 106 | } 107 | 108 | .modal-buttonspace { 109 | padding-bottom: 4px; 110 | padding-top: 4px; 111 | width: 100%; 112 | } 113 | 114 | .modal-footer { 115 | display: flex; 116 | justify-content: flex-end; 117 | padding: 0 16px; 118 | 119 | & > * { 120 | margin-right: 10px; 121 | 122 | &:last-child { 123 | margin-right: 0; 124 | } 125 | } 126 | } 127 | 128 | .modal-footer_stack { 129 | display: block; 130 | 131 | & > * { 132 | margin-bottom: 10px; 133 | width: 100%; 134 | 135 | @media (--XS) { 136 | margin-bottom: 0; 137 | width: auto; 138 | } 139 | 140 | &:last-child { 141 | margin-bottom: 0; 142 | } 143 | } 144 | } 145 | 146 | .modal-footer_block { 147 | & > * { 148 | @media (--XS) { 149 | width: 50%; 150 | } 151 | } 152 | } 153 | 154 | .loader ~ .modal-body { 155 | visibility: hidden; 156 | } 157 | 158 | @keyframes modalDropIn { 159 | to { 160 | opacity: 1; 161 | transform: translateY(0); 162 | } 163 | } 164 | 165 | @keyframes modalOverlayFadeIn { 166 | to { 167 | opacity: 1; 168 | } 169 | } 170 | 171 | -------------------------------------------------------------------------------- /src/server/controllers/ReactController.tsx: -------------------------------------------------------------------------------- 1 | import {renderToString} from 'react-dom/server'; 2 | import {AsyncComponentProvider, createAsyncContext} from 'react-async-component'; 3 | import * as bootstrap from 'react-async-bootstrapper'; 4 | import * as serialize from 'serialize-javascript'; 5 | import * as React from 'react'; 6 | import * as Hapi from 'hapi'; 7 | import RouterWrapper from '../../RouterWrapper'; 8 | import ProviderUtility from '../../utilities/ProviderUtility'; 9 | import ServerUtility from '../utilities/ServerUtility'; 10 | import rootSaga from '../../stores/rootSaga'; 11 | import ISagaStore from '../../stores/ISagaStore'; 12 | import IStore from '../../stores/IStore'; 13 | import IController from './IController'; 14 | import IRenderReducerState from '../../stores/render/IRenderReducerState'; 15 | import RequestMethodEnum from '../../constants/RequestMethodEnum'; 16 | import {createMemoryHistory, History} from 'history'; 17 | 18 | export default class ReactController implements IController { 19 | 20 | private _html: string = null; 21 | 22 | public mapRoutes(server: Hapi.Server): void { 23 | server.route({ 24 | method: RequestMethodEnum.Get, 25 | path: '/{route*}', 26 | handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit): Promise => { 27 | let initialState: Partial = {renderReducer: this._getRenderReducer(request)}; 28 | const history: History = createMemoryHistory(); 29 | const isServerSide: boolean = true; 30 | const store: ISagaStore = ProviderUtility.createProviderStore(initialState, history, isServerSide); 31 | const asyncContext: any = createAsyncContext(); 32 | const routeContext: any = {}; 33 | 34 | const app = ( 35 | 36 | 42 | 43 | ); 44 | 45 | this._html = (this._html === null) ? await ServerUtility.loadHtmlFile() : this._html; 46 | 47 | await bootstrap(app); 48 | 49 | const sagaDone: Promise = store.runSaga(rootSaga).done; 50 | 51 | renderToString(app); 52 | 53 | store.endSaga(); 54 | 55 | await sagaDone; 56 | 57 | if (routeContext.url) { 58 | return h.redirect(routeContext.url); 59 | } 60 | 61 | try { 62 | const renderedHtml: string = renderToString(app); 63 | const asyncComponentsState: IStore = asyncContext.getState(); 64 | const state: IStore = store.getState(); 65 | 66 | initialState = { 67 | ...state, 68 | renderReducer: this._getRenderReducer(request), 69 | }; 70 | 71 | const html: string = this._html 72 | .slice(0) 73 | .replace('{title}', initialState.metaReducer.title) 74 | .replace('{description}', initialState.metaReducer.description) 75 | .replace('{content}', renderedHtml) 76 | .replace('{state}', JSON.stringify(initialState)) 77 | .replace('{asyncComponentsState}', serialize(asyncComponentsState)); 78 | 79 | return h.response(html); 80 | } catch (error) { 81 | return error.toString(); 82 | } 83 | }, 84 | }); 85 | } 86 | 87 | private _getRenderReducer(request: Hapi.Request): IRenderReducerState { 88 | return { 89 | isServerSide: true, 90 | serverSideLocation: ServerUtility.createLocationObject(request), 91 | }; 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/views/home/Home.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {push} from 'connected-react-router'; 4 | import UserAction from '../../stores/user/UserAction'; 5 | import MetaAction from '../../stores/meta/MetaAction'; 6 | import IStore from '../../stores/IStore'; 7 | import {Dispatch} from 'redux'; 8 | import GenericModalAsync from '../modals/GenericModalAsync'; 9 | import ModalAction from '../../stores/modal/ModalAction'; 10 | import ExampleFormModalAsync from '../modals/ExampleFormModalAsync'; 11 | import IAction from '../../stores/IAction'; 12 | import {IProps as GenericModalProps} from '../modals/GenericModal'; 13 | import UserModel from '../../stores/user/models/UserModel'; 14 | import * as PropTypes from 'prop-types'; 15 | 16 | interface IState {} 17 | interface IProps {} 18 | interface IStateToProps { 19 | readonly user: UserModel; 20 | readonly isLoadingUser: boolean; 21 | } 22 | interface IDispatchToProps { 23 | dispatch: (action: IAction) => void; 24 | } 25 | 26 | const mapStateToProps = (state: IStore): IStateToProps => ({ 27 | user: state.userReducer.currentUser, 28 | isLoadingUser: state.userReducer.isLoadingUser, 29 | }); 30 | const mapDispatchToProps = (dispatch: Dispatch>): IDispatchToProps => ({ 31 | dispatch, 32 | }); 33 | 34 | type PropsUnion = IStateToProps | IProps; 35 | 36 | class Home extends React.Component { 37 | 38 | public static defaultProps: Partial = { 39 | // Need defaultProps so compiler doesn't complain about propTypes. 40 | }; 41 | 42 | public static propTypes: Partial = { 43 | isLoadingUser: PropTypes.bool.isRequired, 44 | user: PropTypes.instanceOf(UserModel), 45 | }; 46 | 47 | public componentWillMount(): void { 48 | this.props.dispatch(MetaAction.setMeta({ 49 | title: 'Home Page', 50 | description: 'This is the Home Page', 51 | })); 52 | } 53 | 54 | public render(): JSX.Element { 55 | const {user, isLoadingUser} = this.props; 56 | const showLoader: boolean = !user || isLoadingUser; 57 | 58 | return ( 59 |
60 |
61 | {!showLoader && ( 62 | <> 63 |

{user.name.title} {user.name.fullName}

64 | 69 | 70 | )} 71 | {showLoader && ( 72 |
73 | Loading ... 74 |
75 | )} 76 |

77 | 83 |

84 |
85 |
    86 |
  1. 87 |
  2. 88 |
  3. 89 |
90 |
91 | ); 92 | } 93 | 94 | private _loadUser = (event: React.MouseEvent): void => { 95 | event.preventDefault(); 96 | 97 | this.props.dispatch(UserAction.loadUser()); 98 | } 99 | 100 | private _onClickPushExample = (event: React.MouseEvent): void => { 101 | event.preventDefault(); 102 | 103 | this.props.dispatch(push('/About')); 104 | } 105 | 106 | private _onClickOpenModal = (event: React.MouseEvent): void => { 107 | event.preventDefault(); 108 | 109 | const genericModal: JSX.Element = ( 110 | 113 |

{'Generic Modal'}

114 |

{'Example of a generic modal. Used for simple messages.'}

115 |
116 | )} 117 | acceptLabel={'Open Another Modal'} 118 | rejectLabel={'Close'} 119 | onAccept={this._onAccept} 120 | /> 121 | ); 122 | 123 | this.props.dispatch(ModalAction.addModal(genericModal)); 124 | } 125 | 126 | private _onAccept = (modalProps: GenericModalProps): void => { 127 | const genericModal: JSX.Element = ( 128 | 131 |

{'Handles opening multiple modals.'}

132 | 133 | )} 134 | acceptLabel={'Ok'} 135 | /> 136 | ); 137 | 138 | this.props.dispatch(ModalAction.addModal(genericModal)); 139 | } 140 | 141 | private _onClickFormModal = (event: React.MouseEvent): void => { 142 | const formModal: JSX.Element = ( 143 | 144 | ); 145 | 146 | this.props.dispatch(ModalAction.addModal(formModal)); 147 | } 148 | 149 | } 150 | 151 | export default connect(mapStateToProps, mapDispatchToProps)(Home); 152 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-hapi-react-hot-loader-example", 3 | "version": "2.0.0", 4 | "description": "Simple React Hot Loading example with Hapi Server-side rendering", 5 | "engines": { 6 | "node": ">=10.11.0" 7 | }, 8 | "scripts": { 9 | "---------- HELPERS -----------------------------------------------------------------------------": "", 10 | "clean": "rimraf dist", 11 | "buildServer": "tsc -project tsconfig.server.json", 12 | "watchServer": "tsc -watch -project tsconfig.server.json", 13 | "removeAndBuildServer": "npm run clean && npm run buildServer", 14 | "build": "npm run prod:build", 15 | "start": "cross-env NODE_ENV=production node ./dist/server.js", 16 | "---------- DEVELOPMENT -------------------------------------------------------------------------": "", 17 | "predev": "npm run removeAndBuildServer", 18 | "dev": "npm run watchServer & npm run devServer", 19 | "devWindows": "concurrently --kill-others \"npm run watchServer\" \"npm run devServer\"", 20 | "devServer": "cross-env NODE_ENV=staging nodemon ./dist/server.js", 21 | "---------- LOCAL -----------------------------------------------------------------------------": "", 22 | "prelocal": "npm run removeAndBuildServer", 23 | "local": "npm run watchServer & npm run localServer", 24 | "localServer": "cross-env NODE_ENV=local nodemon ./dist/server.js", 25 | "---------- PRODUCTION --------------------------------------------------------------------------": "", 26 | "preprod:build": "npm run removeAndBuildServer", 27 | "prod:build": "cross-env NODE_ENV=production webpack", 28 | "prod": "npm run prod:build && npm run start", 29 | "---------- TESTING -----------------------------------------------------------------------------": "", 30 | "test": "npm run lint && npm run unit", 31 | "lint": "tslint 'src/**/*.ts{,x}' --exclude 'src/typings.d.ts'", 32 | "unit": "jest", 33 | "------------------------------------------------------------------------------------------------": "" 34 | }, 35 | "dependencies": { 36 | "axios": "0.18.0", 37 | "babel-polyfill": "6.26.0", 38 | "bootstrap": "4.3.1", 39 | "classnames": "2.2.6", 40 | "connected-react-router": "5.0.1", 41 | "fetch-everywhere": "1.0.5", 42 | "form2js": "1.0.0", 43 | "hapi": "17.8.1", 44 | "hapi-webpack-plugin": "3.0.0", 45 | "history": "4.7.2", 46 | "inert": "5.1.2", 47 | "module-alias": "2.1.0", 48 | "node-notifier": "5.3.0", 49 | "prop-types": "15.6.2", 50 | "react": "16.7.0", 51 | "react-async-bootstrapper": "2.1.1", 52 | "react-async-component": "2.0.0", 53 | "react-dom": "16.7.0", 54 | "react-hot-loader": "4.6.3", 55 | "react-redux": "5.1.1", 56 | "react-router-dom": "4.3.1", 57 | "redux": "4.0.1", 58 | "redux-devtools-extension": "2.13.7", 59 | "redux-form": "7.4.2", 60 | "redux-saga": "0.16.2", 61 | "serialize-javascript": "1.6.1", 62 | "sjs-base-model": "1.5.2", 63 | "webpack": "4.28.3" 64 | }, 65 | "devDependencies": { 66 | "@babel/cli": "7.2.3", 67 | "@babel/core": "7.2.2", 68 | "@babel/plugin-proposal-class-properties": "7.2.3", 69 | "@babel/plugin-proposal-object-rest-spread": "7.2.0", 70 | "@babel/plugin-syntax-dynamic-import": "7.2.0", 71 | "@babel/plugin-transform-runtime": "7.2.0", 72 | "@babel/polyfill": "7.2.5", 73 | "@babel/preset-env": "7.2.3", 74 | "@babel/preset-react": "7.0.0", 75 | "@babel/preset-typescript": "7.1.0", 76 | "@types/axios": "0.14.0", 77 | "@types/classnames": "2.2.6", 78 | "@types/hapi": "17.8.0", 79 | "@types/inert": "5.1.2", 80 | "@types/jest": "23.3.10", 81 | "@types/module-alias": "2.0.0", 82 | "@types/node": "10.12.18", 83 | "@types/node-notifier": "0.0.28", 84 | "@types/prop-types": "15.5.8", 85 | "@types/react": "16.7.11", 86 | "@types/react-dom": "16.0.11", 87 | "@types/react-redux": "6.0.11", 88 | "@types/react-router-dom": "4.3.1", 89 | "@types/redux": "3.6.0", 90 | "@types/redux-form": "7.4.13", 91 | "@types/serialize-javascript": "1.5.0", 92 | "@types/webpack": "4.4.22", 93 | "@types/webpack-env": "1.13.6", 94 | "autoprefixer": "9.4.3", 95 | "babel-loader": "8.0.4", 96 | "babel-plugin-transform-react-remove-prop-types": "0.4.21", 97 | "concurrently": "4.1.0", 98 | "copy-webpack-plugin": "4.6.0", 99 | "cross-env": "5.2.0", 100 | "css-hot-loader": "1.4.3", 101 | "css-loader": "2.1.0", 102 | "fork-ts-checker-webpack-plugin": "0.5.2", 103 | "html-webpack-harddisk-plugin": "1.0.1", 104 | "html-webpack-plugin": "3.2.0", 105 | "mini-css-extract-plugin": "0.5.0", 106 | "node-sass": "4.11.0", 107 | "nodemon": "1.18.9", 108 | "postcss-loader": "3.0.0", 109 | "redux-freeze": "0.1.7", 110 | "rimraf": "2.6.2", 111 | "robotstxt-webpack-plugin": "4.0.1", 112 | "sass-loader": "7.1.0", 113 | "source-map-explorer": "1.6.0", 114 | "stylelint": "9.9.0", 115 | "stylelint-config-standard": "18.2.0", 116 | "stylelint-order": "2.0.0", 117 | "tslib": "1.9.3", 118 | "tslint": "5.12.0", 119 | "tslint-react": "3.6.0", 120 | "typescript": "3.2.2", 121 | "webpack-bundle-analyzer": "3.0.3", 122 | "webpack-cli": "3.1.2", 123 | "webpack-dev-server": "3.1.14", 124 | "webpack-env": "0.8.0", 125 | "webpack-hot-middleware": "2.24.3", 126 | "webpack-simple-progress-plugin": "0.0.4", 127 | "write-file-webpack-plugin": "4.5.0" 128 | }, 129 | "repository": { 130 | "type": "git", 131 | "url": "https://github.com/codeBelt/typescript-hapi-react-hot-loader-example" 132 | }, 133 | "keywords": [ 134 | "hapi", 135 | "react", 136 | "reactjs", 137 | "boilerplate", 138 | "hot", 139 | "reload", 140 | "hmr", 141 | "live", 142 | "edit", 143 | "webpack" 144 | ], 145 | "author": "codeBelt", 146 | "license": "MIT" 147 | } 148 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | const SimpleProgressPlugin = require('webpack-simple-progress-plugin'); 7 | const HtmlWebpackHardDiskPlugin = require('html-webpack-harddisk-plugin'); 8 | const WriteFilePlugin = require('write-file-webpack-plugin'); 9 | const RobotstxtPlugin = require('robotstxt-webpack-plugin').default; 10 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 11 | const autoprefixer = require('autoprefixer'); 12 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 13 | const pkg = require('./package.json'); 14 | 15 | const PORT = process.env.PORT || 3000; 16 | const HOST = process.env.HOST || 'localhost'; 17 | const NODE_ENV = process.env.NODE_ENV || 'production'; 18 | const isProduction = (NODE_ENV === 'production'); 19 | const isDevelopment = !isProduction; 20 | const SRC_PATH = path.resolve(__dirname, 'src'); 21 | const DIST_PATH = path.resolve(__dirname, 'dist'); 22 | 23 | const webpackConfig = { 24 | mode: (NODE_ENV === 'production') ? 'production' : 'development', 25 | 26 | entry: isDevelopment 27 | ? [ 28 | '@babel/polyfill', 29 | 30 | `webpack-hot-middleware/client?path=http://${HOST}:${PORT}/__webpack_hmr`, // bundle the client for webpack-hot-middleware and connect to the provided endpoint 31 | 32 | './src/client.tsx', 33 | ] 34 | : [ 35 | '@babel/polyfill', 36 | 37 | './src/client.tsx', 38 | ], 39 | 40 | resolve: { 41 | extensions: ['.ts', '.tsx', '.js', '.json'], 42 | 43 | // Logic to load either the src/environments/production or src/environments/staging file in the app. 44 | alias: { 45 | environment: path.join(SRC_PATH, 'environments', 'production'), 46 | }, 47 | }, 48 | 49 | output: { 50 | path: path.join(DIST_PATH, 'public'), 51 | filename: isDevelopment 52 | ? 'assets/scripts/[name].js' 53 | : 'assets/scripts/[name].[hash].js', 54 | }, 55 | 56 | module: { 57 | rules: [ 58 | { 59 | test: /\.(ts|js)x?$/, 60 | loader: 'babel-loader', 61 | exclude: /node_modules/, 62 | }, 63 | { 64 | test: /\.s?css$/, 65 | use: [ 66 | 'css-hot-loader', 67 | MiniCssExtractPlugin.loader, 68 | { 69 | loader: 'css-loader', 70 | options: { 71 | sourceMap: !isProduction, 72 | }, 73 | }, 74 | { 75 | loader: 'postcss-loader', 76 | options: { 77 | sourceMap: !isProduction, 78 | }, 79 | }, 80 | { 81 | loader: 'sass-loader', 82 | options: { 83 | sourceMap: !isProduction, 84 | }, 85 | }, 86 | ], 87 | }, 88 | { 89 | test: /\.(svg)$/, 90 | use: [ 91 | { 92 | loader: 'file-loader', 93 | options: { 94 | name: '[name].[ext]', 95 | publicPath: '/assets/media/', 96 | }, 97 | }, 98 | ], 99 | include: path.join(__dirname, 'src'), 100 | }, 101 | ], 102 | }, 103 | 104 | plugins: [ 105 | new SimpleProgressPlugin(), 106 | 107 | new MiniCssExtractPlugin({ 108 | filename: isDevelopment 109 | ? 'assets/styles/[name].css' 110 | : 'assets/styles/[name].[hash].css', 111 | }), 112 | 113 | new webpack.LoaderOptionsPlugin({ 114 | options: { 115 | postcss: [autoprefixer()], 116 | }, 117 | }), 118 | 119 | isDevelopment 120 | ? new webpack.HotModuleReplacementPlugin() // enable HMR globally 121 | : null, 122 | 123 | isDevelopment 124 | ? null 125 | : new webpack.BannerPlugin(`${pkg.version} ${new Date().toString()}`), 126 | 127 | new HtmlWebpackPlugin({ 128 | template: path.join(SRC_PATH, 'index.html'), 129 | minify: isProduction ? {collapseWhitespace: true, collapseInlineTagWhitespace: true} : false, 130 | alwaysWriteToDisk: true, 131 | }), 132 | new HtmlWebpackHardDiskPlugin(), 133 | 134 | new CopyWebpackPlugin([ 135 | { 136 | context: 'src/assets', 137 | from: '**/*', 138 | to: 'assets', 139 | ignore: ['styles/**/*'], 140 | }, 141 | ]), 142 | 143 | new RobotstxtPlugin({ 144 | policy: [ 145 | isProduction 146 | ? {userAgent: '*', allow: '/'} 147 | : {userAgent: '*', disallow: '/'}, 148 | ], 149 | }), 150 | 151 | new ForkTsCheckerWebpackPlugin(), 152 | 153 | new WriteFilePlugin(), // Forces webpack-dev-server to write files. 154 | 155 | // new BundleAnalyzerPlugin(), 156 | ].filter(Boolean), 157 | 158 | optimization: { 159 | runtimeChunk: 'single', 160 | splitChunks: { 161 | cacheGroups: { 162 | vendors: { 163 | test: /[\\/]node_modules[\\/]/, 164 | name: 'vendors', 165 | chunks: 'all', 166 | enforce: true, 167 | }, 168 | }, 169 | }, 170 | }, 171 | 172 | devtool: isProduction 173 | ? 'none' 174 | : 'source-map', 175 | 176 | performance: { 177 | maxAssetSize: 500000, 178 | }, 179 | }; 180 | 181 | module.exports = webpackConfig; 182 | -------------------------------------------------------------------------------- /src/views/modals/ExampleFormModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {form2js} from 'form2js'; 3 | import InputField from '../components/InputField'; 4 | import {connect} from 'react-redux'; 5 | import IAction from '../../stores/IAction'; 6 | import IStore from '../../stores/IStore'; 7 | import {Dispatch} from 'redux'; 8 | import ModalAction from '../../stores/modal/ModalAction'; 9 | import BaseModal from './BaseModal'; 10 | 11 | export interface IProps { 12 | isRequired: boolean; 13 | data: any; 14 | } 15 | interface IState {} 16 | interface IStateToProps {} 17 | interface IDispatchToProps { 18 | dispatch: (action: IAction) => void; 19 | } 20 | 21 | const mapStateToProps = (state: IStore) => ({}); 22 | const mapDispatchToProps = (dispatch: Dispatch>): IDispatchToProps => ({ 23 | dispatch, 24 | }); 25 | 26 | type PropsUnion = IStateToProps & IDispatchToProps & IProps; 27 | 28 | class ExampleFormModal extends React.Component { 29 | 30 | public static defaultProps: Partial = { 31 | isRequired: false, 32 | }; 33 | 34 | private _formElement: HTMLFormElement = null; 35 | 36 | public render(): JSX.Element { 37 | return ( 38 | 39 |
40 |

{'Modal Form Title'}

41 |
42 | {this._buildFormJsx()} 43 |
44 |
45 | 48 | 51 |
52 |
53 |
54 | ); 55 | } 56 | 57 | private _buildFormJsx(): JSX.Element { 58 | return ( 59 |
{ 64 | this._formElement = element; 65 | }} 66 | > 67 |

68 | {'This is an example of a custom modal. The "isRequired" attribute is set to "true" which prevents the user from clicking the esc key or click outside of the modal to close it. It also has form validation.'} 69 |

70 |
71 | 77 |
78 | 83 |
84 |
85 |
86 | 92 |
93 | 100 |
101 |
102 |
103 | 109 |
110 | 116 |
117 |
118 |
119 | 125 |
126 | 130 |
131 |
132 |
133 | ); 134 | } 135 | 136 | private _onClickAccept = (event: React.MouseEvent): void => { 137 | event.preventDefault(); 138 | 139 | if (this._formElement.checkValidity()) { 140 | const formData: any = form2js(this._formElement, '.', false); 141 | 142 | console.info(formData); 143 | 144 | this._onClickClose(); 145 | } else { 146 | this._formElement.classList.remove('u-validate'); 147 | } 148 | } 149 | 150 | private _onClickClose = (event: React.MouseEvent = null): void => { 151 | this.props.dispatch(ModalAction.closeModal()); 152 | } 153 | 154 | } 155 | 156 | export default connect(mapStateToProps, mapDispatchToProps)(ExampleFormModal); 157 | -------------------------------------------------------------------------------- /src/views/contact/ContactForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {FormErrors, InjectedFormProps, reduxForm} from 'redux-form'; 3 | import IContactForm from './IContactForm'; 4 | import CustomField from './CustomField'; 5 | 6 | interface IProps extends InjectedFormProps {} 7 | 8 | class ContactForm extends React.Component { 9 | 10 | public render(): JSX.Element { 11 | const {handleSubmit, reset} = this.props; 12 | 13 | return ( 14 |
15 |
16 | 23 |
24 |
25 | 32 | 36 | {'We\'ll never share your email with anyone else.'} 37 | 38 |
39 |
40 | 45 |
46 |
47 | 53 |
54 |
55 | {'Code Quality'} 56 | 57 | 64 | 70 | 77 |
78 |
79 | 85 |
86 | 92 | 99 |
100 | ); 101 | } 102 | 103 | private _onSubmit = (formData: IContactForm): void => { 104 | console.info(formData); 105 | 106 | window.alert(JSON.stringify(formData, null, 2)); 107 | } 108 | 109 | private _renderInputField(field: any): JSX.Element { 110 | const {meta: {touched, error}} = field; 111 | const className: string = `small text-danger ${touched && error ? '' : 'd-none'}`; 112 | 113 | return ( 114 |
115 | 118 | 125 |
126 | ); 127 | } 128 | 129 | private _renderCheckbox(field: any): JSX.Element { 130 | return ( 131 | 142 | ); 143 | } 144 | 145 | private _renderRadio(field: any): JSX.Element { 146 | return ( 147 |
148 | 164 |
165 | ); 166 | } 167 | 168 | private _renderTextArea(field: any): JSX.Element { 169 | const {meta: {touched, error}} = field; 170 | const className: string = `small text-danger ${touched && error ? '' : 'd-none'}`; 171 | 172 | return ( 173 |
174 | 177 |