├── src ├── assets │ ├── languages │ │ ├── whitelists │ │ │ ├── whitelist_es.json │ │ │ ├── whitelist_pt.json │ │ │ └── whitelist_en.json │ │ ├── en.json │ │ ├── es.json │ │ └── pt.json │ ├── hello-world.json │ ├── styles │ │ ├── base │ │ │ ├── _spacing.scss │ │ │ ├── mixins │ │ │ │ ├── _font-icons.scss │ │ │ │ ├── _reset.scss │ │ │ │ ├── _convert-unit-values.scss │ │ │ │ ├── _spacing.scss │ │ │ │ ├── _aspect-ratio.scss │ │ │ │ ├── _colors.scss │ │ │ │ ├── _breakpoints.scss │ │ │ │ └── _typography.scss │ │ │ ├── _mixins.scss │ │ │ ├── _colors.scss │ │ │ ├── _z-index.scss │ │ │ ├── _typography.scss │ │ │ ├── _reset.scss │ │ │ └── _html-elements.scss │ │ ├── global.scss │ │ ├── _base.scss │ │ ├── _custom.scss │ │ ├── custom │ │ │ ├── _links.scss │ │ │ └── _default-theme.scss │ │ └── _skin.scss │ ├── images │ │ ├── gatsby-icon.png │ │ └── gatsby-astronaut.png │ └── icons │ │ ├── hamburger_icon.svg │ │ ├── check_mark_icon.svg │ │ ├── heart_icon.svg │ │ ├── search_icon.svg │ │ ├── close_icon.svg │ │ └── threedots_icon.svg ├── store │ ├── state │ │ ├── language │ │ │ ├── index.ts │ │ │ ├── selectors.ts │ │ │ ├── actions.ts │ │ │ ├── definitions.ts │ │ │ ├── operations.ts │ │ │ ├── reducers.ts │ │ │ └── persistTransform.ts │ │ ├── photos │ │ │ ├── index.ts │ │ │ ├── actions.ts │ │ │ ├── selectors.ts │ │ │ ├── definitions.ts │ │ │ ├── operations.ts │ │ │ └── reducers.ts │ │ ├── index.ts │ │ └── persistConfig.ts │ ├── definitions.ts │ ├── helpers │ │ ├── definitions.ts │ │ └── createReducer.ts │ └── index.ts ├── data │ ├── schemas │ │ └── JsonPlaceholder │ │ │ └── index.ts │ ├── api │ │ └── photos │ │ │ ├── definitions.ts │ │ │ └── index.ts │ └── models │ │ └── Photos │ │ └── index.ts ├── components │ ├── ui │ │ ├── Tiles │ │ │ ├── _styles.scss │ │ │ ├── index.tsx │ │ │ └── Tile │ │ │ │ └── index.tsx │ │ ├── ImageCard │ │ │ ├── _styles.scss │ │ │ └── index.tsx │ │ └── FontIcon │ │ │ └── index.tsx │ ├── Layout │ │ ├── _styles.scss │ │ ├── Header │ │ │ ├── index.tsx │ │ │ └── _styles.scss │ │ ├── Footer │ │ │ ├── index.tsx │ │ │ └── _styles.scss │ │ └── index.tsx │ ├── Wrapper │ │ ├── Root │ │ │ └── index.tsx │ │ ├── Redux │ │ │ └── index.tsx │ │ └── Intl │ │ │ └── index.tsx │ ├── LanguageSelector │ │ ├── _styles.scss │ │ └── index.tsx │ ├── GatsbyAstronoutImage │ │ └── index.tsx │ └── SEO │ │ └── index.tsx ├── domains │ └── Photos │ │ ├── View │ │ ├── _styles.scss │ │ └── index.tsx │ │ ├── Card │ │ ├── _styles.scss │ │ └── index.tsx │ │ ├── List │ │ ├── _styles.scss │ │ └── index.tsx │ │ └── index.tsx ├── pages │ ├── photos.tsx │ ├── 404.tsx │ ├── page-2.tsx │ └── index.tsx ├── helpers │ ├── fetch │ │ ├── readImageBlobAsDataURL.ts │ │ ├── buildCancelableRequest.ts │ │ ├── pagination.ts │ │ └── buildRequest.ts │ ├── cssVariableTransform.ts │ ├── console.ts │ ├── __tests__ │ │ ├── console.ts │ │ ├── check.tsx │ │ ├── arrays.ts │ │ └── values.ts │ ├── check.ts │ ├── language.ts │ ├── values.ts │ ├── definitions.ts │ └── arrays.ts └── html.js ├── __mock__ ├── file-mock.js ├── svgr-mock.js └── gatsby.js ├── index.d.ts ├── jest-helpers ├── jest.setup.js ├── loadershim.js └── tsconfig.jest.json ├── .prettierrc ├── .prettierignore ├── .vscode └── extensions.json ├── svgo-config.json ├── gatsby-wrap-root-element.js ├── gatsby-ssr.js ├── .editorconfig ├── .env.example ├── scripts ├── get-path-prefix.js ├── fs │ ├── rm.js │ ├── mkdir.js │ └── mv.js ├── load-dotenv-config.js ├── proxied-http-server.js ├── translation-runner.js └── font-generator.js ├── tsconfig.json ├── gatsby-browser.js ├── LICENSE ├── gatsby-node.js ├── .babelrc.js ├── .eslintrc.js ├── .gitignore ├── jest.config.js ├── gatsby-config.js ├── package.json └── README.md /src/assets/languages/whitelists/whitelist_es.json: -------------------------------------------------------------------------------- 1 | [ 2 | ] -------------------------------------------------------------------------------- /src/assets/languages/whitelists/whitelist_pt.json: -------------------------------------------------------------------------------- 1 | [ 2 | ] -------------------------------------------------------------------------------- /__mock__/file-mock.js: -------------------------------------------------------------------------------- 1 | module.exports = `test-file-stub`; 2 | -------------------------------------------------------------------------------- /src/assets/hello-world.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "hello world" 3 | } 4 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@formatjs/intl-relativetimeformat/locale-data/*'; 2 | -------------------------------------------------------------------------------- /jest-helpers/jest.setup.js: -------------------------------------------------------------------------------- 1 | require(`@testing-library/jest-dom/extend-expect`); 2 | -------------------------------------------------------------------------------- /jest-helpers/loadershim.js: -------------------------------------------------------------------------------- 1 | global.___loader = { 2 | enqueue: jest.fn(), 3 | }; 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "printWidth": 120 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/styles/base/_spacing.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | @include spaceUnit(var(--base-unit)); 3 | } 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .husky 3 | 4 | package-lock.json 5 | 6 | src/assets/languages/**/*.json 7 | -------------------------------------------------------------------------------- /src/store/state/language/index.ts: -------------------------------------------------------------------------------- 1 | import reducers from './reducers'; 2 | 3 | export default reducers; 4 | -------------------------------------------------------------------------------- /src/store/state/photos/index.ts: -------------------------------------------------------------------------------- 1 | import reducers from './reducers'; 2 | 3 | export default reducers; 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["EditorConfig.EditorConfig", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /src/store/state/index.ts: -------------------------------------------------------------------------------- 1 | export { default as language } from './language'; 2 | export { default as photos } from './photos'; 3 | -------------------------------------------------------------------------------- /jest-helpers/tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/assets/images/gatsby-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erkobridee/gatsby-typescript-app-starter/HEAD/src/assets/images/gatsby-icon.png -------------------------------------------------------------------------------- /src/assets/images/gatsby-astronaut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erkobridee/gatsby-typescript-app-starter/HEAD/src/assets/images/gatsby-astronaut.png -------------------------------------------------------------------------------- /svgo-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | { 4 | "convertPathData": { 5 | "noSpaceAfterFlags": false 6 | } 7 | } 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/data/schemas/JsonPlaceholder/index.ts: -------------------------------------------------------------------------------- 1 | export interface IPhotoEntity { 2 | album: number; 3 | id: number; 4 | title: string; 5 | url: string; 6 | thumbnailUrl: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/ui/Tiles/_styles.scss: -------------------------------------------------------------------------------- 1 | .tiles { 2 | display: grid; 3 | grid-template-columns: repeat(var(--tiles-columns, 3), 1fr); 4 | grid-gap: var(--tiles-gutter, 0); 5 | padding: var(--tiles-padding, 0); 6 | } 7 | -------------------------------------------------------------------------------- /src/domains/Photos/View/_styles.scss: -------------------------------------------------------------------------------- 1 | .photo { 2 | max-width: 600px; 3 | margin: 0 auto; 4 | display: flex; 5 | flex-direction: column; 6 | 7 | &__link { 8 | margin-top: var(--space-lg); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/styles/base/mixins/_font-icons.scss: -------------------------------------------------------------------------------- 1 | @mixin font-icon-content($name: null, $suffix: '_icon') { 2 | @if ($name != null) { 3 | font-family: 'fonticons'; 4 | @extend .icon-#{$name}#{$suffix}:before; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/assets/icons/hamburger_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /__mock__/svgr-mock.js: -------------------------------------------------------------------------------- 1 | // https://react-svgr.com/docs/jest/ 2 | import * as React from 'react'; 3 | export default 'SvgrURL'; 4 | const SvgrMock = React.forwardRef((props, ref) =>
); 5 | export const ReactComponent = SvgrMock; 6 | -------------------------------------------------------------------------------- /src/store/definitions.ts: -------------------------------------------------------------------------------- 1 | import { ILanguageState } from './state/language/definitions'; 2 | import { IPhotosState } from './state/photos/definitions'; 3 | 4 | export interface IRootState { 5 | language: ILanguageState; 6 | photos: IPhotosState; 7 | } 8 | -------------------------------------------------------------------------------- /gatsby-wrap-root-element.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import RootWrapper from './src/components/Wrapper/Root'; 3 | 4 | // eslint-disable-next-line react/display-name,react/prop-types 5 | export default ({ element }) => {element}; 6 | -------------------------------------------------------------------------------- /src/assets/styles/global.scss: -------------------------------------------------------------------------------- 1 | /* 2 | will be generated by the npm script before start 3 | the development or build flow 4 | */ 5 | @import './generated/fonticons.scss'; 6 | 7 | @import './base'; 8 | @import './custom'; 9 | @import './skin'; 10 | -------------------------------------------------------------------------------- /src/assets/styles/base/mixins/_reset.scss: -------------------------------------------------------------------------------- 1 | // reset user agent style 2 | @mixin reset { 3 | background-color: transparent; 4 | padding: 0; 5 | border: 0; 6 | border-radius: 0; 7 | color: inherit; 8 | line-height: inherit; 9 | appearance: none; 10 | } 11 | -------------------------------------------------------------------------------- /gatsby-ssr.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implement Gatsby's SSR (Server Side Rendering) APIs in this file. 3 | * 4 | * See: https://www.gatsbyjs.org/docs/ssr-apis/ 5 | */ 6 | 7 | import withWrapper from './gatsby-wrap-root-element'; 8 | 9 | export const wrapRootElement = withWrapper; 10 | -------------------------------------------------------------------------------- /src/assets/icons/check_mark_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/styles/base/_mixins.scss: -------------------------------------------------------------------------------- 1 | @import 'mixins/convert-unit-values'; 2 | @import 'mixins/font-icons'; 3 | @import 'mixins/colors'; 4 | @import 'mixins/breakpoints'; 5 | @import 'mixins/spacing'; 6 | @import 'mixins/aspect-ratio'; 7 | @import 'mixins/typography'; 8 | @import 'mixins/reset'; 9 | -------------------------------------------------------------------------------- /src/assets/styles/_base.scss: -------------------------------------------------------------------------------- 1 | @import 'base/mixins'; 2 | 3 | :root { 4 | --base-unit: #{pixels2rem(10px)}; 5 | } 6 | 7 | @import 'base/reset'; 8 | @import 'base/z-index'; 9 | @import 'base/colors'; 10 | @import 'base/spacing'; 11 | @import 'base/typography'; 12 | @import 'base/html-elements'; 13 | -------------------------------------------------------------------------------- /src/assets/styles/_custom.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --default-transition: all 0.2s ease-in-out; 3 | 4 | --default-border-radius: 4px; 5 | 6 | --min-app-height: 100vh; 7 | --min-app-width: 350px; 8 | --max-app-width: 960px; 9 | } 10 | 11 | @import './custom/links'; 12 | @import './custom/default-theme'; 13 | -------------------------------------------------------------------------------- /src/pages/photos.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import Layout from 'components/Layout'; 4 | 5 | import Photos from 'domains/Photos'; 6 | 7 | const PhotosPage: React.FunctionComponent = () => ( 8 | 9 | 10 | 11 | ); 12 | 13 | export default PhotosPage; 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/domains/Photos/Card/_styles.scss: -------------------------------------------------------------------------------- 1 | .photocard { 2 | width: 100%; 3 | height: 100%; 4 | box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); 5 | padding-bottom: 1px; 6 | 7 | img { 8 | margin-bottom: 0; 9 | } 10 | 11 | &__title { 12 | margin: var(--space-md); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | API_PREFIX=api 2 | # if the project uses the API from another server / domain 3 | API_HOST=api.example.com (without protocol) 4 | # use it in case that the frontend is deployed together with the backend 5 | # on the server, so it will enable you to call the backend on the server 6 | PROXY_URL=https://server.example.com -------------------------------------------------------------------------------- /src/assets/icons/heart_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/ui/ImageCard/_styles.scss: -------------------------------------------------------------------------------- 1 | @import 'assets/styles/base/_mixins.scss'; 2 | 3 | .imagecard { 4 | background-size: cover; 5 | background-position: center; 6 | width: 100%; 7 | 8 | &--square { 9 | @include aspect-ratio(1, 1); 10 | } 11 | 12 | &--rectangle { 13 | @include aspect-ratio(16, 9); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/store/state/persistConfig.ts: -------------------------------------------------------------------------------- 1 | import storage from 'redux-persist/lib/storage'; 2 | 3 | import language from './language/persistTransform'; 4 | 5 | const persistConfig = { 6 | key: 'states', 7 | storage, 8 | transforms: [language.transform], 9 | whitelist: [language.name], 10 | }; 11 | 12 | export default persistConfig; 13 | -------------------------------------------------------------------------------- /src/store/state/language/selectors.ts: -------------------------------------------------------------------------------- 1 | import { IRootState } from 'store/definitions'; 2 | 3 | export const selectLanguage = (state: IRootState) => state.language; 4 | 5 | export const selectLocale = (state: IRootState) => selectLanguage(state).locale; 6 | 7 | export const selectMessages = (state: IRootState) => selectLanguage(state).messages; 8 | -------------------------------------------------------------------------------- /scripts/get-path-prefix.js: -------------------------------------------------------------------------------- 1 | const getPathPrefix = (pkg) => { 2 | if (process.env.PREFIX_PATH !== 'true') { 3 | return ''; 4 | } 5 | const { name, gatsby } = pkg; 6 | if (gatsby && 'pathPrefix' in gatsby) { 7 | return `/${gatsby.pathPrefix}`; 8 | } 9 | return `/${name}`; 10 | }; 11 | 12 | module.exports = getPathPrefix; 13 | -------------------------------------------------------------------------------- /src/components/Layout/_styles.scss: -------------------------------------------------------------------------------- 1 | .layout { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | min-width: var(--min-app-width); 6 | min-height: var(--min-app-height); 7 | 8 | &__content { 9 | padding: var(--space-md); 10 | width: 100%; 11 | max-width: var(--max-app-width); 12 | flex-grow: 1; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/assets/icons/search_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/store/state/language/actions.ts: -------------------------------------------------------------------------------- 1 | import { LanguageActionTypes as Types, ILanguageAction, ILanguageState } from './definitions'; 2 | 3 | export const updateLanguage = (payload: ILanguageState): ILanguageAction => ({ 4 | type: Types.UPDATE, 5 | payload, 6 | }); 7 | 8 | export const resetLanguage = (): ILanguageAction => ({ 9 | type: Types.RESET, 10 | }); 11 | -------------------------------------------------------------------------------- /__mock__/gatsby.js: -------------------------------------------------------------------------------- 1 | const React = require(`react`); 2 | const gatsby = jest.requireActual(`gatsby`); 3 | 4 | module.exports = { 5 | ...gatsby, 6 | graphql: jest.fn(), 7 | Link: jest.fn().mockImplementation(({ to, ...rest }) => 8 | React.createElement(`a`, { 9 | ...rest, 10 | href: to, 11 | }) 12 | ), 13 | StaticQuery: jest.fn(), 14 | useStaticQuery: jest.fn(), 15 | }; 16 | -------------------------------------------------------------------------------- /scripts/fs/rm.js: -------------------------------------------------------------------------------- 1 | const fse = require('fs-extra'); 2 | const args = require('yargs').usage('Usage: $0 [path]').argv; 3 | 4 | const [path] = args._; 5 | 6 | async function remove() { 7 | try { 8 | await fse.remove(path); 9 | } catch (err) { 10 | console.log(err); 11 | } 12 | } 13 | 14 | if (path) { 15 | remove(); 16 | } else { 17 | console.log(`Usage: ${args.$0} [path]`); 18 | } 19 | -------------------------------------------------------------------------------- /src/store/state/language/definitions.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from 'store/helpers/definitions'; 2 | 3 | export enum LanguageActionTypes { 4 | UPDATE = 'language/UPDATE', 5 | RESET = 'language/RESET', 6 | } 7 | 8 | export interface ILanguageState { 9 | locale: string; 10 | messages?: { [key: string]: string }; 11 | } 12 | 13 | export interface ILanguageAction extends IAction {} 14 | -------------------------------------------------------------------------------- /src/components/Wrapper/Root/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ReduxWraper from 'components/Wrapper/Redux'; 3 | import IntlWrapper from 'components/Wrapper/Intl'; 4 | 5 | const RootWrapper: React.FunctionComponent = ({ children }) => { 6 | return ( 7 | 8 | {children} 9 | 10 | ); 11 | }; 12 | 13 | export default RootWrapper; 14 | -------------------------------------------------------------------------------- /src/assets/styles/base/_colors.scss: -------------------------------------------------------------------------------- 1 | $colors: ( 2 | color-black: #000000, 3 | color-white: #ffffff, 4 | color-silver: #c0c0c0, 5 | color-pink: #ffc0cb, 6 | color-red: #ff0000, 7 | color-green: #00ff00, 8 | color-yellow: #ffff00, 9 | color-orange: #ffa500, 10 | color-cyan: #00ffff, 11 | color-blue: #0000ff, 12 | color-rebeccapurple: #663399, 13 | ); 14 | 15 | :root { 16 | @include convertToCssColors($colors); 17 | } 18 | -------------------------------------------------------------------------------- /src/assets/icons/close_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/styles/base/mixins/_convert-unit-values.scss: -------------------------------------------------------------------------------- 1 | // https://websemantics.uk/articles/font-size-conversion/ 2 | 3 | @function strip-unit($value) { 4 | @return $value / ($value * 0 + 1); 5 | } 6 | 7 | @function convertPixels2RemValue($pixels: 1, $base: 16) { 8 | @return strip-unit($pixels) / $base; 9 | } 10 | 11 | @function pixels2rem($pixels: 1, $base: 16) { 12 | @return #{convertPixels2RemValue($pixels, $base)}rem; 13 | } 14 | -------------------------------------------------------------------------------- /scripts/fs/mkdir.js: -------------------------------------------------------------------------------- 1 | const fse = require('fs-extra'); 2 | const args = require('yargs').usage('Usage: $0 [directory name]').argv; 3 | 4 | const [dirname] = args._; 5 | 6 | async function createDirectory() { 7 | try { 8 | await fse.ensureDir(dirname); 9 | } catch (err) { 10 | console.log(err); 11 | } 12 | } 13 | 14 | if (dirname) { 15 | createDirectory(); 16 | } else { 17 | console.log(`Usage: ${args.$0} [directory name]`); 18 | } 19 | -------------------------------------------------------------------------------- /scripts/fs/mv.js: -------------------------------------------------------------------------------- 1 | const fse = require('fs-extra'); 2 | const args = require('yargs').usage('Usage: $0 [source] [destination]').argv; 3 | 4 | const [src, dest] = args._; 5 | 6 | async function move() { 7 | try { 8 | await fse.move(src, dest, { overwrite: true }); 9 | } catch (err) { 10 | console.log(err); 11 | } 12 | } 13 | 14 | if (src && dest) { 15 | move(); 16 | } else { 17 | console.log(`Usage: ${args.$0} [source] [destination]`); 18 | } 19 | -------------------------------------------------------------------------------- /src/store/state/language/operations.ts: -------------------------------------------------------------------------------- 1 | import 'intl-pluralrules'; 2 | import { Dispatch } from 'redux'; 3 | import { loadLocale } from 'helpers/language'; 4 | import { updateLanguage } from './actions'; 5 | import { ILanguageAction } from './definitions'; 6 | 7 | export const changeLanguage = (locale?: string) => async (dispatch: Dispatch) => { 8 | const { ...payload } = await loadLocale(locale); 9 | dispatch(updateLanguage(payload)); 10 | }; 11 | -------------------------------------------------------------------------------- /src/data/api/photos/definitions.ts: -------------------------------------------------------------------------------- 1 | import { IAPIResponse, IBaseResponse, IPaginationRequestParams } from 'helpers/definitions'; 2 | import { IPhotoEntity } from 'data/schemas/JsonPlaceholder'; 3 | import PhotoModel from 'data/models/Photos'; 4 | 5 | export interface IPhotosAPIResponse extends IAPIResponse {} 6 | 7 | export interface IPhotosParams extends IPaginationRequestParams {} 8 | 9 | export interface IPhotosResponse extends IBaseResponse {} 10 | -------------------------------------------------------------------------------- /src/assets/icons/threedots_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/ui/FontIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import cn from 'clsx'; 3 | 4 | export interface IFontIconProps { 5 | name: string; 6 | className?: string | string[]; 7 | title?: string; 8 | onClick?: React.MouseEventHandler; 9 | } 10 | 11 | const FontIcon: React.FunctionComponent = ({ name, className, ...otherProps }) => { 12 | const classes = cn(`icon icon-${name}_icon`, className); 13 | return ; 14 | }; 15 | 16 | export default FontIcon; 17 | -------------------------------------------------------------------------------- /src/domains/Photos/Card/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import ImageCard from 'components/ui/ImageCard'; 4 | import PhotoModel from 'data/models/Photos'; 5 | 6 | import './_styles.scss'; 7 | 8 | export interface IPhotoCard { 9 | data: PhotoModel; 10 | } 11 | 12 | const PhotoCard: React.FunctionComponent = ({ data: { url, title } }) => ( 13 |
14 | 15 |
{title}
16 |
17 | ); 18 | 19 | export default PhotoCard; 20 | -------------------------------------------------------------------------------- /src/store/state/photos/actions.ts: -------------------------------------------------------------------------------- 1 | import { PhotosActionTypes as Types, IPhotosAction, IPhotosState } from './definitions'; 2 | 3 | export const photosFetching = (): IPhotosAction => ({ 4 | type: Types.FETCHING, 5 | }); 6 | 7 | export const photosLoaded = (payload: IPhotosState): IPhotosAction => ({ 8 | type: Types.LOADED, 9 | payload, 10 | }); 11 | 12 | export const photosFetchError = (): IPhotosAction => ({ 13 | type: Types.FETCH_ERROR, 14 | }); 15 | 16 | export const photosReset = (): IPhotosAction => ({ 17 | type: Types.RESET, 18 | }); 19 | -------------------------------------------------------------------------------- /src/domains/Photos/List/_styles.scss: -------------------------------------------------------------------------------- 1 | @import 'assets/styles/base/_mixins.scss'; 2 | 3 | .photos { 4 | &__hasmore { 5 | display: flex; 6 | justify-content: center; 7 | margin-top: var(--space-lg); 8 | margin-bottom: var(--space-md); 9 | } 10 | 11 | .photocard { 12 | cursor: pointer; 13 | } 14 | 15 | &__tiles { 16 | --tiles-gutter: var(--space-lg); 17 | } 18 | 19 | --tiles-columns: 1; 20 | 21 | @include breakpoint(sm) { 22 | --tiles-columns: 2; 23 | } 24 | 25 | @include breakpoint(lg) { 26 | --tiles-columns: 3; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/Wrapper/Redux/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Provider as ReduxProvider } from 'react-redux'; 3 | import { PersistGate } from 'redux-persist/integration/react'; 4 | import { store, persistor } from 'store'; 5 | 6 | const ReduxWrapper: React.FunctionComponent = ({ children }) => { 7 | return ( 8 | 9 | 10 | {children} 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default ReduxWrapper; 17 | -------------------------------------------------------------------------------- /src/assets/styles/custom/_links.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --link-transition: var(--default-transition); 3 | --link: inherit; 4 | --link-decoration: none; 5 | --link-hover: inherit; 6 | --link-active: inherit; 7 | } 8 | 9 | a, 10 | .link { 11 | transition: var(--link-transition); 12 | color: var(--link); 13 | text-decoration: var(--link-decoration); 14 | 15 | &:hover { 16 | color: var(--link-hover); 17 | } 18 | 19 | &:active { 20 | color: var(--link-active, inherit); 21 | } 22 | 23 | &:visited { 24 | color: var(--link-visited, inherit); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/store/state/photos/selectors.ts: -------------------------------------------------------------------------------- 1 | import { IRootState } from 'store/definitions'; 2 | 3 | export const selectPhotos = (state: IRootState) => state.photos; 4 | 5 | export const selectPhotosList = (state: IRootState) => selectPhotos(state).list; 6 | 7 | export const selectPhotoById = (id: number) => (state: IRootState) => 8 | selectPhotosList(state).find((photo) => photo.id === id); 9 | 10 | export const selectHasMore = (state: IRootState) => selectPhotos(state).hasMore; 11 | 12 | export const selectIsFetching = (state: IRootState) => selectPhotos(state).isFetching; 13 | -------------------------------------------------------------------------------- /src/components/Layout/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Link } from 'gatsby'; 3 | 4 | import './_styles.scss'; 5 | 6 | interface IHeader { 7 | siteTitle: string; 8 | } 9 | 10 | const Header: React.FunctionComponent = ({ siteTitle = '' }) => ( 11 |
12 |
13 |
14 | 15 | {siteTitle} 16 | 17 |
18 |
19 |
20 | ); 21 | 22 | export default Header; 23 | -------------------------------------------------------------------------------- /src/assets/styles/base/mixins/_spacing.scss: -------------------------------------------------------------------------------- 1 | @mixin spaceUnit($space-unit) { 2 | --space-unit: #{$space-unit}; 3 | --space-xxxxs: calc(0.125 * #{$space-unit}); 4 | --space-xxxs: calc(0.25 * #{$space-unit}); 5 | --space-xxs: calc(0.375 * #{$space-unit}); 6 | --space-xs: calc(0.5 * #{$space-unit}); 7 | --space-sm: calc(0.75 * #{$space-unit}); 8 | --space-md: calc(1.25 * #{$space-unit}); 9 | --space-lg: calc(2 * #{$space-unit}); 10 | --space-xl: calc(3.25 * #{$space-unit}); 11 | --space-xxl: calc(5.25 * #{$space-unit}); 12 | --space-xxxl: calc(8.5 * #{$space-unit}); 13 | --space-xxxxl: calc(13.75 * #{$space-unit}); 14 | } 15 | -------------------------------------------------------------------------------- /src/assets/languages/whitelists/whitelist_en.json: -------------------------------------------------------------------------------- 1 | [ 2 | "language.selector.option.english", 3 | "language.selector.option.spanish", 4 | "language.selector.option.portuguese", 5 | "language.selector.label", 6 | "layout.build-with", 7 | "photos.pagetitle", 8 | "photos.loadmore", 9 | "photos.loading", 10 | "photo.pagetitle", 11 | "photo.not-found", 12 | "photo.return-to-photos", 13 | "404.title", 14 | "404.message", 15 | "home.pagetitle", 16 | "home.greeting", 17 | "home.welcome", 18 | "home.gobuild", 19 | "home.link-page2", 20 | "home.link-photos", 21 | "page2.title", 22 | "page2.welcome", 23 | "page2.link" 24 | ] -------------------------------------------------------------------------------- /src/store/helpers/definitions.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction, Action, Reducer } from 'redux'; 2 | 3 | export interface IAction

extends Action { 4 | payload?: P; 5 | } 6 | 7 | export interface IReducerOptions { 8 | state: S; 9 | action: A; 10 | } 11 | 12 | export type TReducer = (state: S, action: A) => S; 13 | 14 | export interface IReducerMap { 15 | [key: string]: TReducer; 16 | } 17 | 18 | export type TReducerMapFunction = (reducerMap: IReducerMap) => Reducer; 19 | -------------------------------------------------------------------------------- /src/store/state/photos/definitions.ts: -------------------------------------------------------------------------------- 1 | import { IAction } from 'store/helpers/definitions'; 2 | import IPhotosModel from 'data/models/Photos'; 3 | import { IPhotosParams } from 'data/api/photos/definitions'; 4 | 5 | export enum PhotosActionTypes { 6 | FETCHING = 'photos/FETCHING', 7 | LOADED = 'photos/LOADED', 8 | FETCH_ERROR = 'photos/FETCH_ERROR', 9 | RESET = 'photos/RESET', 10 | } 11 | 12 | export interface IPhotosState { 13 | list: IPhotosModel[]; 14 | hasMore: boolean; 15 | isFetching: boolean; 16 | } 17 | 18 | export interface IPhotosAction extends IAction {} 19 | 20 | export interface IPhotosLoadParams extends IPhotosParams {} 21 | -------------------------------------------------------------------------------- /src/assets/styles/base/mixins/_aspect-ratio.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Mixin that allows to define a fixed aspect 3 | 4 | the default value will generate a square aspect 5 | */ 6 | @mixin aspect-fixed($height: 100%) { 7 | position: relative; 8 | &:before { 9 | display: block; 10 | content: ''; 11 | width: 100%; 12 | padding-top: $height; 13 | } 14 | } 15 | 16 | /* 17 | Mixin to set aspect ratio. 18 | 19 | 1, 1 - square 20 | 16, 9 - rectangle 21 | 22 | Eg: To set an aspect ratio of 16:9 include the mixin like; 23 | @include aspect-ratio(16, 9); 24 | */ 25 | @mixin aspect-ratio($width, $height) { 26 | @include aspect-fixed(($height / $width) * 100%); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/LanguageSelector/_styles.scss: -------------------------------------------------------------------------------- 1 | @import 'assets/styles/base/_mixins.scss'; 2 | 3 | .languageselector { 4 | display: flex; 5 | flex-direction: row; 6 | align-items: center; 7 | 8 | &__label { 9 | margin-right: var(--languageselector-label-margin-right, var(--space-md)); 10 | } 11 | 12 | &__select { 13 | color: var(--languageselector-select-color, initial); 14 | background: var(--languageselector-select-bg, transparent); 15 | border: 0; 16 | 17 | &container { 18 | border-radius: var(--languageselector-border-radius, var(--default-border-radius)); 19 | border: 1px solid var(--languageselector-select-border, initial); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/helpers/fetch/readImageBlobAsDataURL.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * transform a binary image to its base64 image string 3 | * 4 | * @param {Blob} imageBlob - image binary 5 | * @return {string} image as a base64 string 6 | */ 7 | export const readImageBlobAsDataURL = async (imageBlob: Blob): Promise => { 8 | const imageReader = new FileReader(); 9 | return new Promise((resolve) => { 10 | const load = () => { 11 | imageReader.removeEventListener('load', load); 12 | resolve(imageReader.result as string); 13 | }; 14 | imageReader.addEventListener('load', load, false); 15 | imageReader.readAsDataURL(imageBlob); 16 | }); 17 | }; 18 | 19 | export default readImageBlobAsDataURL; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "*": ["*", "src/*"] 6 | }, 7 | "module": "commonjs", 8 | "target": "esnext", 9 | "jsx": "preserve", 10 | "lib": ["dom", "es2015", "es2017"], 11 | "strict": true, 12 | "noEmit": true, 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "skipLibCheck": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "removeComments": false, 19 | "preserveConstEnums": true, 20 | "resolveJsonModule": true, 21 | "typeRoots": ["node_modules/@types"] 22 | }, 23 | "include": ["index.d.ts", "src/**/*"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /gatsby-browser.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Implement Gatsby's Browser APIs in this file. 3 | * 4 | * See: https://www.gatsbyjs.org/docs/browser-apis/ 5 | */ 6 | import 'assets/styles/global.scss'; 7 | 8 | import 'whatwg-fetch'; 9 | 10 | import './src/helpers/console'; 11 | import cssVariableTransform from './src/helpers/cssVariableTransform'; 12 | import withWrapper from './gatsby-wrap-root-element'; 13 | 14 | // eslint-disable-next-line import/prefer-default-export 15 | export const onClientEntry = () => { 16 | console.log('gatsby browser > onClientEntry > start'); 17 | return cssVariableTransform().finally(() => console.log('gatsby browser > onClientEntry > finished')); 18 | }; 19 | 20 | export const wrapRootElement = withWrapper; 21 | -------------------------------------------------------------------------------- /src/components/ui/ImageCard/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import cn from 'clsx'; 3 | 4 | import './_styles.scss'; 5 | 6 | export enum EImageCard { 7 | SQUARE = 'square', 8 | RECTANGLE = 'rectangle', 9 | } 10 | 11 | interface IImageCardProps { 12 | url: string; 13 | type?: EImageCard; 14 | className?: string; 15 | } 16 | 17 | const ImageCard: React.FunctionComponent = ({ url, type = EImageCard.RECTANGLE, className }) => { 18 | const props = { 19 | className: cn(className, 'imagecard', `imagecard--${type}`), 20 | style: { 21 | backgroundImage: `url('${url}')`, 22 | }, 23 | }; 24 | 25 | return

; 26 | }; 27 | 28 | export default ImageCard; 29 | -------------------------------------------------------------------------------- /src/assets/styles/base/mixins/_colors.scss: -------------------------------------------------------------------------------- 1 | /* 2 | Function to convert 3 | */ 4 | @function color($color-name, $alpha: null) { 5 | @if ($alpha != null) { 6 | @return #{'rgba('}var(--#{$color-name}), $alpha#{')'}; 7 | } 8 | @return #{'rgb('}var(--#{$color-name}) #{')'}; 9 | } 10 | 11 | /* 12 | Mixin to convert sass vars to css variables 13 | */ 14 | @mixin convertToCssColors($colors) { 15 | @each $name, $color in $colors { 16 | --#{$name}: #{red($color)}, #{green($color)}, #{blue($color)}; 17 | } 18 | } 19 | 20 | /* 21 | Mixin to convert sass vars to css variables with the colors 22 | */ 23 | @mixin convertToCssColorsValues($colors) { 24 | @each $name, $color in $colors { 25 | --#{$name}: #{$color}; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/store/state/language/reducers.ts: -------------------------------------------------------------------------------- 1 | import { browserLocale } from 'helpers/language'; 2 | import createReducer from 'store/helpers/createReducer'; 3 | import { IReducerMap } from 'store/helpers/definitions'; 4 | import { LanguageActionTypes as Types, ILanguageAction, ILanguageState } from './definitions'; 5 | 6 | export const initialState: ILanguageState = { 7 | locale: browserLocale, 8 | }; 9 | 10 | const reducerMap: IReducerMap = { 11 | [Types.UPDATE]: (_, action) => { 12 | const { payload } = action; 13 | return payload || initialState; 14 | }, 15 | [Types.RESET]: () => initialState, 16 | }; 17 | 18 | const language = createReducer(initialState)(reducerMap); 19 | 20 | export default language; 21 | -------------------------------------------------------------------------------- /src/store/state/language/persistTransform.ts: -------------------------------------------------------------------------------- 1 | import { createTransform } from 'redux-persist'; 2 | 3 | import { ILanguageState } from './definitions'; 4 | import { initialState } from './reducers'; 5 | 6 | const serialize = (/* inboundState */ { locale }: ILanguageState) => { 7 | return { locale }; 8 | }; 9 | 10 | const unserialize = (outboundState: any) => { 11 | let loadedState = {}; 12 | if ('locale' in outboundState) { 13 | loadedState = { 14 | locale: outboundState.locale, 15 | }; 16 | } 17 | return { ...initialState, ...loadedState }; 18 | }; 19 | 20 | const name = 'language'; 21 | const transform = createTransform(serialize, unserialize, { whitelist: [name] }); 22 | 23 | export default { 24 | name, 25 | transform, 26 | }; 27 | -------------------------------------------------------------------------------- /scripts/load-dotenv-config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const dotenv = require('dotenv'); 3 | 4 | const activeEnv = process.env.ACTIVE_ENV || process.env.NODE_ENV || 'development'; 5 | const dotenvPath = path.resolve(__dirname, '..', `.env.${activeEnv}`); 6 | 7 | //www.gatsbyjs.org/docs/environment-variables/ 8 | const dotenvConfig = dotenv.config({ 9 | path: dotenvPath, 10 | }); 11 | 12 | /* 13 | if (dotenvConfig.error) { 14 | console.log(`\n\n\ndotenv file not found: '${dotenvPath}'\n\n\n`); 15 | } 16 | */ 17 | 18 | const { parsed: dotenvParsed } = dotenvConfig; 19 | 20 | if (dotenvParsed) { 21 | console.log(`\n\ndotenv file '${dotenvPath}' loaded\n${JSON.stringify(dotenvParsed, null, 2)}\n\n`); 22 | } 23 | 24 | module.exports = { 25 | activeEnv, 26 | dotenvParsed, 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/Wrapper/Intl/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { IntlProvider } from 'react-intl'; 4 | import { selectLanguage } from 'store/state/language/selectors'; 5 | import { changeLanguage } from 'store/state/language/operations'; 6 | 7 | export const IntlWrapper: React.FunctionComponent = ({ children }) => { 8 | const dispatch = useDispatch(); 9 | const language = useSelector(selectLanguage); 10 | const { locale } = language; 11 | 12 | React.useEffect(() => { 13 | if (!language.messages) { 14 | dispatch(changeLanguage(locale)); 15 | } 16 | }, []); 17 | 18 | return ; 19 | }; 20 | 21 | export default IntlWrapper; 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | ISC License 3 | 4 | Copyright (c) 2021, Erko Bridee de Almeida Cabrera 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any 7 | purpose with or without fee is hereby granted, provided that the above 8 | copyright notice and this permission notice appear in all copies. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 11 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 12 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 13 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 14 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 15 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 16 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 17 | -------------------------------------------------------------------------------- /src/assets/styles/base/mixins/_breakpoints.scss: -------------------------------------------------------------------------------- 1 | $breakpoints: ( 2 | xxs: 400px, 3 | xs: 512px, 4 | sm: 768px, 5 | md: 1024px, 6 | lg: 1280px, 7 | xl: 1440px, 8 | ); 9 | 10 | @mixin breakpoint($breakpoint, $logic: false) { 11 | @if ($logic) { 12 | @media #{$logic} and (min-width: map-get($map: $breakpoints, $key: $breakpoint)) { 13 | @content; 14 | } 15 | } @else { 16 | @media (min-width: map-get($map: $breakpoints, $key: $breakpoint)) { 17 | @content; 18 | } 19 | } 20 | } 21 | 22 | @mixin breakpoint-max($breakpoint, $logic: false) { 23 | @if ($logic) { 24 | @media #{$logic} and (max-width: map-get($map: $breakpoints, $key: $breakpoint)) { 25 | @content; 26 | } 27 | } @else { 28 | @media (max-width: map-get($map: $breakpoints, $key: $breakpoint)) { 29 | @content; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Layout/Footer/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { FormattedMessage } from 'react-intl'; 3 | 4 | import FontIcon from 'components/ui/FontIcon'; 5 | import LanguageSelector from 'components/LanguageSelector'; 6 | 7 | import './_styles.scss'; 8 | 9 | const Footer: React.FunctionComponent = () => ( 10 |
11 |
12 |
13 | © {new Date().getFullYear()}, 14 | {` `} 15 | 16 | Gatsby 17 | 18 |
19 | 20 |
21 |
22 | ); 23 | 24 | export default Footer; 25 | -------------------------------------------------------------------------------- /src/assets/styles/base/_z-index.scss: -------------------------------------------------------------------------------- 1 | /* z-index pyramid */ 2 | $sun: -1; 3 | $mercury: 1; 4 | $venus: 10; 5 | $earth: 20; 6 | $mars: 30; 7 | $jupiter: 40; 8 | $saturn: 50; 9 | $uranus: 60; 10 | $neptune: 70; 11 | $solarsytem: 100; 12 | $milkwaygalaxy: 300; 13 | $andromedagalaxy: 500; 14 | 15 | $z-index-astros: ( 16 | sun: -1, 17 | mercory: 1, 18 | venus: 10, 19 | earth: 20, 20 | mars: 30, 21 | jupiter: 40, 22 | saturn: 50, 23 | uranus: 60, 24 | neptune: 70, 25 | solarsystem: 100, 26 | milkwaygalaxy: 300, 27 | andromedagalaxy: 500, 28 | ); 29 | 30 | @function zindex($name: mercory) { 31 | @return map-get($map: $z-index-astros, $key: $name); 32 | } 33 | 34 | @mixin zindexToCss() { 35 | @each $name, $value in $z-index-astros { 36 | --zindex-#{$name}: #{$value}; 37 | } 38 | } 39 | 40 | :root { 41 | @include zindexToCss(); 42 | } 43 | -------------------------------------------------------------------------------- /src/helpers/fetch/buildCancelableRequest.ts: -------------------------------------------------------------------------------- 1 | import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'; 2 | import { IAPIResponse, ICancelableBaseResponse, IRequestOptions } from 'helpers/definitions'; 3 | import buildRequest from './buildRequest'; 4 | 5 | /** 6 | * Build and handle a cancelable fetch request 7 | * 8 | * @param {IRequestOptions} options 9 | * 10 | * @returns {Promise} promise 11 | */ 12 | export function buildCancelableRequest( 13 | options: IRequestOptions 14 | ): ICancelableBaseResponse { 15 | const controller = new AbortController(); 16 | const signal = controller.signal; 17 | 18 | return { 19 | request: () => 20 | buildRequest({ 21 | ...options, 22 | signal, 23 | }), 24 | controller, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/domains/Photos/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { withPrefix } from 'gatsby'; 3 | import { Router, RouteComponentProps } from '@reach/router'; 4 | import { useDispatch } from 'react-redux'; 5 | 6 | import { resetPhotos } from 'store/state/photos/operations'; 7 | 8 | import PhotosList from './List'; 9 | import PhotoView from './View'; 10 | 11 | export interface IPhotos extends RouteComponentProps {} 12 | 13 | const Photos: React.FunctionComponent = () => { 14 | const dispatch = useDispatch(); 15 | 16 | React.useEffect(() => { 17 | return () => { 18 | dispatch(resetPhotos()); 19 | }; 20 | }, []); 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | ); 28 | }; 29 | 30 | export default Photos; 31 | -------------------------------------------------------------------------------- /src/components/ui/Tiles/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import cn from 'clsx'; 3 | 4 | export { default as Tile } from './Tile'; 5 | 6 | import './_styles.scss'; 7 | 8 | export interface ITilesProps { 9 | className?: string; 10 | gutter?: string; 11 | children: React.ReactNode; 12 | } 13 | 14 | export const Tiles: React.FunctionComponent = ({ gutter, className, children }) => { 15 | const tilesRef = React.useRef(null); 16 | 17 | React.useEffect(() => { 18 | const { current: tiles } = tilesRef; 19 | if (tiles && !className && gutter) { 20 | tiles.style.setProperty('--tiles-gutter', gutter); 21 | } 22 | }, [gutter, className]); 23 | 24 | return ( 25 |
26 | {children} 27 |
28 | ); 29 | }; 30 | 31 | export default Tiles; 32 | -------------------------------------------------------------------------------- /src/assets/styles/custom/_default-theme.scss: -------------------------------------------------------------------------------- 1 | /* 2 | defined on: 3 | https://material.io/tools/color/#!/?view.left=0&view.right=1&primary.color=663399&secondary.color=F9A825 4 | */ 5 | $md-colors: ( 6 | color-primary: #6d2f9c, 7 | color-primary-light: #9e5dce, 8 | color-primary-dark: #3d006d, 9 | color-on-primary: #ffffff, 10 | color-on-primary-light: #000000, 11 | color-on-primary-dark: #ffffff, 12 | color-secundary: #ffae1f, 13 | color-secundary-light: #ffe058, 14 | color-secundary-dark: #c77f00, 15 | color-on-secundary: #000000, 16 | color-on-secundary-light: #000000, 17 | color-on-secundary-dark: #000000, 18 | color-error: #b00020, 19 | color-on-error: #ffffff, 20 | color-background: #ffffff, 21 | color-on-background: #000000, 22 | color-surface: #ffffff, 23 | color-on-surface: #000000, 24 | ); 25 | 26 | :root { 27 | @include convertToCssColors($md-colors); 28 | } 29 | -------------------------------------------------------------------------------- /src/store/helpers/createReducer.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction, Action } from 'redux'; 2 | import { isFunction } from 'helpers/check'; 3 | import { TReducerMapFunction } from './definitions'; 4 | 5 | /** 6 | * Define a reducer with a initial value and a map of reducer functions by the action type, 7 | * when there is no type matching the reducer will return the state value 8 | * 9 | * @param {S} initialState 10 | * @return {TReducerMapFunction} reducer function 11 | */ 12 | export function createReducer(initialState: S): TReducerMapFunction { 13 | return (reducerMap) => (state = initialState, action) => { 14 | const { type = '__[[__not__defined__]]__' } = action || {}; 15 | const reducer = reducerMap[type]; 16 | return isFunction(reducer) ? reducer(state, action) : state; 17 | }; 18 | } 19 | 20 | export default createReducer; 21 | -------------------------------------------------------------------------------- /src/components/ui/Tiles/Tile/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import cn from 'clsx'; 3 | import { TFunction, IDictionary } from 'helpers/definitions'; 4 | 5 | interface ITileProps { 6 | key?: string | number; 7 | colSpan?: number; 8 | rowSpan?: number; 9 | className?: string; 10 | onClick?: TFunction; 11 | } 12 | 13 | const Tile: React.FunctionComponent = ({ children, colSpan, rowSpan, className, ...otherProps }) => { 14 | const style: IDictionary = {}; 15 | if (colSpan) { 16 | style.gridColumn = `span ${colSpan} / auto`; 17 | } 18 | if (rowSpan) { 19 | style.gridRow = `span ${rowSpan} / auto`; 20 | } 21 | return ( 22 |
29 | {children} 30 |
31 | ); 32 | }; 33 | 34 | export default Tile; 35 | -------------------------------------------------------------------------------- /gatsby-node.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | const { dotenvParsed } = require('./scripts/load-dotenv-config'); 5 | 6 | exports.onCreateWebpackConfig = ({ actions }) => { 7 | // make sure to have the process.env configs from the .env file available on the /src/* files 8 | const plugins = []; 9 | if (dotenvParsed) { 10 | const webpackDefinePluginConfig = {}; 11 | Object.keys(dotenvParsed).forEach((key) => { 12 | webpackDefinePluginConfig[`process.env.${key}`] = JSON.stringify(process.env[key]); 13 | }); 14 | plugins.push(new webpack.DefinePlugin(webpackDefinePluginConfig)); 15 | } 16 | 17 | actions.setWebpackConfig({ 18 | resolve: { 19 | // https://www.gatsbyjs.org/docs/add-custom-webpack-config/#absolute-imports 20 | modules: [path.resolve(__dirname, 'src'), 'node_modules'], 21 | }, 22 | plugins: plugins, 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/GatsbyAstronoutImage/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | https://www.gatsbyjs.com/docs/reference/release-notes/image-migration-guide/ 3 | https://www.gatsbyjs.com/plugins/gatsby-plugin-image/ 4 | https://www.gatsbyjs.com/docs/reference/built-in-components/gatsby-plugin-image/ 5 | */ 6 | import React from 'react'; 7 | import { useStaticQuery, graphql } from 'gatsby'; 8 | import { GatsbyImage } from 'gatsby-plugin-image'; 9 | 10 | const GatsbyAstronoutImage = () => { 11 | const data = useStaticQuery(graphql` 12 | query { 13 | file(relativePath: { eq: "gatsby-astronaut.png" }) { 14 | childImageSharp { 15 | gatsbyImageData(layout: CONSTRAINED, placeholder: BLURRED, formats: [AUTO, WEBP, AVIF]) 16 | } 17 | } 18 | } 19 | `); 20 | 21 | return ; 22 | }; 23 | 24 | export default GatsbyAstronoutImage; 25 | -------------------------------------------------------------------------------- /src/helpers/cssVariableTransform.ts: -------------------------------------------------------------------------------- 1 | import cssVars from 'css-vars-ponyfill'; 2 | import { isBrowser, hasCssVariablesSupport } from './check'; 3 | 4 | export const cssVariableTransform = () => 5 | new Promise((resolve, reject) => { 6 | if (!isBrowser || hasCssVariablesSupport) { 7 | console.log('cssVariableTransform ', { isBrowser, hasCssVariablesSupport }); 8 | return resolve('done'); 9 | } 10 | 11 | console.log('cssVariableTransform start'); 12 | cssVars({ 13 | onWarning: (message) => console.log('cssVariableTransform warning: ', message), 14 | onError: (message, elm, xhr, url) => { 15 | console.log('cssVariableTransform error: ', { message, elm, xhr, url }); 16 | reject(); 17 | }, 18 | onComplete: () => { 19 | console.log('cssVariableTransform completed'); 20 | resolve('done'); 21 | }, 22 | }); 23 | }); 24 | 25 | export default cssVariableTransform; 26 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | // Cache the returned value forever and don't call this function again. 3 | api.cache(true); 4 | 5 | const isTranslationsFlow = Boolean(process.env.TRANSLATIONS); 6 | if (isTranslationsFlow) { 7 | return { 8 | presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'], 9 | plugins: [ 10 | '@babel/plugin-syntax-dynamic-import', 11 | [ 12 | 'react-intl', 13 | { 14 | messagesDir: '.build/i18nExtractedMessages/', 15 | }, 16 | ], 17 | ], 18 | }; 19 | } 20 | 21 | return { 22 | presets: ['babel-preset-gatsby'], 23 | }; 24 | 25 | /* 26 | const presets = []; 27 | const plugins = [ 28 | [ 29 | 'react-intl', 30 | { 31 | messagesDir: '.i18nExtractedMessages/', 32 | }, 33 | ], 34 | ]; 35 | 36 | console.log('HERE'); 37 | 38 | return { 39 | presets, 40 | plugins, 41 | }; 42 | */ 43 | }; 44 | -------------------------------------------------------------------------------- /src/assets/styles/_skin.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --body-bg: rgb(var(--color-background)); 3 | --body-color: rgba(var(--color-on-background), 0.8); 4 | 5 | --header-bg: rgb(var(--color-primary)); 6 | --header-color: rgb(var(--color-on-primary)); 7 | 8 | --footer-bg: rgb(var(--color-primary)); 9 | --footer-color: rgb(var(--color-on-primary)); 10 | 11 | --link: rgba(var(--color-primary), var(--text-opacity-active)); 12 | --link-hover: rgba(var(--color-primary), var(--text-opacity-inactive)); 13 | --link-active: rgba(var(--color-primary), var(--text-opacity-active)); 14 | --link-visited: rgba(var(--color-primary), var(--text-opacity-visited)); 15 | 16 | --link-inverted: rgba(var(--color-on-primary), var(--text-opacity-inactive)); 17 | --link-inverted-hover: rgba(var(--color-on-primary), var(--text-opacity-active)); 18 | --link-inverted-active: rgba(var(--color-on-primary), var(--text-opacity-active)); 19 | --link-inverted-visited: rgba(var(--color-on-primary), var(--text-opacity-visited)); 20 | } 21 | -------------------------------------------------------------------------------- /src/data/models/Photos/index.ts: -------------------------------------------------------------------------------- 1 | import { IBaseModel } from 'helpers/definitions'; 2 | import { IPhotoEntity } from 'data/schemas/JsonPlaceholder'; 3 | 4 | export interface IPhotoProps extends IPhotoEntity {} 5 | 6 | export interface IPhotoModel extends IBaseModel { 7 | readonly id: number; 8 | readonly title: string; 9 | readonly url: string; 10 | } 11 | 12 | export default class PhotoModel implements IPhotoModel { 13 | private props: IPhotoProps; 14 | 15 | constructor(props: IPhotoProps) { 16 | this.props = props; 17 | } 18 | 19 | get __innerprops__() { 20 | return this.props; 21 | } 22 | 23 | get id() { 24 | return this.props.id; 25 | } 26 | 27 | get title() { 28 | return this.props.title; 29 | } 30 | 31 | get url() { 32 | return `//lorempixel.com/600/400/?_=${this.props.id}}`; 33 | } 34 | 35 | toJSON() { 36 | return { 37 | id: this.id, 38 | title: this.title, 39 | url: this.url, 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | manages all the application states through the react-redux 3 | */ 4 | import { applyMiddleware, compose, createStore, combineReducers } from 'redux'; 5 | import { persistReducer, persistStore } from 'redux-persist'; 6 | import thunkMiddleware from 'redux-thunk'; 7 | import { isBrowser } from 'helpers/check'; 8 | import * as reducers from './state'; 9 | import persistConfig from './state/persistConfig'; 10 | 11 | const rootReducer = combineReducers(reducers); 12 | const persistedReducer = persistReducer(persistConfig, rootReducer); 13 | 14 | const composeEnhancers = (isBrowser ? (window as any) : {}).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 15 | const middlewares = [thunkMiddleware]; 16 | 17 | export const store = createStore(persistedReducer, composeEnhancers(applyMiddleware(...middlewares))); 18 | export const persistor = persistStore(store); 19 | 20 | export const dispatch = store.dispatch; 21 | export const getState = () => store.getState(); 22 | 23 | export default store; 24 | -------------------------------------------------------------------------------- /scripts/proxied-http-server.js: -------------------------------------------------------------------------------- 1 | /* 2 | based on: 3 | https://github.com/gatsbyjs/gatsby/blob/2.13.30/packages/gatsby/src/commands/develop.js#L237 4 | */ 5 | const express = require('express'); 6 | const request = require('request'); 7 | 8 | const gatsbyConfig = require('../gatsby-config'); 9 | const { proxy } = gatsbyConfig; 10 | 11 | const app = express(); 12 | 13 | app.use(express.static('public')); 14 | 15 | if (proxy) { 16 | const { prefix, url } = proxy; 17 | app.use(`${prefix}/*`, (req, res) => { 18 | const proxiedUrl = url + req.originalUrl; 19 | req 20 | .pipe( 21 | request(proxiedUrl).on(`error`, (err) => { 22 | const message = `Error when trying to proxy request "${req.originalUrl}" to "${proxiedUrl}"`; 23 | 24 | report.error(message, err); 25 | res.status(500).end(); 26 | }) 27 | ) 28 | .pipe(res); 29 | }); 30 | } 31 | 32 | const port = process.env.PORT || 9000; 33 | app.listen(port, () => console.log(`server started, listening on port ${port}`)); 34 | -------------------------------------------------------------------------------- /src/assets/styles/base/mixins/_typography.scss: -------------------------------------------------------------------------------- 1 | // edit font rendering -> tip: use for light text on dark backgrounds 2 | @mixin fontSmooth { 3 | -webkit-font-smoothing: antialiased; 4 | -moz-osx-font-smoothing: grayscale; 5 | } 6 | 7 | //----------------------------------------------------------------------------// 8 | 9 | // https://css-tricks.com/snippets/css/fluid-typography/ 10 | @mixin fluid-type($min-vw, $max-vw, $min-font-size, $max-font-size) { 11 | $u1: unit($min-vw); 12 | $u2: unit($max-vw); 13 | $u3: unit($min-font-size); 14 | $u4: unit($max-font-size); 15 | 16 | @if $u1 == $u2 and $u1 == $u3 and $u1 == $u4 { 17 | & { 18 | --text-base-size: #{$min-font-size}; 19 | @media screen and (min-width: $min-vw) { 20 | --text-base-size: calc( 21 | #{$min-font-size} + #{strip-unit($max-font-size - $min-font-size)} * 22 | ((100vw - #{$min-vw}) / #{strip-unit($max-vw - $min-vw)}) 23 | ); 24 | } 25 | @media screen and (min-width: $max-vw) { 26 | --text-base-size: #{$max-font-size}; 27 | } 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Layout/Header/_styles.scss: -------------------------------------------------------------------------------- 1 | @import 'assets/styles/base/_mixins.scss'; 2 | 3 | .layoutheader { 4 | a, 5 | .link { 6 | --link: var(--link-inverted-active); 7 | --link-hover: var(--link-inverted-hover); 8 | --link-active: var(--link-inverted-active); 9 | --link-visited: var(--link-inverted-active); 10 | } 11 | 12 | &, 13 | &__content { 14 | display: flex; 15 | flex-direction: row; 16 | width: 100%; 17 | } 18 | 19 | justify-content: center; 20 | background-color: var(--header-bg); 21 | color: var(--header-color); 22 | 23 | &__content { 24 | justify-content: space-between; 25 | max-width: var(--max-app-width); 26 | padding: var(--space-md); 27 | 28 | @include breakpoint-max(sm) { 29 | padding: var(--space-lg) var(--space-md); 30 | } 31 | } 32 | 33 | &__title { 34 | font-size: var(--text-xxl); 35 | font-weight: var(--font-weight-bold); 36 | 37 | @include breakpoint-max(sm) { 38 | font-size: var(--text-xl); 39 | } 40 | 41 | @include breakpoint-max(xs) { 42 | font-size: var(--text-lg); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/assets/languages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "404.message": "You just hit a route that doesn't exist... the sadness.", 3 | "404.title": "404: Not found", 4 | "home.gobuild": "Now go build something great.", 5 | "home.greeting": "Hi people", 6 | "home.link-page2": "Go to page 2", 7 | "home.link-photos": "Go to photos", 8 | "home.pagetitle": "Home", 9 | "home.welcome": "Welcome to your new Gatsby site.", 10 | "language.selector.label": "Select another available language", 11 | "language.selector.option.english": "English", 12 | "language.selector.option.portuguese": "Portuguese", 13 | "language.selector.option.spanish": "Spanish", 14 | "layout.build-with": "Built with", 15 | "page2.link": "Go back to the homepage", 16 | "page2.title": "Hi from the second page", 17 | "page2.welcome": "Welcome to page 2", 18 | "photo.not-found": "Photo: {id} - Not Found", 19 | "photo.pagetitle": "Photo {id}", 20 | "photo.return-to-photos": "Return to photos", 21 | "photos.loading": "Loading...", 22 | "photos.loadmore": "Load more", 23 | "photos.pagetitle": "Photos" 24 | } -------------------------------------------------------------------------------- /src/components/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Layout component that queries for data 3 | * with Gatsby's useStaticQuery component 4 | * 5 | * See: https://www.gatsbyjs.org/docs/use-static-query/ 6 | */ 7 | 8 | import * as React from 'react'; 9 | import { useStaticQuery, graphql } from 'gatsby'; 10 | 11 | import Header from './Header'; 12 | import Footer from './Footer'; 13 | 14 | import './_styles.scss'; 15 | 16 | interface ILayout { 17 | children: React.ReactNode; 18 | displayFooter?: boolean; 19 | } 20 | 21 | const Layout: React.FunctionComponent = ({ children, displayFooter = true }) => { 22 | const data = useStaticQuery(graphql` 23 | query SiteTitleQuery { 24 | site { 25 | siteMetadata { 26 | title 27 | } 28 | } 29 | } 30 | `); 31 | 32 | return ( 33 |
34 |
35 |
{children}
36 | {displayFooter &&
} 37 |
38 | ); 39 | }; 40 | 41 | export default Layout; 42 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { FormattedMessage } from 'react-intl'; 3 | 4 | import Layout from 'components/Layout'; 5 | import SEO from 'components/SEO'; 6 | 7 | const NotFoundPage: React.FunctionComponent = () => { 8 | const [isInitialized, setIsInitialized] = React.useState(false); 9 | React.useEffect(() => { 10 | setIsInitialized(true); 11 | }); 12 | return ( 13 | 14 | {isInitialized && ( 15 | <> 16 | 17 | {(text) => } 18 | 19 |

20 | 21 |

22 |

23 | 27 |

28 | 29 | )} 30 |
31 | ); 32 | }; 33 | 34 | export default NotFoundPage; 35 | -------------------------------------------------------------------------------- /src/store/state/photos/operations.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from 'redux'; 2 | 3 | import { updatePaginationAttributes, hasMoreData } from 'helpers/fetch/pagination'; 4 | 5 | import * as API from 'data/api/photos'; 6 | 7 | import { photosFetching, photosFetchError, photosLoaded, photosReset } from './actions'; 8 | import { IPhotosAction, IPhotosLoadParams } from './definitions'; 9 | 10 | export const loadPhotos = (params?: IPhotosLoadParams) => async (dispatch: Dispatch) => { 11 | const { offset, page, countPerPage } = updatePaginationAttributes(params || { countPerPage: 50 }); 12 | dispatch(photosFetching()); 13 | try { 14 | const responseObject = await API.loadPhotos({ offset, page, countPerPage }); 15 | const { data: list, totalCount = 0 } = responseObject; 16 | const { length } = list; 17 | const hasMore = hasMoreData({ length, totalCount }); 18 | dispatch(photosLoaded({ list, hasMore, isFetching: false })); 19 | } catch (e) { 20 | dispatch(photosFetchError()); 21 | console.log(e); 22 | } 23 | }; 24 | 25 | export const resetPhotos = photosReset; 26 | -------------------------------------------------------------------------------- /src/assets/languages/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "404.message": "Acabas de llegar a una ruta que no existe... que tristeza.", 3 | "404.title": "404: No encontrado", 4 | "home.gobuild": "Ahora va a construir algo grande.", 5 | "home.greeting": "Hola gente", 6 | "home.link-page2": "Ir a la página 2", 7 | "home.link-photos": "Ir a las fotos", 8 | "home.pagetitle": "Casa", 9 | "home.welcome": "Bienvenido a tu nuevo sitio de Gatsby.", 10 | "language.selector.label": "Seleccione otro idioma disponible", 11 | "language.selector.option.english": "Inglés", 12 | "language.selector.option.portuguese": "Portugués", 13 | "language.selector.option.spanish": "Español", 14 | "layout.build-with": "Construido con", 15 | "page2.link": "Volver a la página de inicio", 16 | "page2.title": "Hola desde la segunda pagina", 17 | "page2.welcome": "Bienvenido a la página 2", 18 | "photo.not-found": "Foto: {id} - No encontrado", 19 | "photo.pagetitle": "Foto {id}", 20 | "photo.return-to-photos": "Volver a las fotos", 21 | "photos.loading": "Cargando...", 22 | "photos.loadmore": "Carga más", 23 | "photos.pagetitle": "Fotos" 24 | } -------------------------------------------------------------------------------- /src/assets/languages/pt.json: -------------------------------------------------------------------------------- 1 | { 2 | "404.message": "Você acabou de acertar uma rota que não existe... lamento.", 3 | "404.title": "404: Não encontrado...", 4 | "home.gobuild": "Agora vá construir algo ótimo.", 5 | "home.greeting": "Olá pessoal", 6 | "home.link-page2": "Vá para a página 2", 7 | "home.link-photos": "Ir para fotos", 8 | "home.pagetitle": "Home", 9 | "home.welcome": "Bem-vindo ao seu novo site do Gatsby.", 10 | "language.selector.label": "Selecione outro idioma disponível", 11 | "language.selector.option.english": "Inglês", 12 | "language.selector.option.portuguese": "Português", 13 | "language.selector.option.spanish": "Espanhol", 14 | "layout.build-with": "Construído com", 15 | "page2.link": "Volte para a página inicial", 16 | "page2.title": "Oi da segunda página", 17 | "page2.welcome": "Bem vindo a pagina 2", 18 | "photo.not-found": "Foto: {id} - Não encontrada", 19 | "photo.pagetitle": "Foto {id}", 20 | "photo.return-to-photos": "Retornar para fotos", 21 | "photos.loading": "Carregando...", 22 | "photos.loadmore": "Carregar mais", 23 | "photos.pagetitle": "Fotos" 24 | } -------------------------------------------------------------------------------- /src/components/Layout/Footer/_styles.scss: -------------------------------------------------------------------------------- 1 | @import 'assets/styles/base/_mixins.scss'; 2 | 3 | .layoutfooter { 4 | background-color: var(--footer-bg); 5 | color: var(--footer-color); 6 | 7 | a, 8 | .link { 9 | --link: var(--link-inverted); 10 | --link-hover: var(--link-inverted-hover); 11 | --link-active: var(--link-inverted-active); 12 | --link-visited: var(--link-inverted-visited); 13 | } 14 | &, 15 | &__content { 16 | display: flex; 17 | flex-direction: row; 18 | width: 100%; 19 | } 20 | 21 | justify-content: center; 22 | 23 | &__content { 24 | justify-content: space-between; 25 | max-width: var(--max-app-width); 26 | padding: var(--space-md); 27 | 28 | @include breakpoint-max(sm) { 29 | flex-direction: column; 30 | justify-content: flex-start; 31 | font-size: var(--text-sm); 32 | padding: var(--space-lg) var(--space-md); 33 | 34 | & > *:not(:first-child) { 35 | margin-top: var(--space-sm); 36 | } 37 | } 38 | } 39 | 40 | &__select { 41 | --languageselector-select-color: var(--footer-color); 42 | --languageselector-select-border: var(--footer-color); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/store/state/photos/reducers.ts: -------------------------------------------------------------------------------- 1 | import createReducer from 'store/helpers/createReducer'; 2 | import { IReducerMap } from 'store/helpers/definitions'; 3 | import { arrayMerge } from 'helpers/arrays'; 4 | import { PhotosActionTypes as Types, IPhotosAction, IPhotosState } from './definitions'; 5 | 6 | const initialState: IPhotosState = { 7 | list: [], 8 | hasMore: true, 9 | isFetching: false, 10 | }; 11 | 12 | const reducerMap: IReducerMap = { 13 | [Types.LOADED]: ({ list: previousList }, action) => { 14 | const { payload: { list, hasMore, isFetching } = initialState } = action; 15 | return { 16 | list: arrayMerge(previousList, list, 'id'), 17 | hasMore, 18 | isFetching, 19 | }; 20 | }, 21 | [Types.FETCHING]: (state) => { 22 | return { 23 | ...state, 24 | isFetching: true, 25 | }; 26 | }, 27 | [Types.FETCH_ERROR]: (state) => { 28 | return { 29 | ...state, 30 | isFetching: false, 31 | }; 32 | }, 33 | [Types.RESET]: () => initialState, 34 | }; 35 | 36 | const language = createReducer(initialState)(reducerMap); 37 | 38 | export default language; 39 | -------------------------------------------------------------------------------- /src/html.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default function HTML(props) { 5 | return ( 6 | 7 | 8 | 9 | 10 | 14 | {props.headComponents} 15 | 16 | 17 | {props.preBodyComponents} 18 | 21 |
22 | {props.postBodyComponents} 23 | 24 | 25 | ); 26 | } 27 | 28 | HTML.propTypes = { 29 | htmlAttributes: PropTypes.object, 30 | headComponents: PropTypes.array, 31 | bodyAttributes: PropTypes.object, 32 | preBodyComponents: PropTypes.array, 33 | body: PropTypes.string, 34 | postBodyComponents: PropTypes.array, 35 | }; 36 | -------------------------------------------------------------------------------- /src/helpers/console.ts: -------------------------------------------------------------------------------- 1 | //----------------------------------------------------------------------------// 2 | // Opting out of the "[React Intl] Missing message" in developement #465 3 | // https://github.com/formatjs/react-intl/issues/465#issuecomment-369566628 4 | 5 | // Disable missing translation message as translations will be added later. 6 | // We can add a toggle for this later when we have most translations. 7 | 8 | type TConsoleError = (message?: any, ...optionalParams: any[]) => void; 9 | 10 | // eslint-disable-next-line 11 | const consoleError: TConsoleError = console.error.bind(console); 12 | // eslint-disable-next-line 13 | console.error = (message: any, ...args: any[]) => { 14 | const strMessage = String(message); 15 | if ( 16 | strMessage.startsWith('Error: [@formatjs/intl Error MISSING_TRANSLATION] Missing message:') || 17 | strMessage.startsWith('Error: [@formatjs/intl Error MISSING_TRANSLATION] Missing locale data for locale:') 18 | ) { 19 | return; 20 | } 21 | consoleError(message, ...args); 22 | }; 23 | 24 | //----------------------------------------------------------------------------// 25 | 26 | // redefine the console object 27 | if (process.env.NODE_ENV === 'production') { 28 | console.log = () => undefined; 29 | } 30 | 31 | export default console; 32 | -------------------------------------------------------------------------------- /src/pages/page-2.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Link } from 'gatsby'; 3 | import { FormattedMessage } from 'react-intl'; 4 | 5 | import Layout from 'components/Layout'; 6 | import SEO from 'components/SEO'; 7 | 8 | const toJSON = (value: any) => JSON.stringify(value, null, 2); 9 | 10 | const SecondPage: React.FunctionComponent = () => { 11 | const [message, setMessage] = React.useState(''); 12 | 13 | const loadMessage = async () => { 14 | const newMessage = await import( 15 | /* webpackMode: "lazy", webpackChunkName: "hello-world" */ `assets/hello-world.json` 16 | ); 17 | setMessage(toJSON(newMessage.default)); 18 | }; 19 | 20 | React.useEffect(() => { 21 | loadMessage(); 22 | }, []); 23 | 24 | return ( 25 | 26 | 27 |

28 | 29 |

30 |

31 | 32 |

33 | 34 |
{message}
35 | 36 | 37 | 38 | 39 |
40 | ); 41 | }; 42 | 43 | export default SecondPage; 44 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | // Specifies the ESLint parser 4 | parser: '@typescript-eslint/parser', 5 | extends: ['plugin:react/recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], 6 | settings: { 7 | react: { 8 | version: 'detect', 9 | }, 10 | }, 11 | env: { 12 | browser: true, 13 | node: true, 14 | es6: true, 15 | }, 16 | plugins: ['@typescript-eslint', 'react', 'prettier'], 17 | parserOptions: { 18 | ecmaFeatures: { 19 | jsx: true, 20 | }, 21 | // Allows for the parsing of modern ECMAScript features 22 | ecmaVersion: 2018, 23 | // Allows for the use of imports 24 | sourceType: 'module', 25 | }, 26 | rules: { 27 | // Disable prop-types as we use TypeScript for type checking 28 | 'react/prop-types': 'off', 29 | 'prettier/prettier': 'error', 30 | '@typescript-eslint/explicit-function-return-type': 'off', 31 | '@typescript-eslint/explicit-module-boundary-types': 'off', 32 | '@typescript-eslint/interface-name-prefix': 'off', 33 | '@typescript-eslint/no-explicit-any': 'off', 34 | '@typescript-eslint/ban-ts-ignore': 'off', 35 | '@typescript-eslint/no-empty-interface': 'off', 36 | // needed for NextJS's jsx without react import 37 | 'react/react-in-jsx-scope': 'off', 38 | }, 39 | globals: { React: 'writable' }, 40 | }; 41 | -------------------------------------------------------------------------------- /scripts/translation-runner.js: -------------------------------------------------------------------------------- 1 | const { readFileSync } = require('fs'); 2 | const { 3 | default: manageTranslations, 4 | readMessageFiles, 5 | getDefaultMessages, 6 | } = require('react-intl-translations-manager'); 7 | 8 | const messagesDirectory = '.build/i18nExtractedMessages'; 9 | const defaultLanguage = 'en'; 10 | 11 | const readFile = (filename) => { 12 | try { 13 | return readFileSync(filename); 14 | } catch (error) { 15 | return undefined; 16 | } 17 | }; 18 | 19 | manageTranslations({ 20 | messagesDirectory: messagesDirectory, 21 | translationsDirectory: 'src/assets/languages/', 22 | whitelistsDirectory: 'src/assets/languages/whitelists/', 23 | languages: [defaultLanguage, 'es', 'pt'], 24 | overrideCoreMethods: { 25 | // based on: 26 | // https://github.com/GertjanReynaert/react-intl-translations-manager/issues/76#issuecomment-283186721 27 | provideWhitelistFile: (langResults) => { 28 | if (langResults.lang === defaultLanguage) { 29 | const messageFiles = readMessageFiles(messagesDirectory); 30 | const messages = getDefaultMessages(messageFiles).messages; 31 | return Object.keys(messages); 32 | } 33 | const jsonFile = readFile(langResults.whitelistFilepath); 34 | return jsonFile ? JSON.parse(jsonFile) : undefined; 35 | }, 36 | }, 37 | }); 38 | 39 | console.log('\ntranslation runner done\n\n'); 40 | -------------------------------------------------------------------------------- /src/assets/styles/base/_typography.scss: -------------------------------------------------------------------------------- 1 | $min_width: 500px; 2 | $max_width: 1280px; 3 | $min_font: 14px; 4 | $max_font: 16px; 5 | 6 | $text_opacity_active: 1; 7 | $text_opacity_visited: 0.7; 8 | $text_opacity_inactive: 0.6; 9 | $text_opacity_disable: 0.38; 10 | $text_opacity_error: 1; 11 | 12 | :root { 13 | @include fluid-type($min_width, $max_width, $min_font, $max_font); 14 | 15 | // https://websemantics.uk/articles/font-size-conversion/ 16 | --text-xxxl: #{pixels2rem(42px)}; 17 | --text-xxl: #{pixels2rem(32px)}; 18 | --text-xl: #{pixels2rem(24px)}; 19 | --text-lg: #{pixels2rem(18px)}; 20 | --text-md: #{pixels2rem(16px)}; 21 | --text-sm: #{pixels2rem(14px)}; 22 | --text-xs: #{pixels2rem(12px)}; 23 | 24 | --text-opacity-active: #{$text_opacity_active}; 25 | --text-opacity-visited: #{$text_opacity_visited}; 26 | --text-opacity-inactive: #{$text-opacity-inactive}; 27 | --text-opacity-disable: #{$text-opacity-disable}; 28 | --text-opacity-error: #{$text-opacity-error}; 29 | 30 | --heading-line-height: 1.2; 31 | 32 | --font-family: -apple-system, BlinkMacSystemFont, Roboto, 'Segoe UI', Helvetica, Arial, sans-serif, 33 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 34 | 35 | --font-family-code: 'SFMono-Regular', Consolas, 'Roboto Mono', 'Droid Sans Mono', 'Liberation Mono', Menlo, Courier, 36 | monospace; 37 | 38 | --font-weight-normal: 400; 39 | --font-weight-bold: 700; 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # .vscode 2 | .build 3 | static/generated 4 | src/assets/styles/generated 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (http://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Typescript v1 declaration files 45 | typings/ 46 | 47 | # Optional npm cache directory 48 | .npm 49 | 50 | # Optional eslint cache 51 | .eslintcache 52 | 53 | # Optional REPL history 54 | .node_repl_history 55 | 56 | # Output of 'npm pack' 57 | *.tgz 58 | 59 | # dotenv environment variables file 60 | .env 61 | .env.* 62 | !.env.example 63 | 64 | # gatsby files 65 | public 66 | .cache 67 | 68 | .husky 69 | 70 | # Mac files 71 | .DS_Store 72 | 73 | # Yarn 74 | yarn-error.log 75 | .pnp/ 76 | .pnp.js 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | moduleNameMapper: { 4 | '.+\\.(css|styl|less|sass|scss)$': `identity-obj-proxy`, 5 | '.+\\.svg$': `/__mocks__/svgr-mock.js`, 6 | '.+\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': `/__mocks__/file-mock.js`, 7 | }, 8 | moduleDirectories: ['node_modules', 'src'], 9 | testPathIgnorePatterns: ['node_modules', '.cache', 'public'], 10 | transformIgnorePatterns: ['node_modules/(?!(gatsby)/)'], 11 | globals: { 12 | __PATH_PREFIX__: ``, 13 | }, 14 | testURL: `http://localhost`, 15 | setupFilesAfterEnv: [`/jest-helpers/jest.setup.js`], 16 | setupFiles: [`/jest-helpers/loadershim.js`], 17 | cacheDirectory: `/.build/jest-temp/`, 18 | globals: { 19 | // we must specify a custom tsconfig for tests because we need the typescript transform 20 | // to transform jsx into js rather than leaving it jsx such as the next build requires. you 21 | // can see this setting in tsconfig.jest.json -> "jsx": "react" 22 | 'ts-jest': { 23 | tsConfig: `/jest-helpers/tsconfig.jest.json`, 24 | }, 25 | }, 26 | collectCoverage: true, 27 | collectCoverageFrom: [ 28 | 'src/**/*.{ts,tsx}', 29 | '!**/node_modules/**', 30 | '!**/vendor/**', 31 | '!src/helpers/{cssVariableTransform,language,console}.ts', 32 | ], 33 | coverageReporters: ['lcov', 'text-summary'], 34 | reporters: ['default', 'jest-junit'], 35 | }; 36 | -------------------------------------------------------------------------------- /src/data/api/photos/index.ts: -------------------------------------------------------------------------------- 1 | import buildRequest from 'helpers/fetch/buildRequest'; 2 | import { isNumber } from 'helpers/check'; 3 | import { IAPIResponse, APIResponseStatus, DEFAULT_PAGE_SIZE } from 'helpers/definitions'; 4 | import PhotoModel from 'data/models/Photos'; 5 | import { IPhotosParams, IPhotosAPIResponse, IPhotosResponse } from './definitions'; 6 | 7 | export const loadPhotos = async (parameters?: IPhotosParams): Promise => { 8 | let apiParams; 9 | if (parameters) { 10 | const { offset, page = 0, countPerPage = DEFAULT_PAGE_SIZE } = parameters; 11 | const _start = isNumber(offset) ? offset : page * countPerPage; 12 | apiParams = { _start, _limit: countPerPage }; 13 | } 14 | 15 | try { 16 | const responseObject = await buildRequest({ 17 | host: 'jsonplaceholder.typicode.com', 18 | urlPath: 'photos', 19 | parameters: apiParams, 20 | }); 21 | 22 | const { statusCode, headers, body } = responseObject; 23 | const xTotalCount = headers.get('x-total-count') || 0; 24 | const totalCount = Number(xTotalCount); 25 | const data = body.map((entity) => new PhotoModel(entity)); 26 | return { 27 | status: APIResponseStatus.SUCCESS, 28 | statusCode, 29 | data, 30 | totalCount, 31 | }; 32 | } catch (e) { 33 | const errorObject: IAPIResponse = e; 34 | const { statusCode, body } = errorObject; 35 | throw { 36 | status: APIResponseStatus.ERROR, 37 | statusCode, 38 | body, 39 | }; 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | 4 | import { Link } from 'gatsby'; 5 | import { FormattedMessage } from 'react-intl'; 6 | 7 | import Layout from 'components/Layout'; 8 | import GatsbyAstronoutImage from 'components/GatsbyAstronoutImage'; 9 | import SEO from 'components/SEO'; 10 | 11 | const IndexPage: React.FunctionComponent = () => { 12 | return ( 13 | 14 | 15 | {(text) => { 16 | const title = ReactDOMServer.renderToString(<>{text}); 17 | return ; 18 | }} 19 | 20 |

21 | 22 |

23 |

24 | 25 |

26 |

27 | 28 |

29 |
30 | 31 |
32 |
33 | 34 | 35 | {' '} 36 | {' | '} 37 | 38 | 39 | 40 |
41 |
42 | ); 43 | }; 44 | 45 | export default IndexPage; 46 | -------------------------------------------------------------------------------- /src/helpers/__tests__/console.ts: -------------------------------------------------------------------------------- 1 | describe('console', () => { 2 | describe('error', () => { 3 | beforeAll(() => { 4 | require('../console'); 5 | global.console = { ...global.console, error: jest.fn() }; 6 | }); 7 | 8 | it('should be call', () => { 9 | const message = 'hello'; 10 | console.error(message); 11 | expect(console.error).toBeCalledWith(message); 12 | 13 | console.error(message, 1, 2, 3); 14 | expect(console.error).toBeCalledWith(message, 1, 2, 3); 15 | }); 16 | 17 | it('should not be called', () => { 18 | expect(console.error('[React Intl] Missing message: ???')).toBeUndefined(); 19 | expect(console.error('[React Intl] Missing locale data for locale: ???')).toBeUndefined(); 20 | }); 21 | }); 22 | 23 | describe('log', () => { 24 | const OLD_ENV = process.env; 25 | 26 | beforeEach(() => { 27 | jest.resetModules(); // this is important - it clears the cache 28 | process.env = { ...OLD_ENV }; 29 | delete process.env.NODE_ENV; 30 | }); 31 | 32 | afterEach(() => { 33 | process.env = OLD_ENV; 34 | }); 35 | 36 | it('should not be called', () => { 37 | process.env.NODE_ENV = 'production'; 38 | require('../console'); 39 | expect(console.log()).toBeUndefined(); 40 | }); 41 | 42 | it('should call', () => { 43 | require('../console'); 44 | global.console = { ...global.console, log: jest.fn() }; 45 | const message = 'hello'; 46 | console.log(message); 47 | expect(console.log).toBeCalledWith(message); 48 | }); 49 | }); 50 | }); 51 | 52 | export {}; 53 | -------------------------------------------------------------------------------- /src/domains/Photos/View/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | 4 | import { FormattedMessage } from 'react-intl'; 5 | import { Link } from 'gatsby'; 6 | import { RouteComponentProps /*, Link*/ } from '@reach/router'; 7 | import { useSelector } from 'react-redux'; 8 | 9 | import { selectPhotoById } from 'store/state/photos/selectors'; 10 | 11 | import SEO from 'components/SEO'; 12 | 13 | import PhotoCard from 'domains/Photos/Card'; 14 | 15 | import './_styles.scss'; 16 | 17 | interface IPhotoViewParams { 18 | id: string; 19 | } 20 | 21 | export interface IPhotoView extends RouteComponentProps {} 22 | 23 | const PhotoView: React.FunctionComponent = ({ id }) => { 24 | const photo = useSelector(selectPhotoById(Number(id))); 25 | const info = photo ? photo.id : 'Not Found'; 26 | return ( 27 | <> 28 | 29 | {(text) => { 30 | const title = ReactDOMServer.renderToString(<>{text}); 31 | return ; 32 | }} 33 | 34 |
35 | {photo ? ( 36 | 37 | ) : ( 38 | 39 | )} 40 | 41 | 42 | 43 |
44 | 45 | ); 46 | }; 47 | 48 | export default PhotoView; 49 | -------------------------------------------------------------------------------- /scripts/font-generator.js: -------------------------------------------------------------------------------- 1 | const args = require('yargs').options({ 2 | fontname: { alias: 'f', describe: 'define the fontname', demandOption: true }, 3 | cssfontsurl: { 4 | alias: 'c', 5 | describe: "define the css fonts urls defined on the output styles file (default value: 'assets/icons/generated/')", 6 | demandOption: false, 7 | }, 8 | }).argv; 9 | 10 | const pkg = require('../package.json'); 11 | const pathPrefix = require('./get-path-prefix')(pkg); 12 | 13 | const [source] = args._; 14 | const { fontname, cssfontsurl = '/generated/fonticons/' } = args; 15 | 16 | if (!source || !fontname) { 17 | console.log(`Usage: ${args.$0} [source] -f [fontname]`); 18 | process.exit(); 19 | } 20 | 21 | const webfontsGenerator = require('webfonts-generator'); 22 | const fs = require('fs'); 23 | const path = require('path'); 24 | 25 | const svgRegExp = /\.svg$/; 26 | const destinationFolder = path.resolve(source); 27 | const iconsFolder = path.resolve('.build/icons/', 'compressed'); 28 | const destinationCss = path.join(destinationFolder, `${fontname}.scss`); 29 | 30 | fs.readdir(iconsFolder, (err, files) => { 31 | files = files.filter((file) => file.match(svgRegExp)); 32 | files = files.map((file) => path.join(iconsFolder, file)); 33 | 34 | if (err) { 35 | console.log(err); 36 | process.exit(); 37 | } 38 | 39 | webfontsGenerator( 40 | { 41 | files, 42 | dest: destinationFolder, 43 | fontName: fontname, 44 | cssDest: destinationCss, 45 | cssFontsUrl: `${pathPrefix}${cssfontsurl}`, 46 | }, 47 | function (err) { 48 | if (err) { 49 | console.log('Font generation failed.', err); 50 | } else { 51 | console.log('Font generation succesful.'); 52 | } 53 | } 54 | ); 55 | }); 56 | -------------------------------------------------------------------------------- /src/components/LanguageSelector/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import cn from 'clsx'; 3 | import { useSelector, useDispatch } from 'react-redux'; 4 | import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; 5 | import { changeLanguage } from 'store/state/language/operations'; 6 | import { selectLocale } from 'store/state/language/selectors'; 7 | 8 | import './_styles.scss'; 9 | 10 | const translations = defineMessages({ 11 | en: { id: 'language.selector.option.english', defaultMessage: 'English' }, 12 | es: { id: 'language.selector.option.spanish', defaultMessage: 'Spanish' }, 13 | pt: { id: 'language.selector.option.portuguese', defaultMessage: 'Portuguese' }, 14 | }); 15 | 16 | interface ILanguageSelectorProps { 17 | className?: string; 18 | label?: React.ReactNode; 19 | } 20 | 21 | const LanguageSelector: React.FunctionComponent = ({ 22 | className, 23 | label = , 24 | }) => { 25 | const intl = useIntl(); 26 | const dispatch = useDispatch(); 27 | const locale = useSelector(selectLocale); 28 | 29 | const handleChange = (event: React.ChangeEvent) => { 30 | dispatch(changeLanguage(event.target.value)); 31 | }; 32 | 33 | return ( 34 |
35 | {label &&
{label}
} 36 |
37 | 46 |
47 |
48 | ); 49 | }; 50 | 51 | export default LanguageSelector; 52 | -------------------------------------------------------------------------------- /src/components/SEO/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * SEO component that queries for data with 3 | * Gatsby's useStaticQuery React hook 4 | * 5 | * See: https://www.gatsbyjs.org/docs/use-static-query/ 6 | */ 7 | 8 | import * as React from 'react'; 9 | import Helmet from 'react-helmet'; 10 | import { useStaticQuery, graphql } from 'gatsby'; 11 | 12 | type TMetaProps = JSX.IntrinsicElements['meta']; 13 | 14 | interface ISEO { 15 | title: string; 16 | description?: string; 17 | lang?: string; 18 | meta?: TMetaProps[]; 19 | } 20 | 21 | const SEO: React.FunctionComponent = ({ description, lang, meta = [], title }) => { 22 | const { site } = useStaticQuery( 23 | graphql` 24 | query { 25 | site { 26 | siteMetadata { 27 | title 28 | description 29 | author 30 | } 31 | } 32 | } 33 | ` 34 | ); 35 | 36 | const metaDescription = description || site.siteMetadata.description; 37 | 38 | const baseMeta: TMetaProps[] = [ 39 | { 40 | name: `description`, 41 | content: metaDescription, 42 | }, 43 | { 44 | property: `og:title`, 45 | content: title, 46 | }, 47 | { 48 | property: `og:description`, 49 | content: metaDescription, 50 | }, 51 | { 52 | property: `og:type`, 53 | content: `website`, 54 | }, 55 | { 56 | name: `twitter:card`, 57 | content: `summary`, 58 | }, 59 | { 60 | name: `twitter:creator`, 61 | content: site.siteMetadata.author, 62 | }, 63 | { 64 | name: `twitter:title`, 65 | content: title, 66 | }, 67 | { 68 | name: `twitter:description`, 69 | content: metaDescription, 70 | }, 71 | ]; 72 | 73 | return ( 74 | 82 | ); 83 | }; 84 | 85 | SEO.defaultProps = { 86 | lang: `en`, 87 | meta: [] as TMetaProps[], 88 | description: ``, 89 | }; 90 | 91 | export default SEO; 92 | -------------------------------------------------------------------------------- /src/helpers/fetch/pagination.ts: -------------------------------------------------------------------------------- 1 | import { isUndefined, isNumber } from 'helpers/check'; 2 | import { IPaginationRequestParams } from 'helpers/definitions'; 3 | 4 | export interface IHasMoreDataOptions { 5 | offset?: number; 6 | previousLength?: number; 7 | length: number; 8 | totalCount: number; 9 | } 10 | 11 | /** 12 | * Check if there is more available data to be fetched on the API, the previous amount could be a previous count or 13 | * an offset value, if the offset is present that will be used to do the check 14 | * 15 | * @param {IHasMoreDataOptions} options 16 | * 17 | * @returns {boolean} boolean 18 | */ 19 | export const hasMoreData = (options: IHasMoreDataOptions): boolean => { 20 | const { offset, previousLength, length, totalCount } = options; 21 | 22 | if (isUndefined(offset) && isUndefined(previousLength) && isNumber(length) && isNumber(totalCount)) { 23 | return length > 0 && length < totalCount - 1; // totalCount - 1, because the JS Array first index is 0 24 | } 25 | 26 | if (isNumber(length) && isNumber(totalCount) && (isNumber(offset) || isNumber(previousLength))) { 27 | const currentTotal = (offset ? offset : previousLength || 0) + length; 28 | return length > 0 && currentTotal < totalCount - 1; // totalCount - 1, because the JS Array first index is 0 29 | } 30 | 31 | return false; 32 | }; 33 | 34 | /** 35 | * Process and update the pagination params attributes 36 | * 37 | * @param {T extends IPaginationRequestParams = IPaginationRequestParams} params 38 | * 39 | * @returns {T} processed params 40 | */ 41 | export const updatePaginationAttributes = ( 42 | params: T 43 | ): T => { 44 | const { countPerPage, previousTotalCount } = params; 45 | const { offset, page } = params; 46 | 47 | if (offset || page) { 48 | return params; 49 | } 50 | 51 | if (isNumber(previousTotalCount)) { 52 | if (isUndefined(offset) && isUndefined(page)) { 53 | params.page = Math.floor(previousTotalCount / countPerPage); 54 | return params; 55 | } 56 | } 57 | 58 | return { 59 | ...params, 60 | page: 0, 61 | }; 62 | }; 63 | 64 | export default { 65 | updatePaginationAttributes, 66 | hasMoreData, 67 | }; 68 | -------------------------------------------------------------------------------- /src/helpers/check.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { IProjectWindow, TRUTHY, TO_STRING, JSTypeof, TFunction } from './definitions'; 3 | 4 | // ---------------------------------------------------------------------------- // 5 | // @begin: pre defined values 6 | 7 | export const isProduction = process.env.NODE_ENV === 'production'; 8 | 9 | export const isDevelopment = process.env.NODE_ENV === 'development'; 10 | 11 | export const isBrowser = typeof window !== 'undefined'; 12 | 13 | const localWindow = isBrowser ? window : {}; 14 | const { CSS = {} } = localWindow as IProjectWindow; 15 | 16 | export const hasCssVariablesSupport = isBrowser && CSS && CSS.supports && CSS.supports('(--a: 0)'); 17 | 18 | // @end: pre defined values 19 | // ---------------------------------------------------------------------------- // 20 | // @begin: check values 21 | 22 | export const isFunction = (value: unknown): value is T => 23 | value !== null && typeof value === JSTypeof.FUNCTION; 24 | 25 | export const isUndefined = (value: unknown): value is T => 26 | value !== null && typeof value === JSTypeof.UNDEFINED; 27 | 28 | const isObjectBasicCheck = (value: unknown): value is T => 29 | value !== null && typeof value === JSTypeof.OBJECT; 30 | 31 | export const isObject = (value: unknown): value is T => 32 | isObjectBasicCheck(value) && TO_STRING.call(value) === '[object Object]'; 33 | 34 | export const isArray = (value: unknown): value is T => Array.isArray(value); 35 | 36 | export const isString = (value: unknown): value is T => 37 | value !== null && typeof value === JSTypeof.STRING && TO_STRING.call(value) === '[object String]'; 38 | 39 | export const isNumber = (value: unknown): value is T => 40 | value !== null && typeof value === JSTypeof.NUMBER; 41 | 42 | export const isTrue = (value: unknown): boolean => TRUTHY.test(String(value)) && !!value; 43 | 44 | export const isReactElement = (value: unknown): value is T => 45 | /* eslint-disable-next-line */ isObjectBasicCheck(value) && '$$typeof' in (value as any); 46 | 47 | export const isEmptyChildren = (value: React.ReactNode) => React.Children.count(value) === 0; 48 | 49 | // @end: check values 50 | // ---------------------------------------------------------------------------- // 51 | -------------------------------------------------------------------------------- /src/helpers/language.ts: -------------------------------------------------------------------------------- 1 | /* 2 | https://formatjs.io/docs/polyfills/intl-relativetimeformat/ 3 | */ 4 | import { shouldPolyfill } from '@formatjs/intl-relativetimeformat/should-polyfill'; 5 | 6 | import { ILocale, IProjectWindow } from './definitions'; 7 | import { isBrowser } from './check'; 8 | 9 | export const defaultLocale = 'en'; 10 | 11 | const { navigator = { language: defaultLocale } } = isBrowser ? (window as IProjectWindow) : {}; 12 | 13 | export const browserLanguage = navigator.language; 14 | 15 | export const browserLocale = (() => { 16 | let output = browserLanguage; 17 | if (/-|_/.test(output)) { 18 | ['-', '_'].forEach((languageSparator) => { 19 | if (output.lastIndexOf(languageSparator) !== -1) { 20 | output = output.split(languageSparator)[0]; 21 | } 22 | }); 23 | } 24 | return output; 25 | })(); 26 | 27 | const polyfill = async (locale?: string) => { 28 | if (!shouldPolyfill()) return; 29 | 30 | await import('@formatjs/intl-relativetimeformat/polyfill'); 31 | 32 | switch (locale) { 33 | case 'pt': 34 | await import( 35 | /* webpackMode: "lazy", webpackChunkName: "language_pt" */ `@formatjs/intl-relativetimeformat/locale-data/pt` 36 | ); 37 | break; 38 | case 'es': 39 | await import( 40 | /* webpackMode: "lazy", webpackChunkName: "language_es" */ `@formatjs/intl-relativetimeformat/locale-data/es` 41 | ); 42 | break; 43 | case 'en': 44 | default: 45 | await import( 46 | /* webpackMode: "lazy", webpackChunkName: "language_en" */ `@formatjs/intl-relativetimeformat/locale-data/en` 47 | ); 48 | break; 49 | } 50 | }; 51 | 52 | export async function loadLocale(locale?: string): Promise { 53 | let messages; 54 | 55 | await polyfill(locale); 56 | 57 | switch (locale) { 58 | case 'pt': 59 | messages = (await import(/* webpackMode: "lazy", webpackChunkName: "language_pt" */ `assets/languages/pt.json`)) 60 | .default; 61 | return { locale, messages }; 62 | case 'es': 63 | messages = (await import(/* webpackMode: "lazy", webpackChunkName: "language_es" */ `assets/languages/es.json`)) 64 | .default; 65 | return { locale, messages }; 66 | case 'en': 67 | default: 68 | locale = 'en'; 69 | messages = (await import(/* webpackMode: "lazy", webpackChunkName: "language_en" */ `assets/languages/en.json`)) 70 | .default; 71 | return { locale, messages }; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /gatsby-config.js: -------------------------------------------------------------------------------- 1 | const postcssPresetEnv = require('postcss-preset-env'); 2 | 3 | const { activeEnv } = require('./scripts/load-dotenv-config'); 4 | 5 | const gatsbyConfig = { 6 | siteMetadata: { 7 | title: `Gatsby TypeScript Application Starter`, 8 | description: `Kick off your next, great Gatsby typescript application project with this default starter. This barebones starter ships with the main Gatsby configuration files you might need.`, 9 | author: `@erkobridee`, 10 | }, 11 | plugins: [ 12 | `gatsby-plugin-react-helmet`, 13 | `gatsby-plugin-image`, 14 | { 15 | resolve: `gatsby-source-filesystem`, 16 | options: { 17 | name: `images`, 18 | path: `${__dirname}/src/assets/images`, 19 | }, 20 | }, 21 | `gatsby-plugin-sharp`, 22 | `gatsby-transformer-sharp`, 23 | { 24 | resolve: `gatsby-plugin-manifest`, 25 | options: { 26 | name: `gatsby-typescript-app-starter`, 27 | short_name: `ts-starter`, 28 | start_url: `.`, 29 | background_color: `#ffffff`, 30 | theme_color: `#6d2f9c`, 31 | display: `minimal-ui`, 32 | icon: `src/assets/images/gatsby-icon.png`, // This path is relative to the root of the site. 33 | }, 34 | }, 35 | { 36 | resolve: `gatsby-plugin-sass`, 37 | options: { 38 | postCssPlugins: [postcssPresetEnv()], 39 | }, 40 | }, 41 | { 42 | resolve: `gatsby-plugin-create-client-paths`, 43 | options: { prefixes: [`/photos/*`] }, 44 | }, 45 | ], 46 | }; 47 | 48 | const pkg = require('./package.json'); 49 | const pathPrefix = require('./scripts/get-path-prefix')(pkg); 50 | if (pathPrefix) { 51 | gatsbyConfig.pathPrefix = pathPrefix; 52 | 53 | // this (optional) plugin enables Progressive Web App + Offline functionality 54 | gatsbyConfig.plugins.push({ 55 | resolve: `gatsby-plugin-offline`, 56 | options: { 57 | modifyUrlPrefix: { 58 | '/': `${pathPrefix}/`, 59 | }, 60 | }, 61 | }); 62 | } else { 63 | // this (optional) plugin enables Progressive Web App + Offline functionality 64 | gatsbyConfig.plugins.push(`gatsby-plugin-offline`); 65 | } 66 | 67 | const isDevelopment = activeEnv === 'dev' || activeEnv === 'development'; 68 | const proxyUrl = process.env.PROXY_URL || undefined; 69 | if (isDevelopment && proxyUrl) { 70 | const proxyPrefix = process.env.API_PREFIX || 'api'; 71 | gatsbyConfig.proxy = { 72 | prefix: `/${proxyPrefix}`, 73 | url: proxyUrl, 74 | }; 75 | } 76 | 77 | module.exports = gatsbyConfig; 78 | -------------------------------------------------------------------------------- /src/domains/Photos/List/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | 4 | import { useSelector, useDispatch } from 'react-redux'; 5 | import { FormattedMessage } from 'react-intl'; 6 | import { RouteComponentProps /*, navigate*/ } from '@reach/router'; 7 | import { /*withPrefix,*/ navigate } from 'gatsby'; 8 | 9 | import { selectPhotosList, selectHasMore, selectIsFetching } from 'store/state/photos/selectors'; 10 | import { loadPhotos } from 'store/state/photos/operations'; 11 | import PhotoModel from 'data/models/Photos'; 12 | 13 | import SEO from 'components/SEO'; 14 | import { Tiles, Tile } from 'components/ui/Tiles'; 15 | 16 | import PhotoCard from 'domains/Photos/Card'; 17 | 18 | import './_styles.scss'; 19 | 20 | const countPerPage = 18; 21 | 22 | export interface IPhotosList extends RouteComponentProps {} 23 | 24 | const PhotosList: React.FunctionComponent = () => { 25 | const dispatch = useDispatch(); 26 | const photosList = useSelector(selectPhotosList); 27 | const hasMore = useSelector(selectHasMore); 28 | const isFetching = useSelector(selectIsFetching); 29 | 30 | React.useEffect(() => { 31 | dispatch(loadPhotos({ page: 0, countPerPage, previousTotalCount: photosList.length })); 32 | }, []); 33 | 34 | // const navigateToPhoto = (id: number) => () => navigate(`/gatsby-typescript-app-starter/photos/${id}`); 35 | const navigateToPhoto = (id: number) => () => navigate(`/photos/${id}`); 36 | 37 | const buildTileElement = (item: PhotoModel, index: number) => ( 38 | 39 | 40 | 41 | ); 42 | 43 | const onLoadMoreClick = () => { 44 | dispatch(loadPhotos({ countPerPage, previousTotalCount: photosList.length })); 45 | }; 46 | 47 | return ( 48 | <> 49 | 50 | {(text) => { 51 | const title = ReactDOMServer.renderToString(<>{text}); 52 | return ; 53 | }} 54 | 55 | 56 |
57 | {photosList.map(buildTileElement)} 58 | 59 | {!isFetching && hasMore && ( 60 |
61 | 64 |
65 | )} 66 | 67 | {isFetching && ( 68 |
69 | 70 |
71 | )} 72 |
73 | 74 | ); 75 | }; 76 | 77 | export default PhotosList; 78 | -------------------------------------------------------------------------------- /src/helpers/values.ts: -------------------------------------------------------------------------------- 1 | import { isObject, isArray } from './check'; 2 | 3 | //----------------------------------------------------------------------------// 4 | 5 | export const getPropertyValue = ( 6 | object: TObject, 7 | propertyName: Key 8 | ): TObject[Key] => object[propertyName]; 9 | 10 | export const setPropertyValue = ( 11 | object: TObject, 12 | propertyName: Key, 13 | value: TValue 14 | ): TObject => { 15 | object[propertyName] = value; 16 | return object; 17 | }; 18 | 19 | //----------------------------------------------------------------------------// 20 | 21 | export const randomFloat = (min: number, max: number): number => Math.random() * (max - min) + min; 22 | 23 | export const randomDecimalString = (min: number, max: number, fixed = 2): string => 24 | randomFloat(min, max).toFixed(fixed); 25 | 26 | export const randomDecimal = (min: number, max: number, fixed = 2): number => +randomDecimalString(min, max, fixed); 27 | 28 | export const randomInt = (min: number, max: number): number => Math.floor(randomFloat(min, max)); 29 | 30 | export const randomIntAsString = (min: number, max: number, padStart = 2): string => { 31 | return String(randomInt(min, max)).padStart(padStart, '0'); 32 | }; 33 | 34 | export const randomBoolean = (): boolean => randomInt(0, 1) === 0; 35 | 36 | export const randomValue = (options: T | T[], inNumber?: number): T | undefined => { 37 | if (!isObject(options) && !isArray(options)) { 38 | return; 39 | } 40 | if (isObject(options)) { 41 | options = Object.values(options as any); 42 | } 43 | inNumber = Math.min(inNumber || Number.MAX_SAFE_INTEGER, options.length); 44 | return options[randomInt(0, inNumber - 1)]; 45 | }; 46 | 47 | //----------------------------------------------------------------------------// 48 | 49 | export const calculateAvailablePages = (data: T[], pageSize = 25): number => 50 | Math.ceil(data.length / pageSize); 51 | 52 | export const paginate = (data: T[], start = 0, pageSize = 25): T[] => { 53 | const { length } = data; 54 | if (length === 0 || start > length) { 55 | return []; 56 | } 57 | 58 | if (start + pageSize > length) { 59 | return data.slice(start, length); 60 | } 61 | 62 | return data.slice(start, start + pageSize); 63 | }; 64 | 65 | export const randomSetOfData = (data: T[], amount: number = randomInt(1, 10)): T[] => { 66 | const pagesAmount = calculateAvailablePages(data, amount); 67 | const randomPage = randomInt(1, pagesAmount); 68 | const pageStart = (randomPage - 1) * amount; 69 | return paginate(data, pageStart, amount); 70 | }; 71 | 72 | export const randomAmountOfData = (data: T[], amount: number = randomInt(1, 10)): T[] => { 73 | const output: T[] = []; 74 | if (data.length === 0) { 75 | return output; 76 | } 77 | data = [...data]; 78 | while (output.length < amount) { 79 | const index = randomInt(0, data.length - 1); 80 | output.push(data[index]); 81 | data.splice(index, 1); 82 | data = [...data]; 83 | } 84 | return output; 85 | }; 86 | 87 | //----------------------------------------------------------------------------// 88 | -------------------------------------------------------------------------------- /src/assets/styles/base/_reset.scss: -------------------------------------------------------------------------------- 1 | * { 2 | &, 3 | &:before, 4 | &:after { 5 | box-sizing: inherit; 6 | } 7 | 8 | font: inherit; 9 | outline: none; 10 | } 11 | 12 | html, 13 | body, 14 | div, 15 | span, 16 | applet, 17 | object, 18 | iframe, 19 | h1, 20 | h2, 21 | h3, 22 | h4, 23 | h5, 24 | h6, 25 | p, 26 | blockquote, 27 | pre, 28 | a, 29 | abbr, 30 | acronym, 31 | address, 32 | big, 33 | cite, 34 | code, 35 | del, 36 | dfn, 37 | em, 38 | img, 39 | ins, 40 | kbd, 41 | q, 42 | s, 43 | samp, 44 | small, 45 | strike, 46 | strong, 47 | sub, 48 | sup, 49 | tt, 50 | var, 51 | b, 52 | u, 53 | i, 54 | center, 55 | dl, 56 | dt, 57 | dd, 58 | ol, 59 | ul, 60 | li, 61 | fieldset, 62 | form, 63 | label, 64 | legend, 65 | table, 66 | caption, 67 | tbody, 68 | tfoot, 69 | thead, 70 | tr, 71 | th, 72 | td, 73 | article, 74 | aside, 75 | canvas, 76 | details, 77 | embed, 78 | figure, 79 | figcaption, 80 | footer, 81 | header, 82 | hgroup, 83 | menu, 84 | nav, 85 | output, 86 | ruby, 87 | section, 88 | summary, 89 | time, 90 | mark, 91 | audio, 92 | video, 93 | hr { 94 | margin: 0; 95 | padding: 0; 96 | border: 0; 97 | } 98 | 99 | html { 100 | box-sizing: border-box; 101 | } 102 | 103 | body { 104 | background-color: var(--body-bg, white); 105 | } 106 | 107 | article, 108 | aside, 109 | details, 110 | figcaption, 111 | figure, 112 | footer, 113 | header, 114 | hgroup, 115 | menu, 116 | nav, 117 | section, 118 | main, 119 | form legend { 120 | display: block; 121 | } 122 | 123 | ol, 124 | ul { 125 | list-style: none; 126 | } 127 | 128 | blockquote, 129 | q { 130 | quotes: none; 131 | } 132 | 133 | button, 134 | input, 135 | textarea, 136 | select { 137 | margin: 0; 138 | } 139 | 140 | .btn, 141 | .form-control, 142 | .link, 143 | .reset { 144 | @include reset; 145 | } 146 | 147 | select.form-control::-ms-expand { 148 | display: none; // hide Select default icon on IE 149 | } 150 | 151 | textarea { 152 | resize: vertical; 153 | overflow: auto; 154 | vertical-align: top; 155 | } 156 | 157 | input::-ms-clear { 158 | display: none; // hide X icon in IE and Edge 159 | } 160 | 161 | table { 162 | border-collapse: collapse; 163 | border-spacing: 0; 164 | } 165 | 166 | img, 167 | video, 168 | svg { 169 | max-width: 100%; 170 | } 171 | 172 | //----------------------------------------------------------------------------// 173 | 174 | [type='reset'], 175 | [type='submit'], 176 | button, 177 | html [type='button'] { 178 | -webkit-appearance: button; 179 | } 180 | 181 | [type='button']::-moz-focus-inner, 182 | [type='reset']::-moz-focus-inner, 183 | [type='submit']::-moz-focus-inner, 184 | button::-moz-focus-inner { 185 | border-style: none; 186 | padding: 0; 187 | } 188 | 189 | [type='button']:-moz-focusring, 190 | [type='reset']:-moz-focusring, 191 | [type='submit']:-moz-focusring, 192 | button:-moz-focusring { 193 | outline: 1px dotted ButtonText; 194 | } 195 | 196 | [type='checkbox'], 197 | [type='radio'] { 198 | box-sizing: border-box; 199 | padding: 0; 200 | } 201 | 202 | [type='number']::-webkit-inner-spin-button, 203 | [type='number']::-webkit-outer-spin-button { 204 | height: auto; 205 | } 206 | 207 | [type='search'] { 208 | -webkit-appearance: textfield; 209 | outline-offset: -2px; 210 | } 211 | 212 | [type='search']::-webkit-search-cancel-button, 213 | [type='search']::-webkit-search-decoration { 214 | -webkit-appearance: none; 215 | } 216 | 217 | ::-webkit-input-placeholder { 218 | color: inherit; 219 | opacity: 0.54; 220 | } 221 | 222 | ::-webkit-file-upload-button { 223 | -webkit-appearance: button; 224 | font: inherit; 225 | } 226 | 227 | //----------------------------------------------------------------------------// 228 | -------------------------------------------------------------------------------- /src/helpers/definitions.ts: -------------------------------------------------------------------------------- 1 | // ---------------------------------------------------------------------------- // 2 | // @begin: constants block 3 | 4 | export const DEFAULT_PAGE_SIZE = 25; 5 | 6 | export const TO_STRING = {}.toString; 7 | 8 | export const TRUTHY = /^(?:t(?:rue)?|y(?:es)?|on|1)$/i; 9 | 10 | export const FALSY = /^(?:f(?:alse)?|no?|off|0)$/i; 11 | 12 | // @end: constants block 13 | // ---------------------------------------------------------------------------- // 14 | // @begin: enums block 15 | 16 | export enum JSTypeof { 17 | UNDEFINED = 'undefined', 18 | FUNCTION = 'function', 19 | OBJECT = 'object', 20 | STRING = 'string', 21 | NUMBER = 'number', 22 | } 23 | 24 | export enum RequestProtocol { 25 | HTTP = 'http', 26 | HTTPS = 'https', 27 | } 28 | 29 | /** 30 | * Defines HTTP Request Methods 31 | */ 32 | export enum RequestMethod { 33 | GET = 'GET', 34 | POST = 'POST', 35 | PUT = 'PUT', 36 | PATCH = 'PATCH', 37 | DELETE = 'DELETE', 38 | } 39 | 40 | /** 41 | * Defines request content types 42 | */ 43 | export enum RequestContentTypes { 44 | IMAGE = 'image/jpg, image/png', 45 | JSON = 'application/json', 46 | FORM = '', 47 | } 48 | 49 | export enum APIResponseStatus { 50 | SUCCESS = 'api:response::success', 51 | ERROR = 'api:response::error', 52 | } 53 | 54 | // @end: enums block 55 | // ---------------------------------------------------------------------------- // 56 | // @begin: types block 57 | 58 | export type TJSObject = Record; 59 | 60 | export type TJSValue = TJSObject | any; 61 | 62 | export type TArrayFilter = (item: Item) => boolean; 63 | 64 | export type TFunction = (...args: Tuple) => Return; 65 | 66 | export type TEmptyCallback = () => void; 67 | 68 | export type TCallback = 69 | | TFunction 70 | | TEmptyCallback; 71 | 72 | export type TTypeCallback = (options?: Options) => Return; 73 | 74 | export type TRender = TFunction<[RenderOptions], JSX.Element> | React.ReactNode; 75 | 76 | //--- 77 | 78 | /** 79 | * Possible request body to be sended to the API 80 | */ 81 | export type TRequestBody = string | FormData | undefined; 82 | 83 | export type TAPIResponse = IStringAPIResponse | IAPIResponse; 84 | 85 | // @end: types block 86 | // ---------------------------------------------------------------------------- // 87 | // @begin: interfaces block 88 | 89 | /* eslint-disable-next-line */ 90 | export interface IDictionary { 91 | [key: string]: T; 92 | } 93 | 94 | export interface IProjectWindow extends Window { 95 | /* eslint-disable-next-line */ 96 | CSS?: any; 97 | } 98 | 99 | export interface ILocale { 100 | locale: string; 101 | messages: IDictionary; 102 | } 103 | 104 | export interface IBaseModel { 105 | readonly __innerprops__: T; 106 | } 107 | 108 | export interface IBaseResponse { 109 | status: APIResponseStatus; 110 | statusCode: number; 111 | data: T; 112 | /** defined when the API has "pagination" support */ 113 | totalCount?: number; 114 | } 115 | 116 | export interface ICancelableBaseResponse { 117 | controller?: AbortController; 118 | request: TTypeCallback>; 119 | } 120 | 121 | /** 122 | * Base response object returned from the buildRequest 123 | */ 124 | export interface IAPIResponse { 125 | readonly statusCode: number; 126 | readonly headers: Headers; 127 | readonly body: T; 128 | 129 | // defined to be able to use the Pick 130 | // and read the generic type assigned to it 131 | __bodyType__?: T; 132 | } 133 | 134 | export interface IStringAPIResponse extends IAPIResponse {} 135 | 136 | export interface IPaginationRequestParams { 137 | offset?: number; 138 | page?: number; 139 | countPerPage: number; 140 | previousTotalCount?: number; 141 | } 142 | 143 | /** 144 | * Defines api request options that is used to build an api call. 145 | */ 146 | export interface IRequestOptions { 147 | /** like passing the access token with the value: `Bearer ${accessToken}` */ 148 | authorization?: string; 149 | contentType?: RequestContentTypes; 150 | protocol?: RequestProtocol | string; 151 | method?: RequestMethod; 152 | host?: string; 153 | api?: string; 154 | /** uses the values defined on the .env file for the host and api options */ 155 | useDefaultPrefix?: boolean; 156 | urlPath: string; 157 | variables?: IDictionary; 158 | parameters?: IDictionary; 159 | headers?: IDictionary; 160 | noCache?: boolean; 161 | signal?: AbortSignal; 162 | } 163 | 164 | // @end: interfaces block 165 | // ---------------------------------------------------------------------------- // 166 | -------------------------------------------------------------------------------- /src/helpers/fetch/buildRequest.ts: -------------------------------------------------------------------------------- 1 | import * as url from 'url'; 2 | import * as qs from 'qs'; 3 | import { 4 | IDictionary, 5 | RequestContentTypes, 6 | RequestMethod, 7 | TRequestBody, 8 | IRequestOptions, 9 | IAPIResponse, 10 | TAPIResponse, 11 | } from 'helpers/definitions'; 12 | import readImageBlobAsDataURL from './readImageBlobAsDataURL'; 13 | 14 | /** 15 | * Build an output response object that implements the IAPIResponse readonly attributes 16 | * 17 | * @param {Response} response 18 | * @param {any} body 19 | * 20 | * @returns {R} output response object 21 | */ 22 | function buildResponseOutput( 23 | response: Response, 24 | bodyObject: Pick = {} 25 | ): R { 26 | const { status, headers } = response; 27 | return { 28 | get statusCode() { 29 | return status; 30 | }, 31 | get headers() { 32 | return headers; 33 | }, 34 | get body() { 35 | return bodyObject; 36 | }, 37 | } as R; 38 | } 39 | 40 | /** 41 | * Build and handle a fetch request 42 | * 43 | * @param {IRequestOptions} options 44 | * 45 | * @returns {Promise} promise 46 | */ 47 | export async function buildRequest(options: IRequestOptions): Promise { 48 | let { parameters } = options; 49 | const { useDefaultPrefix = false } = options; 50 | const { 51 | authorization, 52 | method = RequestMethod.GET, 53 | contentType = RequestContentTypes.JSON, 54 | protocol, 55 | host = useDefaultPrefix ? process.env.API_HOST || undefined : undefined, 56 | api = useDefaultPrefix ? process.env.API_PREFIX || undefined : undefined, 57 | urlPath, 58 | variables = {}, 59 | headers: extraHeaders = {}, 60 | noCache, 61 | signal, 62 | } = options; 63 | 64 | /** 65 | * No cache parameter 66 | */ 67 | if (noCache) { 68 | parameters = { ...parameters, _: Math.random() }; 69 | } 70 | 71 | const getRequestURL = () => { 72 | const pathname = api ? `${api}/${urlPath}` : urlPath; 73 | if (!host) { 74 | return pathname; 75 | } 76 | 77 | return url.format({ 78 | ...{ 79 | slashes: true, 80 | host, 81 | pathname, 82 | }, 83 | ...(protocol ? { protocol } : {}), 84 | }); 85 | }; 86 | 87 | /** 88 | * Request URL is the URL that we will call and all the variable is replaced to values 89 | * @type {string} 90 | */ 91 | let requestUrl = getRequestURL(); 92 | 93 | /** 94 | * Replace url path variables, like {userId} 95 | */ 96 | for (const variable in variables) { 97 | if (variables.hasOwnProperty(variable)) { 98 | requestUrl = requestUrl.replace(`{${variable}}`, variables[variable]); 99 | } 100 | } 101 | 102 | /** 103 | * Prepare parameters that will be sent to the api. 104 | */ 105 | let body: TRequestBody; 106 | if (parameters) { 107 | switch (method) { 108 | case RequestMethod.GET: 109 | requestUrl += `?${qs.stringify(parameters)}`; 110 | break; 111 | default: 112 | switch (contentType) { 113 | case RequestContentTypes.JSON: 114 | body = JSON.stringify(parameters); 115 | break; 116 | case RequestContentTypes.FORM: 117 | body = new FormData(); 118 | for (const parameter in parameters) { 119 | if (parameters.hasOwnProperty(parameter)) { 120 | body.append(parameter, parameters[parameter]); 121 | } 122 | } 123 | break; 124 | } 125 | break; 126 | } 127 | } 128 | 129 | /** 130 | * Prepare headers 131 | */ 132 | const headers: IDictionary = { 133 | Accept: contentType, 134 | 'Content-Type': contentType, 135 | }; 136 | 137 | /** 138 | * If there is any authorization added to the header 139 | */ 140 | if (authorization) { 141 | headers.Authorization = authorization; 142 | } 143 | 144 | for (const extraHeader in extraHeaders) { 145 | if (extraHeaders.hasOwnProperty(extraHeader)) { 146 | headers[extraHeader] = extraHeaders[extraHeader]; 147 | } 148 | } 149 | 150 | /** 151 | * Do the fetch call and process its response 152 | */ 153 | try { 154 | const response = await fetch(requestUrl, { 155 | method, 156 | headers, 157 | body, 158 | signal, 159 | }); 160 | 161 | if (!response.ok) { 162 | throw response; 163 | } 164 | 165 | let outputObject = {}; 166 | if (contentType === RequestContentTypes.IMAGE) { 167 | const imageBlob = await response.blob(); 168 | const imageAsString = await readImageBlobAsDataURL(imageBlob); 169 | outputObject = imageAsString; 170 | } else { 171 | try { 172 | outputObject = await response.json(); 173 | } catch (e) { 174 | /* comment to fix the tslint empty block warning */ 175 | } 176 | } 177 | return buildResponseOutput(response, outputObject); 178 | } catch (error) { 179 | let finalError: R; 180 | try { 181 | finalError = await error.json(); 182 | } catch (e) { 183 | finalError = error; 184 | } 185 | throw buildResponseOutput(error, finalError); 186 | } 187 | } 188 | 189 | export default buildRequest; 190 | -------------------------------------------------------------------------------- /src/helpers/arrays.ts: -------------------------------------------------------------------------------- 1 | import { TArrayFilter, TJSValue } from './definitions'; 2 | import { isObject, isArray } from './check'; 3 | 4 | /** 5 | * array filter function to get the intersection 6 | * 7 | * @param {T[]} values 8 | * @param {string} byObjectProperty 9 | * 10 | * @return {TFilter} 11 | */ 12 | function filterIntersection(values: T[], byObjectProperty?: string): TArrayFilter { 13 | if (byObjectProperty) { 14 | return (x: T) => values.some((y) => (x as any)[byObjectProperty] === (y as any)[byObjectProperty]); 15 | } 16 | return (x: T) => values.includes(x); 17 | } 18 | 19 | /** 20 | * array filter function to get the difference 21 | * 22 | * @param {T[]} values 23 | * @param {string} byObjectProperty 24 | * 25 | * @return {TArrayFilter} 26 | */ 27 | function filterDifference(values: T[], byObjectProperty?: string): TArrayFilter { 28 | if (byObjectProperty) { 29 | return (x: T) => !values.some((y) => (x as any)[byObjectProperty] === (y as any)[byObjectProperty]); 30 | } 31 | return (x: T) => !values.includes(x); 32 | } 33 | 34 | // -------------------------------------------------------------------------- // 35 | 36 | /** 37 | * array find function 38 | * 39 | * @param {T} value 40 | * @param {string} byObjectProperty 41 | * 42 | * @return {TArrayFilter} 43 | */ 44 | function filterFindValue(value: T, byObjectProperty?: string): TArrayFilter { 45 | if (byObjectProperty) { 46 | return (x: T) => (x as any)[byObjectProperty] === (isObject(value) ? (value as any)[byObjectProperty] : value); 47 | } 48 | return (x: T) => x === value; 49 | } 50 | 51 | // -------------------------------------------------------------------------- // 52 | 53 | /** 54 | * from a given array get the unique elements from it 55 | * 56 | * @param {T[]} values 57 | * @param {string} byObjectProperty - optional parameter in case of array of objects 58 | * 59 | * @return {T[]} values without repetition 60 | */ 61 | function unique(values: T[], byObjectProperty?: string): T[] { 62 | return values.reduce((unique, item) => { 63 | return filterIntersection(unique, byObjectProperty)(item) ? unique : [...unique, item]; 64 | }, [] as T[]); 65 | } 66 | 67 | /** 68 | * get only the values present on both parameters arrays 69 | * 70 | * @param {T[]} arr1 71 | * @param {T[]} arr2 72 | * @param {string} byObjectProperty - optional parameter in case of array of objects 73 | * 74 | * @return {T[]} values present on both arrays 75 | */ 76 | function intersection(arr1: T[], arr2: T[], byObjectProperty?: string): T[] { 77 | return arr1.filter(filterIntersection(arr2, byObjectProperty)); 78 | } 79 | 80 | /** 81 | * check if the first parameter contains the values from the second parameter 82 | * 83 | * @param {T[]} sourceArray 84 | * @param {T | T[]} matchValue 85 | * @param {string} byObjectProperty - optional parameter in case of array of objects 86 | * 87 | * @return {boolean} flag 88 | */ 89 | function contains(sourceArray: T[], matchValue: T | T[], byObjectProperty?: string): boolean { 90 | if (isArray(matchValue)) { 91 | const arr2Lenght = matchValue.length; 92 | const arr3 = intersection(sourceArray, matchValue, byObjectProperty); 93 | return arr2Lenght === arr3.length; 94 | } 95 | return filterIntersection(sourceArray, byObjectProperty)(matchValue as T); 96 | } 97 | 98 | /** 99 | * get the different values present on the first parameter 100 | * 101 | * @param {T[]} sourceArray 102 | * @param {T[]} matchArray 103 | * @param {string} byObjectProperty - optional parameter in case of array of objects 104 | * 105 | * @return {T[]} 106 | */ 107 | function difference(sourceArray: T[], matchArray: T[], byObjectProperty?: string): T[] { 108 | return sourceArray.filter(filterDifference(matchArray, byObjectProperty)); 109 | } 110 | 111 | /** 112 | * get the different values present on both parameters arrays 113 | * 114 | * @param {T[]} arr1 115 | * @param {T[]} arr2 116 | * @param {string} byObjectProperty - optional parameter in case of array of objects 117 | * 118 | * @return {T[]} 119 | */ 120 | function symmetricDifference(arr1: T[], arr2: T[], byObjectProperty?: string): T[] { 121 | return arr1 122 | .filter(filterDifference(arr2, byObjectProperty)) 123 | .concat(arr2.filter(filterDifference(arr1, byObjectProperty))); 124 | } 125 | 126 | /** 127 | * find a value inside of the given values array 128 | * 129 | * @param {T[]} values 130 | * @param {T | string} value 131 | * @param {string} byObjectProperty - optional parameter in case of array of objects 132 | * 133 | * @return {T | undefined} value founded or undefined 134 | */ 135 | function find(values: T[], value: T | string, byObjectProperty?: string): T | undefined { 136 | return values.find(filterFindValue(value, byObjectProperty)); 137 | } 138 | 139 | /** 140 | * Merge two arrays of given type where will return a new array with the values updates 141 | * and the new non repeted elements added to it 142 | * 143 | * @param {T[]} sourceArray 144 | * @param {T[]} withArray 145 | * @param {string} byObjectProperty - optional parameter in case of array of objects 146 | */ 147 | function merge(sourceArray: T[], withArray: T[], byObjectProperty?: string): T[] { 148 | return [ 149 | ...sourceArray.map((item) => { 150 | const element = find(withArray, item, byObjectProperty); 151 | if (element) { 152 | return element; 153 | } 154 | return item; 155 | }), 156 | ...difference(withArray, sourceArray, byObjectProperty), 157 | ]; 158 | } 159 | 160 | // -------------------------------------------------------------------------- // 161 | 162 | export { 163 | unique as arrayUnique, 164 | intersection as arrayIntersection, 165 | contains as arrayContains, 166 | difference as arrayDifference, 167 | symmetricDifference as arraySymmetricDifference, 168 | find as arrayFind, 169 | merge as arrayMerge, 170 | }; 171 | 172 | // -------------------------------------------------------------------------- // 173 | 174 | export default { 175 | unique, 176 | intersection, 177 | contains, 178 | difference, 179 | symmetricDifference, 180 | find, 181 | merge, 182 | }; 183 | -------------------------------------------------------------------------------- /src/helpers/__tests__/check.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as Check from '../check'; 3 | 4 | describe('helpers/check', () => { 5 | describe('isFunction', () => { 6 | it('should be a function', () => { 7 | expect(Check.isFunction(() => undefined)).toBeTruthy(); 8 | }); 9 | 10 | it('should not be a function', () => { 11 | expect(Check.isFunction({})).toBeFalsy(); 12 | expect(Check.isFunction(null)).toBeFalsy(); 13 | expect(Check.isFunction(undefined)).toBeFalsy(); 14 | expect(Check.isFunction('')).toBeFalsy(); 15 | expect(Check.isFunction(123)).toBeFalsy(); 16 | }); 17 | }); 18 | 19 | describe('isUndefined', () => { 20 | it('should be undefined', () => { 21 | expect(Check.isUndefined(undefined)).toBeTruthy(); 22 | }); 23 | 24 | it('should not be undefined', () => { 25 | expect(Check.isUndefined(1)).toBeFalsy(); 26 | expect(Check.isUndefined('')).toBeFalsy(); 27 | expect(Check.isUndefined(null)).toBeFalsy(); 28 | expect(Check.isUndefined({})).toBeFalsy(); 29 | expect(Check.isUndefined([])).toBeFalsy(); 30 | expect(Check.isUndefined(() => undefined)).toBeFalsy(); 31 | }); 32 | }); 33 | 34 | describe('isObject', () => { 35 | it('should be an object', () => { 36 | expect(Check.isObject({})).toBeTruthy(); 37 | expect(Check.isObject({ a: 'a', number: 1, boolean: true, array: [1, 2, 3] })).toBeTruthy(); 38 | }); 39 | 40 | it('should not be an object', () => { 41 | expect(Check.isObject(() => undefined)).toBeFalsy(); 42 | expect(Check.isObject(null)).toBeFalsy(); 43 | expect(Check.isObject(undefined)).toBeFalsy(); 44 | expect(Check.isObject(123)).toBeFalsy(); 45 | expect(Check.isObject('')).toBeFalsy(); 46 | }); 47 | }); 48 | 49 | describe('isArray', () => { 50 | it('should be an array', () => { 51 | expect(Check.isArray([])).toBeTruthy(); 52 | expect(Check.isArray([1, 2, 3])).toBeTruthy(); 53 | expect(Check.isArray(['a', 'b', 'c'])).toBeTruthy(); 54 | expect(Check.isArray(['', 1, NaN, undefined, null, {}, []])).toBeTruthy(); 55 | }); 56 | 57 | it('should not be an array', () => { 58 | expect(Check.isArray('hello')).toBeFalsy(); 59 | expect(Check.isArray({})).toBeFalsy(); 60 | expect(Check.isArray(null)).toBeFalsy(); 61 | expect(Check.isArray(undefined)).toBeFalsy(); 62 | expect(Check.isArray(123)).toBeFalsy(); 63 | }); 64 | }); 65 | 66 | describe('isString', () => { 67 | it('should be a string', () => { 68 | expect(Check.isString('')).toBeTruthy(); 69 | }); 70 | 71 | it('should not be a string', () => { 72 | expect(Check.isString(123)).toBeFalsy(); 73 | expect(Check.isString({})).toBeFalsy(); 74 | expect(Check.isString([])).toBeFalsy(); 75 | expect(Check.isString(null)).toBeFalsy(); 76 | expect(Check.isString(undefined)).toBeFalsy(); 77 | expect(Check.isString(() => undefined)).toBeFalsy(); 78 | }); 79 | }); 80 | 81 | describe('isNumber', () => { 82 | it('should be a number', () => { 83 | expect(Check.isNumber(123)).toBeTruthy(); 84 | }); 85 | 86 | it('should not be a number', () => { 87 | expect(Check.isNumber('')).toBeFalsy(); 88 | expect(Check.isNumber({})).toBeFalsy(); 89 | expect(Check.isNumber([])).toBeFalsy(); 90 | expect(Check.isNumber(null)).toBeFalsy(); 91 | expect(Check.isNumber(undefined)).toBeFalsy(); 92 | expect(Check.isNumber(() => undefined)).toBeFalsy(); 93 | }); 94 | }); 95 | 96 | describe('isTrue', () => { 97 | it('should be true', () => { 98 | expect(Check.isTrue(1)).toBeTruthy(); 99 | expect(Check.isTrue('true')).toBeTruthy(); 100 | expect(Check.isTrue(true)).toBeTruthy(); 101 | }); 102 | 103 | it('should be false', () => { 104 | expect(Check.isTrue(0)).toBeFalsy(); 105 | expect(Check.isTrue('false')).toBeFalsy(); 106 | expect(Check.isTrue(false)).toBeFalsy(); 107 | expect(Check.isTrue(null)).toBeFalsy(); 108 | expect(Check.isTrue(undefined)).toBeFalsy(); 109 | expect(Check.isTrue(NaN)).toBeFalsy(); 110 | expect(Check.isTrue([])).toBeFalsy(); 111 | expect(Check.isTrue({})).toBeFalsy(); 112 | }); 113 | }); 114 | 115 | describe('isReactElement', () => { 116 | it('should not be', () => { 117 | expect(Check.isReactElement(() => undefined)).toBeFalsy(); 118 | expect(Check.isReactElement({})).toBeFalsy(); 119 | expect(Check.isReactElement([])).toBeFalsy(); 120 | expect(Check.isReactElement('')).toBeFalsy(); 121 | expect(Check.isReactElement(null)).toBeFalsy(); 122 | expect(Check.isReactElement(undefined)).toBeFalsy(); 123 | }); 124 | 125 | it('should be', () => { 126 | expect(Check.isReactElement(<>)).toBeTruthy(); 127 | expect(Check.isReactElement(
hello world
)).toBeTruthy(); 128 | expect(Check.isReactElement(hello world)).toBeTruthy(); 129 | }); 130 | }); 131 | 132 | describe('isEmptyChildren', () => { 133 | const getReactComponentChildren = (element: JSX.Element) => { 134 | const { props } = element; 135 | return props.children; 136 | }; 137 | 138 | it('should not be', () => { 139 | expect( 140 | Check.isEmptyChildren( 141 | getReactComponentChildren( 142 | <> 143 |
144 | 145 |

hello

146 |
147 |
148 | 149 | ) 150 | ) 151 | ).toBeFalsy(); 152 | expect( 153 | Check.isEmptyChildren( 154 | getReactComponentChildren( 155 |
156 | 157 |

hello

158 |
159 |
160 | ) 161 | ) 162 | ).toBeFalsy(); 163 | expect( 164 | Check.isEmptyChildren( 165 | getReactComponentChildren( 166 | 167 |

hello

168 |
169 | ) 170 | ) 171 | ).toBeFalsy(); 172 | }); 173 | 174 | it('should be', () => { 175 | expect(Check.isEmptyChildren(getReactComponentChildren(<>))).toBeTruthy(); 176 | expect(Check.isEmptyChildren(getReactComponentChildren(
))).toBeTruthy(); 177 | expect(Check.isEmptyChildren(getReactComponentChildren())).toBeTruthy(); 178 | }); 179 | }); 180 | }); 181 | -------------------------------------------------------------------------------- /src/helpers/__tests__/arrays.ts: -------------------------------------------------------------------------------- 1 | import { default as Arrays } from '../arrays'; 2 | 3 | describe('helpers/arrays', () => { 4 | describe('unique', () => { 5 | it('on plain arrays', () => { 6 | const valuesResultExpected = ['a', 'b', 'c']; 7 | const values = Arrays.unique(['a', 'a', 'b', 'b', 'b', 'c', 'c']); 8 | expect(values).toHaveLength(valuesResultExpected.length); 9 | expect(values).toMatchObject(valuesResultExpected); 10 | }); 11 | 12 | it('on object arrays', () => { 13 | const valuesResultExpected = [{ att: 'a' }, { att: 'b' }, { att: 'c' }]; 14 | const values = Arrays.unique( 15 | [{ att: 'a' }, { att: 'a' }, { att: 'b' }, { att: 'b' }, { att: 'c' }, { att: 'c' }], 16 | 'att' 17 | ); 18 | expect(values).toHaveLength(valuesResultExpected.length); 19 | expect(values).toMatchObject(valuesResultExpected); 20 | }); 21 | }); 22 | 23 | describe('intersection', () => { 24 | it('on plain arrays', () => { 25 | const valuesA = ['a', 'b', 'c', 'd']; 26 | const valuesB = ['c', 'd', 'e', 'f']; 27 | const valuesResultExpected = ['c', 'd']; 28 | 29 | const values = Arrays.intersection(valuesA, valuesB); 30 | expect(values).toHaveLength(valuesResultExpected.length); 31 | expect(values).toMatchObject(valuesResultExpected); 32 | }); 33 | 34 | it('on object arrays', () => { 35 | const valuesA = [{ att: 'a' }, { att: 'b' }, { att: 'c' }, { att: 'd' }]; 36 | const valuesB = [{ att: 'c' }, { att: 'd' }, { att: 'e' }, { att: 'f' }]; 37 | const valuesResultExpected = [{ att: 'c' }, { att: 'd' }]; 38 | 39 | const values = Arrays.intersection(valuesA, valuesB, 'att'); 40 | expect(values).toHaveLength(valuesResultExpected.length); 41 | expect(values).toMatchObject(valuesResultExpected); 42 | }); 43 | }); 44 | 45 | describe('contains', () => { 46 | describe('on plain arrays', () => { 47 | const values = ['a', 'b', 'c', 'd', 'e', 'f']; 48 | 49 | it('should contain a value', () => { 50 | const check = 'c'; 51 | expect(Arrays.contains(values, check)).toBeTruthy(); 52 | }); 53 | 54 | it('should contain a set of values', () => { 55 | const check = ['c', 'd']; 56 | expect(Arrays.contains(values, check)).toBeTruthy(); 57 | }); 58 | }); 59 | 60 | describe('on object arrays', () => { 61 | const values = [{ att: 'a' }, { att: 'b' }, { att: 'c' }, { att: 'd' }, { att: 'e' }, { att: 'f' }]; 62 | 63 | it('should contain a value', () => { 64 | const check = { att: 'c' }; 65 | expect(Arrays.contains(values, check, 'att')).toBeTruthy(); 66 | }); 67 | 68 | it('should contain a set of values', () => { 69 | const check = [{ att: 'c' }, { att: 'd' }]; 70 | expect(Arrays.contains(values, check, 'att')).toBeTruthy(); 71 | }); 72 | }); 73 | }); 74 | 75 | describe('difference', () => { 76 | it('on plain arrays', () => { 77 | const valuesA = ['a', 'b', 'c', 'd']; 78 | const valuesB = ['c', 'd', 'e', 'f']; 79 | const valuesResultExpected = ['a', 'b']; 80 | 81 | const values = Arrays.difference(valuesA, valuesB); 82 | expect(values).toHaveLength(valuesResultExpected.length); 83 | expect(values).toMatchObject(valuesResultExpected); 84 | }); 85 | 86 | it('on object arrays', () => { 87 | const valuesA = [{ att: 'a' }, { att: 'b' }, { att: 'c' }, { att: 'd' }]; 88 | const valuesB = [{ att: 'c' }, { att: 'd' }, { att: 'e' }, { att: 'f' }]; 89 | const valuesResultExpected = [{ att: 'a' }, { att: 'b' }]; 90 | 91 | const values = Arrays.difference(valuesA, valuesB, 'att'); 92 | expect(values).toHaveLength(valuesResultExpected.length); 93 | expect(values).toMatchObject(valuesResultExpected); 94 | }); 95 | }); 96 | 97 | describe('symmetricDifference', () => { 98 | it('on plain arrays', () => { 99 | const valuesA = ['a', 'b', 'c', 'd']; 100 | const valuesB = ['c', 'd', 'e', 'f']; 101 | const valuesResultExpected = ['a', 'b', 'e', 'f']; 102 | 103 | const values = Arrays.symmetricDifference(valuesA, valuesB); 104 | expect(values).toHaveLength(valuesResultExpected.length); 105 | expect(values).toMatchObject(valuesResultExpected); 106 | }); 107 | 108 | it('on object arrays', () => { 109 | const valuesA = [{ att: 'a' }, { att: 'b' }, { att: 'c' }, { att: 'd' }]; 110 | const valuesB = [{ att: 'c' }, { att: 'd' }, { att: 'e' }, { att: 'f' }]; 111 | const valuesResultExpected = [{ att: 'a' }, { att: 'b' }, { att: 'e' }, { att: 'f' }]; 112 | 113 | const values = Arrays.symmetricDifference(valuesA, valuesB, 'att'); 114 | expect(values).toHaveLength(valuesResultExpected.length); 115 | expect(values).toMatchObject(valuesResultExpected); 116 | }); 117 | }); 118 | 119 | describe('find', () => { 120 | it('on plain arrays', () => { 121 | const values = ['a', 'b', 'c']; 122 | 123 | expect(Arrays.find(values, 'a')).toBeDefined(); 124 | expect(Arrays.find(values, 'b')).toBeDefined(); 125 | expect(Arrays.find(values, 'c')).toBeDefined(); 126 | }); 127 | 128 | it('on object arrays', () => { 129 | const values = [{ att: 'a' }, { att: 'b' }, { att: 'c' }]; 130 | 131 | expect(Arrays.find(values, 'a', 'att')).toBeDefined(); 132 | expect(Arrays.find(values, 'b', 'att')).toBeDefined(); 133 | expect(Arrays.find(values, 'c', 'att')).toBeDefined(); 134 | }); 135 | }); 136 | 137 | describe('merge', () => { 138 | it('on plain arrays', () => { 139 | const valuesA = ['a', 'b', 'c', 'd']; 140 | const valuesB = ['c', 'd', 'e', 'f']; 141 | const valuesResultExpected = ['a', 'b', 'c', 'd', 'e', 'f']; 142 | 143 | const values = Arrays.merge(valuesA, valuesB); 144 | expect(values).toHaveLength(valuesResultExpected.length); 145 | expect(values).toMatchObject(valuesResultExpected); 146 | }); 147 | 148 | it('on object arrays', () => { 149 | const valuesA = [{ att: 'a' }, { att: 'b' }, { att: 'c' }, { att: 'd' }]; 150 | const valuesB = [{ att: 'c' }, { att: 'd' }, { att: 'e' }, { att: 'f' }]; 151 | const valuesResultExpected = [{ att: 'a' }, { att: 'b' }, { att: 'c' }, { att: 'd' }, { att: 'e' }, { att: 'f' }]; 152 | 153 | const values = Arrays.merge(valuesA, valuesB, 'att'); 154 | expect(values).toHaveLength(valuesResultExpected.length); 155 | expect(values).toMatchObject(valuesResultExpected); 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-typescript-app-starter", 3 | "private": true, 4 | "description": "A simple typescript application starter to get up and developing quickly with Gatsby", 5 | "version": "0.5.0", 6 | "author": "Erko Bridee ", 7 | "dependencies": { 8 | "abortcontroller-polyfill": "^1.7.1", 9 | "clsx": "^1.1.1", 10 | "css-vars-ponyfill": "^2.4.3", 11 | "qs": "^6.10.0", 12 | "react": "^17.0.1", 13 | "react-dom": "^17.0.1", 14 | "react-helmet": "^6.1.0", 15 | "react-intl": "^5.13.4", 16 | "react-redux": "^7.2.2", 17 | "redux": "^4.0.5", 18 | "redux-persist": "^6.0.0", 19 | "redux-thunk": "^2.3.0", 20 | "whatwg-fetch": "^3.6.2" 21 | }, 22 | "devDependencies": { 23 | "@babel/cli": "^7.13.10", 24 | "@babel/preset-env": "^7.13.10", 25 | "@babel/preset-react": "^7.12.13", 26 | "@formatjs/intl-relativetimeformat": "^8.1.3", 27 | "@testing-library/jest-dom": "^5.11.9", 28 | "@testing-library/react": "^11.2.5", 29 | "@types/jest": "^26.0.21", 30 | "@types/node": "^14.14.35", 31 | "@types/qs": "^6.9.6", 32 | "@types/reach__router": "^1.3.7", 33 | "@types/react": "^17.0.3", 34 | "@types/react-dom": "^17.0.2", 35 | "@types/react-helmet": "^6.1.0", 36 | "@types/react-redux": "^7.1.16", 37 | "@typescript-eslint/eslint-plugin": "^4.18.0", 38 | "@typescript-eslint/parser": "^4.18.0", 39 | "babel-jest": "^26.6.3", 40 | "babel-plugin-react-intl": "^5.1.2", 41 | "babel-preset-gatsby": "^1.1.0", 42 | "browserslist": "^4.16.3", 43 | "eslint": "^7.22.0", 44 | "eslint-config-prettier": "^8.1.0", 45 | "eslint-loader": "^4.0.2", 46 | "eslint-plugin-prettier": "^3.3.1", 47 | "eslint-plugin-react": "^7.22.0", 48 | "express": "^4.17.1", 49 | "fs-extra": "^9.0.0", 50 | "gatsby": "^3.1.1", 51 | "gatsby-plugin-create-client-paths": "^3.1.0", 52 | "gatsby-plugin-image": "^1.1.1", 53 | "gatsby-plugin-manifest": "^3.1.0", 54 | "gatsby-plugin-offline": "^4.1.0", 55 | "gatsby-plugin-react-helmet": "^4.1.0", 56 | "gatsby-plugin-sass": "^4.1.0", 57 | "gatsby-plugin-sharp": "^3.1.1", 58 | "gatsby-source-filesystem": "^3.1.0", 59 | "gatsby-transformer-sharp": "^3.1.0", 60 | "gh-pages": "^3.1.0", 61 | "husky": "^5.1.3", 62 | "identity-obj-proxy": "^3.0.0", 63 | "intl-pluralrules": "^1.2.2", 64 | "is-ci": "^3.0.0", 65 | "jest": "^26.6.3", 66 | "jest-junit": "^12.0.0", 67 | "lint-staged": "^10.5.4", 68 | "npm-check": "^5.9.2", 69 | "npm-run-all": "^4.1.5", 70 | "postcss-preset-env": "^6.7.0", 71 | "prettier": "^2.2.1", 72 | "react-intl-translations-manager": "^5.0.3", 73 | "request": "^2.88.0", 74 | "sass": "^1.32.8", 75 | "svgo": "^1.3.2", 76 | "ts-jest": "^26.5.4", 77 | "typescript": "^4.2.3", 78 | "undefined": "^0.1.0", 79 | "webfonts-generator": "^0.4.0", 80 | "yargs": "^15.3.1" 81 | }, 82 | "keywords": [ 83 | "gatsby", 84 | "starter", 85 | "sass", 86 | "typescript", 87 | "dotenv", 88 | "proxy support", 89 | "jest", 90 | "unit tests", 91 | "unit tests coverage" 92 | ], 93 | "license": "MIT", 94 | "scripts": { 95 | "postinstall": "is-ci || npx husky install", 96 | "fonticons:clean-scss": "node scripts/fs/rm src/assets/styles/generated/", 97 | "fonticons:clean-generated": "node scripts/fs/rm .build/icons/", 98 | "fonticons:clean-static": "node scripts/fs/rm static/generated/", 99 | "fonticons:clean": "run-p fonticons:clean-generated fonticons:clean-static fonticons:clean-scss", 100 | "fonticons:compress-mkdir": "node scripts/fs/mkdir .build/icons/compressed", 101 | "fonticons:compress-run": "svgo --config=svgo-config.json --enable=removeStyleElement --enable=removeScriptElement --enable=sortAttrs src/assets/icons/*.svg -o .build/icons/compressed > /dev/null", 102 | "fonticons:compress": "run-s fonticons:compress-mkdir fonticons:compress-run", 103 | "fonticons:generate": "node scripts/font-generator static/generated/fonticons/ -f fonticons", 104 | "fonticons:move-scss": "node scripts/fs/mv static/generated/fonticons/fonticons.scss src/assets/styles/generated/fonticons.scss", 105 | "fonticons": "run-s fonticons:clean fonticons:compress fonticons:generate fonticons:move-scss", 106 | "translations:extract": "TRANSLATIONS=true babel src --extensions '.ts,.tsx' --out-file .build/i18nExtractedMessages/compiled.js", 107 | "translations:runner": "node scripts/translation-runner.js", 108 | "translations": "run-s translations:extract translations:runner", 109 | "generate": "run-p fonticons translations", 110 | "format": "prettier --write '**/*.{js,jsx,ts,tsx,json,md}'", 111 | "lint": "eslint 'src/**/*.{ts,tsx}'", 112 | "type-check": "tsc", 113 | "gatsby-clean": "gatsby clean", 114 | "gatsby-develop": "gatsby develop -H 0.0.0.0", 115 | "gatsby-build": "gatsby build --prefix-paths", 116 | "gatsby-serve": "node scripts/proxied-http-server.js", 117 | "clean:build": "node scripts/fs/rm .build/", 118 | "clean:coverage": "node scripts/fs/rm coverage/", 119 | "clean": "run-p clean:coverage clean:build fonticons:clean gatsby-clean", 120 | "unit-tests": "jest --passWithNoTests", 121 | "check": "run-s type-check lint", 122 | "base": "run-s generate check", 123 | "develop": "run-s base gatsby-develop", 124 | "start": "npm run develop", 125 | "build": "run-s clean base unit-tests gatsby-build", 126 | "serve": "ACTIVE_ENV=development run-s build gatsby-serve", 127 | "gh-deploy": "gh-pages -d public", 128 | "deploy": "PREFIX_PATH=true run-s build gh-deploy", 129 | "upgrade-interactive": "npm-check --update" 130 | }, 131 | "lint-staged": { 132 | "src/**/*.{js,jsx,ts,tsx,md,html,css,scss}": "prettier --write", 133 | "src/**/*.{js,jsx,ts,tsx}": "eslint --fix" 134 | }, 135 | "husky": { 136 | "hooks": { 137 | "pre-commit": "lint-staged" 138 | } 139 | }, 140 | "browserslist": [ 141 | ">1%", 142 | "ie>=11", 143 | "iOS >= 9", 144 | "Android >= 4.4", 145 | "not op_mini all" 146 | ], 147 | "gatsby": { 148 | "pathPrefix": "gatsby-typescript-app-starter" 149 | }, 150 | "jest-junit": { 151 | "suiteName": "jest tests", 152 | "outputDirectory": "coverage", 153 | "outputName": "junit.xml", 154 | "uniqueOutputName": "false", 155 | "classNameTemplate": "{classname}", 156 | "titleTemplate": "{title}", 157 | "ancestorSeparator": " › ", 158 | "usePathForSuiteName": "true" 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/assets/styles/base/_html-elements.scss: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: var(--text-base-size); 3 | font-family: var(--font-family); 4 | text-size-adjust: 100%; 5 | overflow-y: scroll; 6 | -webkit-overflow-scrolling: touch; 7 | } 8 | 9 | body { 10 | @include fontSmooth; 11 | background-color: var(--body-bg, #{color(color-white)}); 12 | color: var(--body-color, #{color(color-black)}); 13 | font-weight: var(--font-weight-normal); 14 | font-size: var(--text-md); 15 | word-wrap: break-word; 16 | font-kerning: normal; 17 | font-feature-settings: 'kern', 'liga', 'clig', 'calt'; 18 | } 19 | 20 | article, 21 | aside, 22 | details, 23 | figcaption, 24 | figure, 25 | footer, 26 | header, 27 | main, 28 | menu, 29 | nav, 30 | section, 31 | summary { 32 | display: block; 33 | } 34 | 35 | audio, 36 | canvas, 37 | progress, 38 | video { 39 | display: inline-block; 40 | } 41 | 42 | audio:not([controls]) { 43 | display: none; 44 | height: 0; 45 | } 46 | 47 | progress { 48 | vertical-align: baseline; 49 | } 50 | 51 | [hidden], 52 | template { 53 | display: none; 54 | } 55 | 56 | a { 57 | background-color: transparent; 58 | text-decoration-skip: objects; 59 | } 60 | 61 | abbr[title] { 62 | border-bottom: none; 63 | text-decoration: underline; 64 | text-decoration: underline dotted; 65 | } 66 | 67 | dfn { 68 | font-style: italic; 69 | } 70 | 71 | mark { 72 | background-color: #{color(color-yellow)}; 73 | color: #{color(color-black)}; 74 | } 75 | 76 | small { 77 | font-size: 80%; 78 | } 79 | 80 | sub, 81 | sup { 82 | font-size: 75%; 83 | line-height: 0; 84 | position: relative; 85 | vertical-align: baseline; 86 | } 87 | sub { 88 | bottom: -0.25em; 89 | } 90 | 91 | sup { 92 | top: -0.5em; 93 | } 94 | 95 | code, 96 | kbd, 97 | pre, 98 | samp { 99 | font-family: monospace, monospace; 100 | font-size: var(--text-sm); 101 | } 102 | 103 | figure { 104 | margin: 1em 40px; 105 | } 106 | 107 | img { 108 | max-width: 100%; 109 | border-style: none; 110 | } 111 | 112 | svg:not(:root) { 113 | overflow: hidden; 114 | } 115 | 116 | hr { 117 | box-sizing: content-box; 118 | height: 0; 119 | overflow: visible; 120 | } 121 | 122 | button, 123 | input, 124 | optgroup, 125 | select, 126 | textarea { 127 | font: inherit; 128 | margin: 0; 129 | } 130 | 131 | button, 132 | input { 133 | overflow: visible; 134 | } 135 | 136 | button, 137 | select { 138 | text-transform: none; 139 | } 140 | 141 | //----------------------------------------------------------------------------// 142 | 143 | fieldset { 144 | border: 1px solid #{color(color-silver)}; 145 | margin: 0 2px; 146 | padding: 0.35em 0.625em 0.75em; 147 | } 148 | 149 | legend { 150 | box-sizing: border-box; 151 | color: inherit; 152 | display: table; 153 | max-width: 100%; 154 | padding: 0; 155 | white-space: normal; 156 | } 157 | 158 | textarea { 159 | overflow: auto; 160 | } 161 | 162 | b, 163 | strong, 164 | dt, 165 | th, 166 | optgroup, 167 | h1, 168 | h2, 169 | h3, 170 | h4, 171 | h5, 172 | h6 { 173 | font-weight: var(--font-weight-bold); 174 | } 175 | 176 | img, 177 | h1, 178 | h2, 179 | h3, 180 | h4, 181 | h5, 182 | h6, 183 | hgroup, 184 | ul, 185 | ol, 186 | dl, 187 | dd, 188 | p, 189 | pre, 190 | figure, 191 | table, 192 | fieldset, 193 | blockquote, 194 | form, 195 | noscript, 196 | iframe, 197 | hr { 198 | margin-bottom: var(--space-md); 199 | } 200 | 201 | h1, 202 | h2, 203 | h3, 204 | h4, 205 | h5, 206 | h6 { 207 | color: inherit; 208 | font-family: var(--font-family); 209 | text-rendering: optimizeLegibility; 210 | line-height: var(--heading-line-height); 211 | } 212 | 213 | h1 { 214 | font-size: var(--text-xxl); 215 | } 216 | 217 | h2 { 218 | font-size: var(--text-xl); 219 | } 220 | 221 | h3 { 222 | font-size: var(--text-lg); 223 | } 224 | 225 | h4 { 226 | font-size: var(--text-md); 227 | } 228 | 229 | h5 { 230 | font-size: var(--text-sm); 231 | } 232 | 233 | h6 { 234 | font-size: var(--text-xs); 235 | } 236 | 237 | ul, 238 | ol { 239 | margin-left: var(--space-md); 240 | list-style-position: outside; 241 | list-style-image: none; 242 | } 243 | 244 | pre { 245 | font-size: 0.85rem; 246 | line-height: 1.42; 247 | background: #{color(color-black, 0.04)}; 248 | border-radius: 3px; 249 | overflow: auto; 250 | word-wrap: normal; 251 | padding: var(--space-md); 252 | } 253 | 254 | table { 255 | font-size: var(--text-base-size); 256 | line-height: 1.45; 257 | border-collapse: collapse; 258 | width: 100%; 259 | } 260 | 261 | blockquote { 262 | margin-left: var(--space-md); 263 | margin-right: var(--space-md); 264 | margin-bottom: var(--space-md); 265 | } 266 | 267 | hr { 268 | margin-bottom: calc(var(--space-md) - 1px); 269 | background: #{color(color-black, 0.2)}; 270 | border: none; 271 | height: 1px; 272 | } 273 | 274 | address { 275 | margin-bottom: var(--space-md); 276 | } 277 | 278 | li { 279 | margin-bottom: calc(var(--space-md) / 2); 280 | } 281 | 282 | ol li, 283 | ul li { 284 | padding-left: 0; 285 | } 286 | 287 | li > ol, 288 | li > ul { 289 | margin-left: var(--space-md); 290 | margin-bottom: calc(var(--space-md) / 2); 291 | margin-top: calc(var(--space-md) / 2); 292 | } 293 | 294 | blockquote *:last-child, 295 | li *:last-child, 296 | p *:last-child { 297 | margin-bottom: 0; 298 | } 299 | 300 | li > p { 301 | margin-bottom: calc(var(--space-md) / 2); 302 | } 303 | 304 | code, 305 | kbd, 306 | samp { 307 | font-size: var(--text-sm); 308 | line-height: 1.45; 309 | } 310 | 311 | abbr, 312 | acronym, 313 | abbr[title] { 314 | border-bottom: 1px dotted #{color(color-black, 0.5)}; 315 | cursor: help; 316 | } 317 | 318 | abbr[title] { 319 | text-decoration: none; 320 | } 321 | 322 | thead { 323 | text-align: left; 324 | } 325 | 326 | td, 327 | th { 328 | --padding-x: calc(var(--space-md) * 1.246); 329 | --padding-y: calc(var(--space-md) * 0.93); 330 | text-align: left; 331 | border-bottom: 1px solid #{color(color-black, 0.12)}; 332 | font-feature-settings: 'tnum'; 333 | padding-left: var(--padding-x); 334 | padding-right: var(--padding-x); 335 | padding-top: var(--padding-y); 336 | padding-bottom: calc(var(--padding-y) - 1px); 337 | } 338 | 339 | th:first-child, 340 | td:first-child { 341 | padding-left: 0; 342 | } 343 | th:last-child, 344 | td:last-child { 345 | padding-right: 0; 346 | } 347 | 348 | tt, 349 | code { 350 | background-color: #{color(color-black, 0.04)}; 351 | border-radius: 3px; 352 | font-family: var(--font-family-code); 353 | padding: 0; 354 | padding-top: 0.2em; 355 | padding-bottom: 0.2em; 356 | } 357 | 358 | pre code { 359 | background: none; 360 | line-height: 1.42; 361 | } 362 | 363 | code:before, 364 | code:after, 365 | tt:before, 366 | tt:after { 367 | letter-spacing: -0.2em; 368 | content: ' '; 369 | } 370 | 371 | pre code:before, 372 | pre code:after, 373 | pre tt:before, 374 | pre tt:after { 375 | content: ''; 376 | } 377 | -------------------------------------------------------------------------------- /src/helpers/__tests__/values.ts: -------------------------------------------------------------------------------- 1 | import * as Values from '../values'; 2 | import { arrayFind } from '../arrays'; 3 | 4 | //----------------------------------------------------------------------------// 5 | 6 | const REGEX_FLOAT_NUMBER = /^[+-]?\d+([.]\d*)?(e[+-]?\d+)?$/g; 7 | 8 | const REGEX_INT_NUMBER = /^[+-]?\d+(e[+-]?\d+)?$/g; 9 | 10 | const buildDecimalRegex = (fixed: number | string = 1) => new RegExp(`^[+-]?\\d+([.]\\d{${fixed}})$`, 'g'); 11 | 12 | const buildRandomIntAsStringRegex = (padStart = 2) => 13 | new RegExp(`^0{${padStart - 1}}\\d+([.]\\d*)?(e[+-]?\\d+)?$`, `g`); 14 | 15 | //----------------------------------------------------------------------------// 16 | 17 | describe('helpers/values', () => { 18 | describe('getPropertyValue', () => { 19 | it('should get an attribute value from a given object', () => { 20 | const value = 'hello'; 21 | const obj = { att: value }; 22 | expect(Values.getPropertyValue(obj, 'att')).toEqual(value); 23 | }); 24 | }); 25 | 26 | describe('setPropertyValue', () => { 27 | it('should set a value to a given object attribute', () => { 28 | const newValue = 'hello world'; 29 | let obj = { att: 'hello' }; 30 | expect(Values.getPropertyValue(obj, 'att')).not.toEqual(newValue); 31 | obj = Values.setPropertyValue(obj, 'att', newValue); 32 | expect(Values.getPropertyValue(obj, 'att')).toEqual(newValue); 33 | }); 34 | }); 35 | 36 | describe('randomFloat', () => { 37 | it('should get a random float number', () => { 38 | const min = 1; 39 | const max = 3; 40 | const result = Values.randomFloat(min, max); 41 | expect(`${result}`).toMatch(REGEX_FLOAT_NUMBER); 42 | expect(result >= min && result <= max).toBeTruthy(); 43 | }); 44 | }); 45 | 46 | describe('randomDecimalString', () => { 47 | it('should get a decimal number with fixed 2', () => { 48 | const min = 10; 49 | const max = 30; 50 | const fixed = 2; 51 | const result = Values.randomDecimalString(min, max); 52 | expect(`${result}`).toMatch(buildDecimalRegex(fixed)); 53 | const value = Number(result); 54 | expect(value >= min && value <= max).toBeTruthy(); 55 | }); 56 | 57 | it('should get a decimal number with fixed 3', () => { 58 | const min = 10; 59 | const max = 30; 60 | const fixed = 3; 61 | const result = Values.randomDecimalString(min, max, fixed); 62 | expect(`${result}`).toMatch(buildDecimalRegex(fixed)); 63 | const value = Number(result); 64 | expect(value >= min && value <= max).toBeTruthy(); 65 | }); 66 | }); 67 | 68 | describe('randomDecimal', () => { 69 | it('should get a decimal number with fixed {1,2}', () => { 70 | const min = 10; 71 | const max = 30; 72 | const fixed = 2; 73 | const result = Values.randomDecimal(min, max); 74 | expect(`${result}`).toMatch(buildDecimalRegex(`1,${fixed}`)); 75 | const value = Number(result); 76 | expect(value >= min && value <= max).toBeTruthy(); 77 | }); 78 | 79 | it('should get a decimal number with fixed {1,3}', () => { 80 | const min = 10; 81 | const max = 30; 82 | const fixed = 3; 83 | const result = Values.randomDecimal(min, max, fixed); 84 | expect(`${result}`).toMatch(buildDecimalRegex(`1,${fixed}`)); 85 | const value = Number(result); 86 | expect(value >= min && value <= max).toBeTruthy(); 87 | }); 88 | }); 89 | 90 | describe('randomInt', () => { 91 | it('should get an integer number', () => { 92 | const min = 1; 93 | const max = 5; 94 | const result = Values.randomInt(min, max); 95 | expect(`${result}`).toMatch(REGEX_INT_NUMBER); 96 | expect(result >= min && result <= max).toBeTruthy(); 97 | }); 98 | }); 99 | 100 | describe('randomIntAsString', () => { 101 | it('should get a random integer string with two 0 left', () => { 102 | const min = 1; 103 | const max = 5; 104 | const padStart = 2; 105 | const result = Values.randomIntAsString(min, max); 106 | expect(`${result}`).toMatch(buildRandomIntAsStringRegex(padStart)); 107 | expect(result.length).toEqual(padStart); 108 | const value = Number(result); 109 | expect(value >= min && value <= max).toBeTruthy(); 110 | }); 111 | 112 | it('should get a random integer string with three 0 left', () => { 113 | const min = 1; 114 | const max = 5; 115 | const padStart = 3; 116 | const result = Values.randomIntAsString(min, max, padStart); 117 | expect(`${result}`).toMatch(buildRandomIntAsStringRegex(padStart)); 118 | expect(result.length).toEqual(padStart); 119 | const value = Number(result); 120 | expect(value >= min && value <= max).toBeTruthy(); 121 | }); 122 | }); 123 | 124 | describe('randomBoolean', () => { 125 | it('should be a boolean', () => { 126 | expect(typeof Values.randomBoolean()).toEqual('boolean'); 127 | }); 128 | }); 129 | 130 | describe('randomValue', () => { 131 | it('should return undefined to not object or array as parameter', () => { 132 | expect(Values.randomValue(null)).toBeUndefined(); 133 | expect(Values.randomValue(undefined)).toBeUndefined(); 134 | expect(Values.randomValue(NaN)).toBeUndefined(); 135 | expect(Values.randomValue(123)).toBeUndefined(); 136 | expect(Values.randomValue('hello')).toBeUndefined(); 137 | }); 138 | 139 | describe('in an object values', () => { 140 | it('should get a random value from a given object', () => { 141 | const obj = { att1: 'value 1', att2: 'value 2', att3: 'value 3' }; 142 | const objValues: any[] = Object.values(obj); 143 | const randomValue = Values.randomValue(obj); 144 | expect(randomValue).toBeDefined(); 145 | expect(objValues.includes(randomValue)).toBeTruthy(); 146 | }); 147 | }); 148 | 149 | describe('in an array values', () => { 150 | it('should get a random value from a plain array', () => { 151 | const values: any[] = ['a', 'b', 'c', 'd', 'e', 'f']; 152 | const randomValue = Values.randomValue(values); 153 | expect(randomValue).toBeDefined(); 154 | expect(values.includes(randomValue)).toBeTruthy(); 155 | }); 156 | 157 | it('should get a random value from an object array', () => { 158 | const values: any[] = [{ att: 'a' }, { att: 'b' }, { att: 'c' }, { att: 'd' }, { att: 'e' }, { att: 'f' }]; 159 | const randomValue = Values.randomValue(values); 160 | expect(randomValue).toBeDefined(); 161 | expect(arrayFind(values, randomValue, 'att')).toBeDefined(); 162 | }); 163 | }); 164 | }); 165 | 166 | describe('calculateAvailablePages', () => { 167 | const values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; 168 | 169 | it('should use the default pageSize and return 1 as result', () => { 170 | expect(Values.calculateAvailablePages(values)).toEqual(1); 171 | }); 172 | 173 | it('should use 5 items as page size ', () => { 174 | expect(Values.calculateAvailablePages(values, 5)).toEqual(2); 175 | }); 176 | }); 177 | 178 | describe('paginate', () => { 179 | const pageSize = 5; 180 | const values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; 181 | 182 | it('should have an empty response to an empty data array', () => { 183 | const result = Values.paginate([], 0, pageSize); 184 | expect(result).toBeDefined(); 185 | expect(result).toHaveLength(0); 186 | }); 187 | 188 | it('should use the default values to get the first page', () => { 189 | const result = Values.paginate(values); 190 | expect(result).toBeDefined(); 191 | expect(result).toHaveLength(10); 192 | }); 193 | 194 | it('should get the first page', () => { 195 | const result = Values.paginate(values, 0, pageSize); 196 | expect(result).toBeDefined(); 197 | expect(result).toHaveLength(pageSize); 198 | }); 199 | 200 | it('should get the second page', () => { 201 | const result = Values.paginate(values, 5, pageSize); 202 | expect(result).toBeDefined(); 203 | expect(result).toHaveLength(pageSize); 204 | }); 205 | 206 | it('should get last 3 items from the array', () => { 207 | const result = Values.paginate(values, 7, pageSize); 208 | expect(result).toBeDefined(); 209 | expect(result).toHaveLength(3); 210 | }); 211 | }); 212 | 213 | describe('randomSetOfData', () => { 214 | const values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; 215 | 216 | it('should get a random set of data between 1 and 10', () => { 217 | const result = Values.randomSetOfData(values); 218 | expect(result).toBeDefined(); 219 | const { length } = result; 220 | expect(length >= 1 && length <= 10).toBeTruthy(); 221 | }); 222 | 223 | it('should get a random set of data with 5 items', () => { 224 | const pageSize = 5; 225 | const result = Values.randomSetOfData(values, pageSize); 226 | expect(result).toBeDefined(); 227 | expect(result).toHaveLength(pageSize); 228 | }); 229 | }); 230 | 231 | describe('randomAmountOfData', () => { 232 | const values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; 233 | 234 | it('should get an empty array to a given empty data array', () => { 235 | const result = Values.randomAmountOfData([], 10); 236 | expect(result).toBeDefined(); 237 | expect(result).toHaveLength(0); 238 | }); 239 | 240 | it('should get a random amount of data between 1 and 10', () => { 241 | const result = Values.randomAmountOfData(values); 242 | expect(result).toBeDefined(); 243 | const { length } = result; 244 | expect(length >= 1 && length <= 10).toBeTruthy(); 245 | }); 246 | 247 | it('should get a random amount of data with 5 items', () => { 248 | const pageSize = 5; 249 | const result = Values.randomAmountOfData(values, pageSize); 250 | expect(result).toBeDefined(); 251 | expect(result).toHaveLength(pageSize); 252 | }); 253 | }); 254 | }); 255 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Gatsby 4 | 5 |

6 |

7 | Gatsby's typescript application starter 8 |

9 | 10 | Kick off your project with this default boilerplate. This starter ships with the main Gatsby configuration files you might need to get up and running blazing fast with the blazing fast app generator for React. 11 | 12 | _Have another more specific idea? You may want to check out our vibrant collection of [official and community-created starters](https://www.gatsbyjs.org/docs/gatsby-starters/)._ 13 | 14 | ## 🚀 Quick start 15 | 16 | ### 1. **Create a Gatsby site.** 17 | 18 | Use the Gatsby CLI to create a new site, specifying the default starter. 19 | 20 | ```sh 21 | # create a new Gatsby site using the typescript application starter 22 | gatsby new my-typescript-app-starter https://github.com/erkobridee/gatsby-typescript-app-starter 23 | ``` 24 | 25 | Another way to create the a new project without having the Gatsby CLI 26 | 27 | ```sh 28 | npx gatsby new my-typescript-app-starter https://github.com/erkobridee/gatsby-typescript-app-starter 29 | ``` 30 | 31 | **TIP:** double check if you need to have the _**.git**_ directory or you should delete it 32 | 33 | ### 2. **Start developing.** 34 | 35 | Navigate into your new site’s directory and start it up. 36 | 37 | ```sh 38 | cd my-typescript-app-starter/ 39 | npm i 40 | npm start 41 | ``` 42 | 43 | or you can also use: `npm run develop` 44 | 45 | ### 3. **Open the source code and start editing!** 46 | 47 | Your site is now running at `http://localhost:8000`! 48 | 49 | _Note: You'll also see a second link: _`http://localhost:8000/___graphql`_. This is a tool you can use to experiment with querying your data. Learn more about using this tool in the [Gatsby tutorial](https://www.gatsbyjs.org/tutorial/part-five/#introducing-graphiql)._ 50 | 51 | Open the `my-typescript-app-starter` directory in your code editor of choice and edit `src/pages/index.js`. Save your changes and the browser will update in real time! 52 | 53 | ## 🧐 What's inside? 54 | 55 | A quick look at the top-level files and directories you'll see in this project. [Gatsby Docs - Gatsby Project Structure](https://www.gatsbyjs.org/docs/gatsby-project-structure/). 56 | 57 | . 58 | ├── node_modules 59 | ├── __mock__ 60 | ├── coverage 61 | ├── jest-helpers 62 | ├── scripts 63 | ├── src 64 | ├── static 65 | ├── .babelrc.js 66 | ├── .eslintrc 67 | ├── .gitignore 68 | ├── .prettierrc 69 | ├── gatsby-browser.js 70 | ├── gatsby-config.js 71 | ├── gatsby-node.js 72 | ├── gatsby-ssr.js 73 | ├── gatsby-wrap-root-element.js 74 | ├── jest.config.js 75 | ├── package-lock.json 76 | ├── package.json 77 | ├── tsconfig.json 78 | └── README.md 79 | 80 | - **`/__mock__`** - jest global mocks directory. 81 | 82 | - **`/.build/jest-temp`** - jest unit tests cache directory. 83 | 84 | - **`/.build/.i18nExtractedMessages`** - i18n messages extracted from the code and those messages will be used to generate the JSON translation files. 85 | 86 | - **`/.cache`** - files manages by the Gatsby used to build the output on the **`/public`** folder. 87 | 88 | - **`/node_modules`** - This directory contains all of the modules of code that your project depends on (npm packages) are automatically installed. 89 | 90 | - **`/coverage`** - output of jest unit tests coverage reports. 91 | 92 | - **`/jest-helpers`** - jest setup helpers. 93 | 94 | - **`/public`** - Gatsby output directory, the content of this folder after the build flow must be copied to the server. 95 | 96 | - **`/scripts`** - contains font icons generation, the translations manager and the path prefix helper. 97 | 98 | - **`/src`** - This directory will contain all of the code related to what you will see on the front-end of your site (what you see in the browser) such as your site header or a page template. `src` is a convention for “source code”. 99 | 100 | - **`/static`** - static files that should be copied by the Gastby to the **`/public`** folder during the build flow. [Gatsby Docs - Adding assets outside of the module system](https://www.gatsbyjs.org/docs/static-folder/) 101 | 102 | - **`.gitignore`** - This file tells git which files it should not track / not maintain a version history for. 103 | 104 | - **`.prettierrc`** - This is a configuration file for [Prettier](https://prettier.io/). Prettier is a tool to help keep the formatting of your code consistent. 105 | 106 | - **`gatsby-browser.js`** - This file is where Gatsby expects to find any usage of the [Gatsby browser APIs](https://www.gatsbyjs.org/docs/browser-apis/) (if any). These allow customization/extension of default Gatsby settings affecting the browser. 107 | 108 | - **`gatsby-config.js`** - This is the main configuration file for a Gatsby site. This is where you can specify information about your site (metadata) like the site title and description, which Gatsby plugins you’d like to include, etc. (Check out the [config docs](https://www.gatsbyjs.org/docs/gatsby-config/) for more detail). 109 | 110 | - **`gatsby-node.js`** - This file is where Gatsby expects to find any usage of the [Gatsby Node APIs](https://www.gatsbyjs.org/docs/node-apis/) (if any). These allow customization/extension of default Gatsby settings affecting pieces of the site build process. 111 | 112 | - **`gatsby-ssr.js`** - This file is where Gatsby expects to find any usage of the [Gatsby server-side rendering APIs](https://www.gatsbyjs.org/docs/ssr-apis/) (if any). These allow customization of default Gatsby settings affecting server-side rendering. 113 | 114 | - **`gatsby-wrap-root-element.js`** - used by **`gatsby-browser.js`** as the `export.wrapRootElement`, more details about it please check the [Gatsby browser APIs - wrapRootElement](https://www.gatsbyjs.org/docs/browser-apis/#wrapRootElement). The wrappers used by the application they are defined at the `src/components/Wrapper` folder. 115 | 116 | - **`jest.config.js`** - project jest configs 117 | 118 | - **`package-lock.json`** - (See `package.json` below, first). This is an automatically generated file based on the exact versions of your npm dependencies that were installed for your project. **(You won’t change this file directly).** 119 | 120 | - **`package.json`** - A manifest file for Node.js projects, which includes things like metadata (the project’s name, author, etc). This manifest is how npm knows which packages to install for your project. 121 | 122 | - **`README.md`** - A text file containing useful reference information about your project. 123 | 124 | ### 📂 The `src/` folder - important items to know 125 | 126 | . 127 | └── src 128 | ├── assets 129 | ├── icons 130 | ├── languages 131 | └── styles 132 | ├── components 133 | ├── Layout 134 | ├── SEO 135 | ├── ui 136 | └── Wrapper 137 | ├── data 138 | ├── api 139 | ├── models 140 | └── schemas 141 | ├── domains 142 | ├── helpers 143 | ├── pages 144 | ├── store 145 | ├── helpers 146 | ├── state 147 | ├── definitions.ts 148 | └── index.ts 149 | └── html.js 150 | 151 | - **`/assets`** 152 | 153 | - **`/icons`** - SVG files, each one represents a font icon, the file should be named following the pattern `{icon-name}_icon.svg` and to be easialy used on the project we have the component `FontIcon` located at the `src/components/ui/FontIcon` folder 154 | 155 | - **`/languages`** - translations JSON files generated from the extrated messages from the code, here you should maintain and update the keys entries to each supported language 156 | 157 | - **`/styles`** - global styles definitions and mixins which uses the SASS syntax 158 | 159 | - **`/components`** - stand alone components 160 | 161 | - **`/Layout`** - components that defines the pages layouts used on the application 162 | 163 | - **`/SEO`** - component to easialy manage the HTML header elements, like the page title 164 | 165 | - **`/Wrapper`** - components related to the providers, for example, the redux, internationalization and so one 166 | 167 | - **`/ui`** - small ui components, like the `FontIcon` one 168 | 169 | - **`/data`** 170 | 171 | - **`/schemas`** - defines the data structure used to communicate with the backends/APIs 172 | 173 | - **`/models`** - defines the data structure used inside of the application that receives as input one or more `schemas`. 174 | 175 | - **`/api`** - defines the communication with the backends/APIs using the data `schemas` definitions and transform them to data `models` to be used inside of the application. 176 | 177 | - **`/domains`** - and/or use cases managed by the application, for example: authentication. 178 | 179 | - **`/helpers`** - common code used across the aplication, like small processment code, fetch, react hooks and types/values definitions. 180 | 181 | - **`/pages`** - the components on this directory will define the page content and the file name will define the URL path to the page. 182 | 183 | - **`/store`** - code related to the [Redux](https://redux.js.org/) that follows the [re-ducks pattern](https://www.freecodecamp.org/news/scaling-your-redux-app-with-ducks-6115955638be/), check on the notes section to read more about this pattern. 184 | 185 | - **`/helpers`** - has a create reducer as an object definition instead of need to define as a switch case pattern 186 | 187 | - **`/state`** - the redux states are placed inside of this directory 188 | 189 | - **`/{statename}`** - defines one redux state 190 | 191 | - **`definitions.ts`** - defines the action types strings, the state object interface and the triggered actions interface 192 | 193 | - **`actions.ts`** - defines the actions triggered that uses the types and actions interfaces defined on the `definitions.ts` 194 | 195 | - **`operations.ts`** - defines the operations that manages a async flow or manage a set of actions to be triggered 196 | 197 | - **`reducers.ts`** - defines the redux reducers to the given redux state 198 | 199 | - **`selectors.ts`** - access data from the given redux state 200 | 201 | - **`index.ts`** - expose the redux reducers from the given directory 202 | 203 | - **`definitions.ts`** - defines the Redux root state type definition 204 | 205 | - **`index.ts`** - defines the Redux store 206 | 207 | - **`html.js`** - overwrites the default Gatsby index.html generation [Gatsby Doc - Customizing html.js](https://www.gatsbyjs.org/docs/custom-html/), it was needed to redefine the `viewport` configuration to avoid the zoom in on the iOS devices ([Prevent iOS from zooming in on input fields](https://blog.osmosys.asia/2017/01/05/prevent-ios-from-zooming-in-on-input-fields/)) 208 | 209 | ## 💻 Available Commands 210 | 211 | - generate translations using react-intl: `npm run translations` 212 | 213 | - format the code using the prettier: `npm run format` 214 | 215 | - lint the code using the eslint: `npm run lint` 216 | 217 | - check the code (typings and lint): `npm run check` 218 | 219 | - start the development server: `npm run develop` or `npm start` (if you need to read a specific `.env` file, define the `NODE_ENV` before the command) 220 | 221 | - cleanup the temporary directories: `npm run clean` 222 | 223 | - build the production output version: `npm run build` (if you need to read a specific `.env` file, define the `ACTIVE_ENV` before the command) 224 | 225 | - test the production outputed version: `npm run serve` 226 | 227 | - run the jest unit tests and generate the coverage report: `npm run unit-tests` 228 | 229 | ## 🎓 Learning Gatsby 230 | 231 | Looking for more guidance? Full documentation for Gatsby lives [on the website](https://www.gatsbyjs.org/). Here are some places to start: 232 | 233 | - **For most developers, we recommend starting with our [in-depth tutorial for creating a site with Gatsby](https://www.gatsbyjs.org/tutorial/).** It starts with zero assumptions about your level of ability and walks through every step of the process. 234 | 235 | - **To dive straight into code samples, head [to our documentation](https://www.gatsbyjs.org/docs/).** In particular, check out the _Guides_, _API Reference_, and _Advanced Tutorials_ sections in the sidebar. 236 | 237 | - [Upgrade for Minor or Patch Releases](https://www.gatsbyjs.org/docs/upgrade-gatsby-and-dependencies/) 238 | 239 | ## 📝 Notes 240 | 241 | - the project uses sass and the postcss with autoprefixer support and related to this last one there is an open issue related to the warning message showed on the terminal `BrowserslistError: Unknown browser query 'android all'` ( [browserslist issue #382](https://github.com/browserslist/browserslist/issues/382) ) 242 | 243 | - added a temporary solution described on the issue comments 244 | 245 | - the project has the redux on it and its follow the re-ducks pattern, read more about on this links [Scaling your Redux App with ducks](https://www.freecodecamp.org/news/scaling-your-redux-app-with-ducks-6115955638be/) and check this [example](https://github.com/FortechRomania/react-redux-complete-example) 246 | 247 | - but, instead of `src/state/` it uses `src/store/` and instead of `src/state/ducks/` it uses `src/store/state/` 248 | 249 | - Gatsby 250 | 251 | - production build issue when has the `window` on the code, check the [gatsbyjs / gatsby - issue 12427](https://github.com/gatsbyjs/gatsby/issues/12427) 252 | 253 | - the project has the configuration to be able to use absolute imports on the code ( [Gatsby Docs - Absolute imports](https://www.gatsbyjs.org/docs/add-custom-webpack-config/#absolute-imports) ) 254 | 255 | - [Gatsby Docs - How to use a custom `.babelrc` file](https://www.gatsbyjs.org/docs/babel/#how-to-use-a-custom-babelrc-file) 256 | 257 | - [Gatsby Docs - Environment Variables](www.gatsbyjs.org/docs/environment-variables/) 258 | 259 | - [Building Apps with Gatsby](https://www.gatsbyjs.org/docs/building-apps-with-gatsby/) 260 | 261 | - [[Slides] Benefits of using TypeScript + React + Unit Testing](https://slides.com/erkobridee/benefits-typescript-react-unit-testing/) 262 | 263 | ## 🧪Jest unit tests 264 | 265 | This project has unit tests support that runs on top of the [Jest](https://jestjs.io/) - ( [Docs](https://jestjs.io/docs/en/getting-started) | [API](https://jestjs.io/docs/en/api) ). 266 | 267 | The jest unit tests will be executed right before the gatsby-build on the build flow `npm run build` or you can execute it anytime with the following commands: `npm run unit-tests`, `jest` or if you want to keep it running use `jest --watch`; 268 | 269 | The jest mock files should be placed inside a directory with the given name: `__mock__`, read more about it on the [Jest Docs](https://jestjs.io/docs/en/manual-mocks). 270 | 271 | About the the jest unit tests detection, it will look for the content of directories with the name `__tests__` or files with the sufix `{test|spec}.[jt]sx?`, read more about it on the [Jest Docs](https://jestjs.io/docs/en/configuration#testregex-string--arraystring). 272 | 273 | ### Useful links related to TypeScript + React + Jest 274 | 275 | - [[GitHub] facebook/jest - TypeScript](https://github.com/facebook/jest/tree/master/examples/typescript) 276 | 277 | - [Add Testing | Gatsby.js Doc](https://www.gatsbyjs.org/docs/testing/) 278 | 279 | - [Unit Testing | Gatsby.js Doc](https://www.gatsbyjs.org/docs/unit-testing/) 280 | 281 | --- 282 | 283 | - [Understanding Jest Mocks | Rick Hanlon II - Medium](https://medium.com/@rickhanlonii/understanding-jest-mocks-f0046c68e53c) 284 | 285 | - [Configuring Jest to show code coverage for all of your files](https://joshtronic.com/2017/10/24/configuring-jest-to-show-code-coverage-for-all-of-your-files/) 286 | 287 | - [Best kept Jest secret: Testing only changed files with coverage reports | @stipsan - Medium](https://medium.com/@stipsan/best-kept-jest-secret-testing-only-changed-files-with-coverage-reports-3affc8b4d30f) 288 | 289 | --- 290 | 291 | - [Using Jest with TypeScript](https://basarat.gitbooks.io/typescript/docs/testing/jest.html) 292 | 293 | - [Testing TypeScript with Jest](https://rjzaworski.com/2016/12/testing-typescript-with-jest) 294 | 295 | - [[GitHub] rjz/typescript-react-redux](https://github.com/rjz/typescript-react-redux) 296 | 297 | - [Mocking TypeScript classes with Jest | David Guijarro - Medium](https://medium.com/@davguij/mocking-typescript-classes-with-jest-8ef992170d1d) 298 | 299 | - [[GitHub] davguij/typescript-jest-mocked-classes](https://github.com/davguij/typescript-jest-mocked-classes) 300 | 301 | - [Debugging with TypeScript, Jest, ts-jest and Visual Studio Code](https://medium.com/@mtiller/debugging-with-typescript-jest-ts-jest-and-visual-studio-code-ef9ca8644132) 302 | 303 | - [TypeScript - jest (ts-jest) | typescript Tutorial](https://riptutorial.com/typescript/example/29207/jest--ts-jest-) 304 | 305 | - [[GitHub] kulshekhar/ts-jest](https://github.com/kulshekhar/ts-jest) - TypeScript preprocessor with sourcemap support for Jest 306 | 307 | - [[GitHub] zeit / next.js - Testing with typescript + jest + ts-jest #8663](https://github.com/zeit/next.js/issues/8663) - tip to how to solve the jsx parsing problem 308 | 309 | - [[GitHub] jest-community / jest-junit](https://github.com/jest-community/jest-junit) - A Jest reporter that creates compatible junit xml files 310 | 311 | --- 312 | 313 | - [Testing in React with Jest and Enzyme: An Introduction | @rossbulat - Medium](https://medium.com/@rossbulat/testing-in-react-with-jest-and-enzyme-an-introduction-99ce047dfcf8) 314 | 315 | - [Test Driven Development in React with Jest and Enzyme | @rossbulat - Medium](https://medium.com/@rossbulat/testing-in-react-with-jest-and-enzyme-an-introduction-99ce047dfcf8) 316 | --------------------------------------------------------------------------------