├── .formatignore ├── src ├── api │ ├── package.json │ ├── utils │ │ ├── package.json │ │ ├── omitParam.js │ │ ├── _utils.js │ │ ├── makeRequest.js │ │ ├── makeRequestUrl.js │ │ ├── metrics.js │ │ ├── validateRequestParams.js │ │ └── validateResponse.js │ └── _api.js ├── stores │ ├── package.json │ ├── _stores.js │ ├── GlobalStore.js │ ├── RatesStore.js │ ├── RootStore.js │ ├── I18nStore.js │ ├── MarketsListStore.js │ ├── RouterStore.js │ └── CurrentTPStore.js ├── utils │ ├── package.json │ ├── sum.js │ ├── detectNilEquality.js │ ├── renderToDOM.js │ ├── setTheme.js │ ├── precise.js │ ├── declOfNum.js │ ├── formatPercentage.js │ ├── initSentry.js │ ├── _utils.js │ ├── objectToEncoded.js │ ├── validateObjects.js │ ├── getUrlParams.js │ ├── observer.js │ └── makeObservable.js ├── components │ ├── App │ │ ├── package.json │ │ └── App.js │ ├── Link │ │ ├── package.json │ │ └── Link.js │ ├── Tabs │ │ ├── package.json │ │ ├── Tabs.scss │ │ └── Tabs.js │ ├── Router │ │ ├── package.json │ │ └── Router.js │ ├── Message │ │ ├── package.json │ │ └── Message.js │ └── GlobalLoader │ │ ├── package.json │ │ ├── GlobalLoader.scss │ │ └── GlobalLoader.js ├── pages │ ├── Error404 │ │ ├── package.json │ │ ├── Error404.scss │ │ └── Error404.js │ └── MarketDetailed │ │ ├── Chart │ │ ├── package.json │ │ ├── config │ │ │ ├── configLegend.js │ │ │ ├── configCredits.js │ │ │ ├── configNavigator.js │ │ │ ├── configYAxis.js │ │ │ ├── configScrollbar.js │ │ │ ├── configChart.js │ │ │ ├── configSeries.js │ │ │ ├── chartConfig.js │ │ │ ├── configTooltip.js │ │ │ └── configRangeSelector.js │ │ ├── messages.js │ │ ├── assets │ │ │ └── fill.svg │ │ ├── Chart.js │ │ └── Chart.scss │ │ ├── TPInfo │ │ ├── package.json │ │ ├── messages.js │ │ ├── TPInfo.scss │ │ └── TPInfo.js │ │ ├── package.json │ │ ├── OrderBook │ │ ├── package.json │ │ ├── messages.js │ │ ├── OrderBook.scss │ │ ├── OffersTable.js │ │ └── OrderBook.js │ │ ├── MarketList │ │ ├── package.json │ │ ├── assets │ │ │ ├── russia-flag-icon-32.png │ │ │ └── united-kingdom-flag-icon-32.png │ │ ├── messages.js │ │ ├── MarketTabs.js │ │ ├── Togglers.js │ │ ├── MarketList.js │ │ ├── MarketList.scss │ │ └── PairsList.js │ │ ├── MarketDetailed.scss │ │ └── MarketDetailed.js ├── hooks │ ├── index.js │ ├── useStore.js │ └── useLocalization.js ├── polyfill.js ├── routeComponents.js ├── app.js ├── styles │ ├── mixins.scss │ ├── global.scss │ ├── constants.scss │ ├── themes.scss │ ├── reset.scss │ └── layout.scss ├── localization │ ├── en.json │ └── ru.json ├── autorun.js └── routes.js ├── eslint-custom ├── rules │ ├── strict-mode.yaml │ ├── import.yaml │ ├── node-js-and-common-js.yaml │ ├── react.yaml │ ├── stylelint.yaml │ ├── possible-errors.yaml │ ├── ecmascript-6.yaml │ ├── variables.yaml │ ├── best-practices.yaml │ └── stylistic-issues.yaml ├── stylelint.config.js ├── package.json └── eslint.config.js ├── .gitignore ├── tests ├── cypress.json ├── cypress │ ├── support │ │ ├── index.js │ │ ├── polyfillFetch.js │ │ ├── routes.js │ │ └── commands.js │ ├── plugins │ │ └── index.js │ └── integration │ │ └── mixed.js └── package.json ├── README.md ├── webpack-custom ├── config │ ├── configStats.js │ ├── configPerformance.js │ ├── configNode.js │ ├── configDevTool.js │ ├── configEntry.js │ ├── configResolve.js │ ├── configModule.js │ ├── configOutput.js │ ├── configDevServer.js │ └── configOptimization.js ├── loaders │ ├── loaderStyle.js │ ├── loaderSassTheme.js │ ├── loaderUrl.js │ ├── loaderSvgInline.js │ ├── loaderThreadParallel.js │ ├── loaderCss.js │ ├── loaderExtractCss.js │ ├── loaderBabel.js │ └── loaderPostcss.js ├── plugins │ ├── pluginHot.js │ ├── pluginClean.js │ ├── pluginCircularDependency.js │ ├── pluginExtract.js │ ├── pluginSpeedMeasure.js │ ├── pluginDefine.js │ ├── pluginHtml.js │ └── pluginAnalyzer.js ├── rules │ ├── ruleSassThemes.js │ ├── ruleImages.js │ ├── ruleBabel.js │ ├── ruleSvgInline.js │ └── ruleSass.js ├── utils │ ├── paths.js │ ├── envParams.js │ └── sassVariablesLoader.js ├── package.json └── webpack.config.js ├── .frontend.env.example ├── .frontend.env.prod.example ├── templates └── index.html ├── dist ├── index.html ├── runtime.d370529eaf8353423ae4.js ├── app.6aeef33d963c36044219.css └── lodash.b3ab72b27800aa0a5a97.js └── package.json /.formatignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /src/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "_api.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/stores/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "_stores.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "_utils.js" 3 | } 4 | -------------------------------------------------------------------------------- /eslint-custom/rules/strict-mode.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | strict: off 3 | -------------------------------------------------------------------------------- /src/api/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "_utils.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/components/App/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "App.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/components/Link/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "Link.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/components/Tabs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "Tabs.js" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .frontend.env 2 | .idea 3 | node_modules 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /src/components/Router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "Router.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/Error404/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "Error404.js" 3 | } 4 | -------------------------------------------------------------------------------- /tests/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:8080" 3 | } 4 | -------------------------------------------------------------------------------- /src/components/Message/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "Message.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "Chart.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/TPInfo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "TPInfo.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "MarketDetailed.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/api/utils/omitParam.js: -------------------------------------------------------------------------------- 1 | export function omitParam() { 2 | return true; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/GlobalLoader/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "GlobalLoader.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/OrderBook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "OrderBook.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/hooks/index.js: -------------------------------------------------------------------------------- 1 | export * from './useLocalization'; 2 | export * from './useStore'; 3 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/MarketList/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "MarketList.js" 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/sum.js: -------------------------------------------------------------------------------- 1 | export function sum(accumulator, num) { 2 | return accumulator + num; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/Tabs/Tabs.scss: -------------------------------------------------------------------------------- 1 | @import "mixins.scss"; 2 | 3 | .tabs { 4 | @include tabs; 5 | } 6 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/config/configLegend.js: -------------------------------------------------------------------------------- 1 | export default { 2 | enabled: false, 3 | }; 4 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/config/configCredits.js: -------------------------------------------------------------------------------- 1 | export default { 2 | enabled: false, 3 | }; 4 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/config/configNavigator.js: -------------------------------------------------------------------------------- 1 | export default { 2 | enabled: false, 3 | }; 4 | -------------------------------------------------------------------------------- /tests/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | import './commands'; 2 | import './polyfillFetch'; 3 | import './routes'; 4 | -------------------------------------------------------------------------------- /src/stores/_stores.js: -------------------------------------------------------------------------------- 1 | import { RootStore } from './RootStore'; 2 | 3 | export const store = new RootStore(); 4 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/MarketDetailed.scss: -------------------------------------------------------------------------------- 1 | @import "mixins.scss"; 2 | 3 | .contentWrapper { 4 | padding: 0 20px; 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Фронтенд биржи для статьи на Хабрахабре - https://habr.com/ru/post/450360/ 2 | 3 | Демо: https://dkazakov8.github.io/exchange_habr/dist/ -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/messages.js: -------------------------------------------------------------------------------- 1 | export const messages = { 2 | chart: 'Chart', 3 | month: '{count} {count: month,months,months}', 4 | }; 5 | -------------------------------------------------------------------------------- /src/polyfill.js: -------------------------------------------------------------------------------- 1 | import { toJS } from 'mobx'; 2 | 3 | console.js = function consoleJsCustom(...args) { 4 | console.log(...args.map(arg => toJS(arg))); 5 | }; 6 | -------------------------------------------------------------------------------- /eslint-custom/stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['./rules/stylelint.yaml', 'stylelint-prettier/recommended'], 3 | plugins: ['stylelint-scss'], 4 | }; 5 | -------------------------------------------------------------------------------- /webpack-custom/config/configStats.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/configuration/stats 3 | * 4 | */ 5 | 6 | module.exports = { 7 | children: false, 8 | }; 9 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/OrderBook/messages.js: -------------------------------------------------------------------------------- 1 | export const messages = { 2 | orderBook: 'Order book', 3 | price: 'Price', 4 | amount: 'Amount', 5 | total: 'Total', 6 | }; 7 | -------------------------------------------------------------------------------- /webpack-custom/config/configPerformance.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/configuration/performance 3 | * 4 | */ 5 | 6 | module.exports = { 7 | hints: false, 8 | }; 9 | -------------------------------------------------------------------------------- /webpack-custom/loaders/loaderStyle.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/loaders/style-loader 3 | * 4 | */ 5 | 6 | module.exports = { 7 | loader: 'style-loader', 8 | }; 9 | -------------------------------------------------------------------------------- /webpack-custom/loaders/loaderSassTheme.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | loader: path.resolve(__dirname, '../utils/sassVariablesLoader.js'), 5 | }; 6 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/MarketList/assets/russia-flag-icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dkazakov8/habr_exchange/HEAD/src/pages/MarketDetailed/MarketList/assets/russia-flag-icon-32.png -------------------------------------------------------------------------------- /webpack-custom/loaders/loaderUrl.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/loaders/url-loader 3 | * 4 | */ 5 | 6 | module.exports = { 7 | loader: 'url-loader?limit=15000', 8 | }; 9 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/TPInfo/messages.js: -------------------------------------------------------------------------------- 1 | export const messages = { 2 | lastPrice: 'Last Price', 3 | change24h: '24h Change', 4 | high24h: '24h High', 5 | low24h: '24h Low', 6 | }; 7 | -------------------------------------------------------------------------------- /webpack-custom/loaders/loaderSvgInline.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/loaders/svg-inline-loader 3 | * 4 | */ 5 | 6 | module.exports = { 7 | loader: 'svg-inline-loader', 8 | }; 9 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/MarketList/assets/united-kingdom-flag-icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dkazakov8/habr_exchange/HEAD/src/pages/MarketDetailed/MarketList/assets/united-kingdom-flag-icon-32.png -------------------------------------------------------------------------------- /webpack-custom/config/configNode.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/configuration/node 3 | * 4 | */ 5 | 6 | module.exports = { 7 | __filename: true, 8 | __dirname: true, 9 | }; 10 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/MarketList/messages.js: -------------------------------------------------------------------------------- 1 | export const messages = { 2 | marketsList: 'Markets list', 3 | pair: 'Pair', 4 | price: 'Price', 5 | change: 'Change', 6 | some: '{test} asd', 7 | }; 8 | -------------------------------------------------------------------------------- /webpack-custom/config/configDevTool.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/configuration/devtool 3 | * 4 | */ 5 | 6 | const { getParam } = require('../utils/envParams'); 7 | 8 | module.exports = getParam('DEV_TOOL'); 9 | -------------------------------------------------------------------------------- /webpack-custom/plugins/pluginHot.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/guides/hot-module-replacement 3 | * 4 | */ 5 | 6 | const webpack = require('webpack'); 7 | 8 | module.exports = new webpack.HotModuleReplacementPlugin(); 9 | -------------------------------------------------------------------------------- /src/routeComponents.js: -------------------------------------------------------------------------------- 1 | import { MarketDetailed } from 'pages/MarketDetailed'; 2 | import { Error404 } from 'pages/Error404'; 3 | 4 | export const routeComponents = { 5 | marketDetailed: MarketDetailed, 6 | error404: Error404, 7 | }; 8 | -------------------------------------------------------------------------------- /src/api/utils/_utils.js: -------------------------------------------------------------------------------- 1 | export * from './metrics'; 2 | export * from './omitParam'; 3 | export * from './makeRequest'; 4 | export * from './makeRequestUrl'; 5 | export * from './validateResponse'; 6 | export * from './validateRequestParams'; 7 | -------------------------------------------------------------------------------- /webpack-custom/plugins/pluginClean.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://github.com/johnagan/clean-webpack-plugin 3 | * 4 | */ 5 | 6 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 7 | 8 | module.exports = new CleanWebpackPlugin(); 9 | -------------------------------------------------------------------------------- /src/utils/detectNilEquality.js: -------------------------------------------------------------------------------- 1 | export function detectNilEquality({ num, onEqual, onMore, onLess }) { 2 | if (num === 0) { 3 | return onEqual; 4 | } 5 | 6 | if (num > 0) { 7 | return onMore; 8 | } 9 | 10 | return onLess; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/App/App.js: -------------------------------------------------------------------------------- 1 | import { hot } from 'react-hot-loader/root'; 2 | import React from 'react'; 3 | import { RouterConnected } from 'components/Router'; 4 | 5 | function App() { 6 | return ; 7 | } 8 | 9 | export default hot(App); 10 | -------------------------------------------------------------------------------- /src/utils/renderToDOM.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@hot-loader/react-dom'; 3 | 4 | const containerElement = document.getElementById('app'); 5 | 6 | export function renderToDOM(Component) { 7 | render(, containerElement); 8 | } 9 | -------------------------------------------------------------------------------- /webpack-custom/rules/ruleSassThemes.js: -------------------------------------------------------------------------------- 1 | const loaderSassTheme = require('../loaders/loaderSassTheme'); 2 | const { themesPath } = require('../utils/paths'); 3 | 4 | module.exports = { 5 | test: /\.scss$/, 6 | include: [themesPath], 7 | use: [loaderSassTheme], 8 | }; 9 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import './polyfill'; 2 | import './styles/reset.scss'; 3 | import './styles/global.scss'; 4 | 5 | import { store } from 'stores'; 6 | import { initSentry } from 'utils'; 7 | import { initAutorun } from 'autorun'; 8 | 9 | initSentry(); 10 | initAutorun(store); 11 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/config/configYAxis.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | height: '65%', 4 | labels: { 5 | x: -5, 6 | }, 7 | }, 8 | { 9 | top: '70%', 10 | height: '30%', 11 | labels: { 12 | x: -5, 13 | }, 14 | }, 15 | ]; 16 | -------------------------------------------------------------------------------- /webpack-custom/loaders/loaderThreadParallel.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://github.com/webpack-contrib/thread-loader 3 | * 4 | */ 5 | 6 | module.exports = { 7 | loader: 'thread-loader', 8 | options: { 9 | workers: 4, 10 | poolParallelJobs: 50, 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /src/utils/setTheme.js: -------------------------------------------------------------------------------- 1 | import themes from 'styles/themes.scss'; 2 | 3 | const root = document.documentElement; 4 | 5 | export function setTheme(theme) { 6 | Object.entries(themes[theme]).forEach(([key, value]) => { 7 | root.style.setProperty(key, value); 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/pages/Error404/Error404.scss: -------------------------------------------------------------------------------- 1 | @import "mixins.scss"; 2 | 3 | .contentWrapper { 4 | text-align: center; 5 | 6 | .title { 7 | font-size: 40px; 8 | line-height: 46px; 9 | padding-top: 50px; 10 | } 11 | 12 | .text { 13 | padding-top: 20px; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/hooks/useStore.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { store } from 'stores'; 3 | 4 | const storeContext = React.createContext(store); 5 | 6 | /** 7 | * @returns {RootStore} 8 | * 9 | */ 10 | export function useStore() { 11 | return React.useContext(storeContext); 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/config/configScrollbar.js: -------------------------------------------------------------------------------- 1 | export default { 2 | barBorderWidth: 0, 3 | barBorderRadius: 0, 4 | buttonBorderWidth: 0, 5 | buttonBorderRadius: 0, 6 | trackBorderWidth: 1, 7 | trackBorderRadius: 0, 8 | margin: 0, 9 | height: 13, 10 | liveRedraw: true, 11 | }; 12 | -------------------------------------------------------------------------------- /webpack-custom/loaders/loaderCss.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://github.com/webpack-contrib/css-loader 3 | * 4 | */ 5 | 6 | module.exports = { 7 | loader: 'css-loader', 8 | options: { 9 | importLoaders: 1, 10 | modules: true, 11 | localIdentName: '[folder]__[local]', 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/utils/precise.js: -------------------------------------------------------------------------------- 1 | const byCurrency = { 2 | usd: 2, 3 | eth: 6, 4 | btc: 8, 5 | }; 6 | 7 | export function precise(value, tradedCurrency) { 8 | if (tradedCurrency === 'percent') { 9 | return value.toFixed(2); 10 | } 11 | 12 | return value.toFixed(byCurrency[tradedCurrency] || 4); 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/config/configChart.js: -------------------------------------------------------------------------------- 1 | import styles from '../Chart.scss'; 2 | 3 | export default { 4 | animated: true, 5 | marginTop: 2, 6 | marginBottom: 10, 7 | marginLeft: 0, 8 | marginRight: 0, 9 | className: styles.chart, 10 | spacing: [0, 0, 0, 0], 11 | height: 363, 12 | }; 13 | -------------------------------------------------------------------------------- /eslint-custom/rules/import.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | import/default: 2 3 | import/extensions: warn 4 | import/first: warn 5 | import/newline-after-import: off 6 | import/order: [2, { newlines-between: "always", groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'] }] 7 | import/prefer-default-export: off 8 | -------------------------------------------------------------------------------- /src/utils/declOfNum.js: -------------------------------------------------------------------------------- 1 | // declOfNum(count, ['найдена', 'найдено', 'найдены']); 2 | 3 | export function declOfNum(n, titles) { 4 | return titles[ 5 | n % 10 === 1 && n % 100 !== 11 6 | ? 0 7 | : n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) 8 | ? 1 9 | : 2 10 | ]; 11 | } 12 | -------------------------------------------------------------------------------- /tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tests", 3 | "version": "1.0.0", 4 | "description": "Integration tests for Exchange platform", 5 | "author": "Dmitry Kazakov", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "cypress open" 9 | }, 10 | "dependencies": { 11 | "cypress": "3.2.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /webpack-custom/config/configEntry.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/configuration/entry-context 3 | * 4 | */ 5 | 6 | const path = require('path'); 7 | 8 | const { sourcePath } = require('../utils/paths'); 9 | 10 | module.exports = { 11 | app: [path.resolve(sourcePath, 'app.js')].filter(Boolean), 12 | }; 13 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/assets/fill.svg: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /src/api/utils/makeRequest.js: -------------------------------------------------------------------------------- 1 | export function makeRequest(requestUrl) { 2 | const headers = new Headers(); 3 | headers.append('pragma', 'no-cache'); 4 | headers.append('cache-control', 'no-cache'); 5 | 6 | return fetch(requestUrl, { 7 | method: 'GET', 8 | headers, 9 | }).then(response => response.clone().json()); 10 | } 11 | -------------------------------------------------------------------------------- /eslint-custom/rules/node-js-and-common-js.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | callback-return: error 3 | global-require: off 4 | handle-callback-err: error 5 | no-mixed-requires: error 6 | no-new-require: error 7 | no-path-concat: error 8 | no-process-env: error 9 | no-process-exit: error 10 | no-restricted-modules: error 11 | no-sync: error 12 | -------------------------------------------------------------------------------- /webpack-custom/rules/ruleImages.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/loaders/url-loader 3 | * 4 | */ 5 | 6 | const { sourcePath } = require('../utils/paths'); 7 | 8 | const loaderUrl = require('../loaders/loaderUrl'); 9 | 10 | module.exports = { 11 | test: /\.(png|jpg|gif)$/, 12 | include: [sourcePath], 13 | use: [loaderUrl], 14 | }; 15 | -------------------------------------------------------------------------------- /webpack-custom/rules/ruleBabel.js: -------------------------------------------------------------------------------- 1 | const { sourcePath } = require('../utils/paths'); 2 | 3 | const loaderBabel = require('../loaders/loaderBabel'); 4 | const loaderThreadParallel = require('../loaders/loaderThreadParallel'); 5 | 6 | module.exports = { 7 | test: /\.jsx?$/, 8 | include: [sourcePath], 9 | use: [loaderThreadParallel, loaderBabel], 10 | }; 11 | -------------------------------------------------------------------------------- /.frontend.env.example: -------------------------------------------------------------------------------- 1 | AGGREGATION_TIMEOUT=0 2 | BUNDLE_ANALYZER=false 3 | BUNDLE_ANALYZER_PORT=8889 4 | CIRCULAR_CHECK=true 5 | CSS_EXTRACT=false 6 | DEV_SERVER_PORT=8080 7 | HOT_RELOAD=true 8 | NODE_ENV=development 9 | SENTRY_URL=false 10 | SPEED_ANALYZER=false 11 | PUBLIC_URL=false 12 | 13 | # https://webpack.js.org/configuration/devtool 14 | DEV_TOOL=cheap-module-source-map -------------------------------------------------------------------------------- /webpack-custom/rules/ruleSvgInline.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/loaders/svg-inline-loader 3 | * 4 | */ 5 | 6 | const { sourcePath } = require('../utils/paths'); 7 | 8 | const loaderSvgInline = require('../loaders/loaderSvgInline'); 9 | 10 | module.exports = { 11 | test: /\.svg$/, 12 | include: [sourcePath], 13 | use: [loaderSvgInline], 14 | }; 15 | -------------------------------------------------------------------------------- /.frontend.env.prod.example: -------------------------------------------------------------------------------- 1 | AGGREGATION_TIMEOUT=0 2 | BUNDLE_ANALYZER=false 3 | BUNDLE_ANALYZER_PORT=8889 4 | CIRCULAR_CHECK=false 5 | CSS_EXTRACT=true 6 | DEV_SERVER_PORT=8080 7 | HOT_RELOAD=false 8 | NODE_ENV=production 9 | SENTRY_URL=false 10 | SPEED_ANALYZER=false 11 | PUBLIC_URL=/exchange_habr/dist 12 | 13 | # https://webpack.js.org/configuration/devtool 14 | DEV_TOOL=false -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/config/configSeries.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | type: 'candlestick', 4 | name: 'Price', 5 | tooltip: { 6 | valueDecimals: 4, 7 | }, 8 | }, 9 | { 10 | type: 'area', 11 | name: 'Volume', 12 | yAxis: 1, 13 | tooltip: { 14 | valueDecimals: 2, 15 | }, 16 | threshold: null, 17 | }, 18 | ]; 19 | -------------------------------------------------------------------------------- /src/utils/formatPercentage.js: -------------------------------------------------------------------------------- 1 | import { detectNilEquality } from './detectNilEquality'; 2 | import { precise } from './precise'; 3 | 4 | export function formatPercentage(value) { 5 | const valueFormatted = precise(value, 'percent'); 6 | return detectNilEquality({ 7 | num: value, 8 | onMore: `+${valueFormatted}%`, 9 | onLess: `-${valueFormatted}%`, 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /webpack-custom/plugins/pluginCircularDependency.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://github.com/aackerman/circular-dependency-plugin 3 | * 4 | */ 5 | 6 | const CircularDependencyPlugin = require('circular-dependency-plugin'); 7 | 8 | module.exports = new CircularDependencyPlugin({ 9 | exclude: /node_modules/, 10 | failOnError: true, 11 | allowAsyncCycles: false, 12 | cwd: process.cwd(), 13 | }); 14 | -------------------------------------------------------------------------------- /webpack-custom/plugins/pluginExtract.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://github.com/webpack-contrib/mini-css-extract-plugin 3 | * 4 | */ 5 | 6 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 7 | 8 | const { isProduction } = require('../utils/envParams'); 9 | 10 | module.exports = new MiniCssExtractPlugin({ 11 | filename: isProduction ? '[name].[contenthash].css' : '[name].css', 12 | }); 13 | -------------------------------------------------------------------------------- /src/styles/mixins.scss: -------------------------------------------------------------------------------- 1 | @import "constants.scss"; 2 | @import "layout.scss"; 3 | 4 | @mixin clearfix { 5 | &:after { 6 | content: ""; 7 | display: table; 8 | clear: both; 9 | } 10 | } 11 | 12 | @mixin inline-top { 13 | display: inline-block; 14 | vertical-align: top; 15 | } 16 | 17 | @mixin ellipsis { 18 | white-space: nowrap; 19 | overflow: hidden; 20 | text-overflow: ellipsis; 21 | } 22 | -------------------------------------------------------------------------------- /tests/cypress/support/polyfillFetch.js: -------------------------------------------------------------------------------- 1 | let polyfill = null; 2 | 3 | before(() => { 4 | const polyfillUrl = 'https://unpkg.com/unfetch/dist/unfetch.umd.js'; 5 | cy.request(polyfillUrl).then(response => { 6 | polyfill = response.body; 7 | }); 8 | }); 9 | 10 | Cypress.on('window:before:load', window => { 11 | delete window.fetch; 12 | window.eval(polyfill); 13 | window.fetch = window.unfetch; 14 | }); 15 | -------------------------------------------------------------------------------- /src/styles/global.scss: -------------------------------------------------------------------------------- 1 | @import "mixins.scss"; 2 | @import url("https://fonts.googleapis.com/css?family=Roboto"); 3 | 4 | body { 5 | font-family: "Roboto", sans-serif; 6 | font-size: 14px; 7 | line-height: $line_height; 8 | overflow-x: hidden; 9 | background: $N0; 10 | } 11 | 12 | ::selection { 13 | background: $N900; 14 | color: $N0; 15 | fill: $N0; 16 | } 17 | 18 | a { 19 | text-decoration: none; 20 | } 21 | -------------------------------------------------------------------------------- /webpack-custom/config/configResolve.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/configuration/resolve 3 | * 4 | */ 5 | 6 | const path = require('path'); 7 | 8 | const { rootPath, sourcePath } = require('../utils/paths'); 9 | 10 | module.exports = { 11 | modules: [sourcePath, path.resolve(rootPath, 'node_modules')], 12 | extensions: ['.js'], 13 | alias: { 14 | 'react-dom': '@hot-loader/react-dom', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /webpack-custom/loaders/loaderExtractCss.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://github.com/webpack-contrib/mini-css-extract-plugin 3 | * 4 | */ 5 | 6 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 7 | 8 | const { getParamAsBoolean } = require('../utils/envParams'); 9 | 10 | module.exports = { 11 | loader: MiniCssExtractPlugin.loader, 12 | options: { 13 | hmr: getParamAsBoolean('HOT_RELOAD'), 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /webpack-custom/plugins/pluginSpeedMeasure.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://github.com/stephencookdev/speed-measure-webpack-plugin 3 | * 4 | */ 5 | 6 | const SpeedMeasurePlugin = require('speed-measure-webpack-plugin'); 7 | 8 | const { getParamAsBoolean } = require('../utils/envParams'); 9 | 10 | module.exports = new SpeedMeasurePlugin({ 11 | disable: !getParamAsBoolean('SPEED_ANALYZER'), 12 | outputFormat: 'human', 13 | }); 14 | -------------------------------------------------------------------------------- /src/utils/initSentry.js: -------------------------------------------------------------------------------- 1 | import * as Sentry from '@sentry/browser'; 2 | 3 | export function initSentry() { 4 | if (SENTRY_URL !== 'false') { 5 | Sentry.init({ 6 | dsn: SENTRY_URL, 7 | }); 8 | 9 | const originalErrorLogger = console.error; 10 | console.error = function consoleErrorCustom(...args) { 11 | Sentry.captureException(...args); 12 | 13 | return originalErrorLogger(...args); 14 | }; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | const webpack = require('../../../node_modules/@cypress/webpack-preprocessor'); 2 | const webpackConfig = require('../../../webpack-custom/webpack.config'); 3 | 4 | module.exports = on => { 5 | const options = webpack.defaultOptions; 6 | 7 | options.webpackOptions.module = webpackConfig.module; 8 | options.webpackOptions.resolve = webpackConfig.resolve; 9 | 10 | on('file:preprocessor', webpack(options)); 11 | }; 12 | -------------------------------------------------------------------------------- /webpack-custom/plugins/pluginDefine.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/plugins/define-plugin 3 | * 4 | */ 5 | 6 | const webpack = require('webpack'); 7 | 8 | const { getParam } = require('../utils/envParams'); 9 | 10 | module.exports = new webpack.DefinePlugin({ 11 | SENTRY_URL: JSON.stringify(getParam('SENTRY_URL')), 12 | HOT_RELOAD: JSON.stringify(getParam('HOT_RELOAD')), 13 | PUBLIC_URL: JSON.stringify(getParam('PUBLIC_URL')), 14 | }); 15 | -------------------------------------------------------------------------------- /webpack-custom/utils/paths.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | rootPath: path.resolve(__dirname, '../../'), 5 | distPath: path.resolve(__dirname, '../../dist'), 6 | sourcePath: path.resolve(__dirname, '../../src'), 7 | stylesPath: path.resolve(__dirname, '../../src/styles'), 8 | themesPath: path.resolve(__dirname, '../../src/styles/themes.scss'), 9 | templatesPath: path.resolve(__dirname, '../../templates'), 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils/_utils.js: -------------------------------------------------------------------------------- 1 | export * from './sum'; 2 | export * from './precise'; 3 | export * from './observer'; 4 | export * from './setTheme'; 5 | export * from './declOfNum'; 6 | export * from './initSentry'; 7 | export * from './renderToDOM'; 8 | export * from './getUrlParams'; 9 | export * from './makeObservable'; 10 | export * from './objectToEncoded'; 11 | export * from './validateObjects'; 12 | export * from './formatPercentage'; 13 | export * from './detectNilEquality'; 14 | -------------------------------------------------------------------------------- /tests/cypress/support/routes.js: -------------------------------------------------------------------------------- 1 | import { apiRoutes } from 'api'; 2 | 3 | before(() => { 4 | cy.server(); 5 | cy.route(`${apiRoutes.symbolsList.url}**`).as('symbolsList'); 6 | cy.route(`${apiRoutes.rates.url}**`).as('rates'); 7 | cy.route(`${apiRoutes.marketsList.url}**`).as('marketsList'); 8 | cy.route(`${apiRoutes.symbolInfo.url({ id: 'bitcoin-cash' })}**`).as( 9 | 'symbolInfo' 10 | ); 11 | cy.route(`${apiRoutes.chartData.url}**`).as('chartData'); 12 | }); 13 | -------------------------------------------------------------------------------- /src/utils/objectToEncoded.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | export function objectToEncoded(obj) { 4 | const str = []; 5 | for (const p in obj) { 6 | if (obj.hasOwnProperty(p)) { 7 | const v = obj[p]; 8 | if (v !== '') { 9 | str.push( 10 | _.isObject(v) 11 | ? objectToEncoded(v, p) 12 | : `${encodeURIComponent(p)}=${encodeURIComponent(v)}` 13 | ); 14 | } 15 | } 16 | } 17 | return str.join('&'); 18 | } 19 | -------------------------------------------------------------------------------- /webpack-custom/plugins/pluginHtml.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://github.com/webpack-contrib/webpack-bundle-analyzer 3 | * 4 | */ 5 | 6 | const path = require('path'); 7 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 8 | 9 | const { templatesPath } = require('../utils/paths'); 10 | 11 | module.exports = new HtmlWebpackPlugin({ 12 | title: 'Exchange', 13 | filename: 'index.html', 14 | template: path.resolve(templatesPath, 'index.html'), 15 | inject: 'body', 16 | }); 17 | -------------------------------------------------------------------------------- /webpack-custom/config/configModule.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/configuration/module 3 | * 4 | */ 5 | 6 | const ruleBabel = require('../rules/ruleBabel'); 7 | const ruleSass = require('../rules/ruleSass'); 8 | const ruleImages = require('../rules/ruleImages'); 9 | const ruleSvgInline = require('../rules/ruleSvgInline'); 10 | const ruleSassThemes = require('../rules/ruleSassThemes'); 11 | 12 | module.exports = { 13 | rules: [ruleBabel, ruleSass, ruleSassThemes, ruleSvgInline, ruleImages], 14 | }; 15 | -------------------------------------------------------------------------------- /webpack-custom/config/configOutput.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/concepts/output 3 | * 4 | */ 5 | 6 | const { distPath } = require('../utils/paths'); 7 | const { isProduction, getParam } = require('../utils/envParams'); 8 | 9 | let publicUrl = getParam('PUBLIC_URL'); 10 | publicUrl = publicUrl === 'false' ? '' : publicUrl; 11 | 12 | module.exports = { 13 | path: distPath, 14 | filename: isProduction ? '[name].[contenthash].js' : '[name].js', 15 | pathinfo: false, 16 | publicPath: `${publicUrl}/`, 17 | }; 18 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/MarketList/MarketTabs.js: -------------------------------------------------------------------------------- 1 | import { observer } from 'utils'; 2 | import { useStore } from 'hooks'; 3 | 4 | import { Tabs } from 'components/Tabs'; 5 | 6 | function MarketTabs() { 7 | const store = useStore(); 8 | const currentTab = store.marketsList.currentMarket; 9 | const currentPair = store.currentTP.urlName; 10 | 11 | return Tabs({ 12 | listArray: store.marketsList.availableMarkets, 13 | currentTab, 14 | currentPair, 15 | }); 16 | } 17 | 18 | export const MarketTabsConnected = observer(MarketTabs); 19 | -------------------------------------------------------------------------------- /webpack-custom/loaders/loaderBabel.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://github.com/babel/babel-loader 3 | * 4 | */ 5 | 6 | const { getParamAsBoolean } = require('../utils/envParams'); 7 | 8 | module.exports = { 9 | loader: 'babel-loader', 10 | options: { 11 | presets: ['@babel/preset-env', '@babel/preset-react'], 12 | plugins: [ 13 | getParamAsBoolean('HOT_RELOAD') && 'react-hot-loader/babel', 14 | ['@babel/plugin-proposal-decorators', { legacy: true }], 15 | ['@babel/plugin-proposal-class-properties', { loose: true }], 16 | 'lodash', 17 | ].filter(Boolean), 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/GlobalLoader/GlobalLoader.scss: -------------------------------------------------------------------------------- 1 | @import "mixins.scss"; 2 | 3 | .loader { 4 | position: fixed; 5 | z-index: 100; 6 | left: 0; 7 | top: 0; 8 | width: 100%; 9 | height: 6px; 10 | background: $G500; 11 | opacity: 0; 12 | transform: translateX(-100%); 13 | 14 | &.firstHalf { 15 | opacity: 1; 16 | transform: translateX(-30%); 17 | transition: transform 4s cubic-bezier(0.075, 0.85, 0.085, 0.885), 18 | opacity 0.5s ease; 19 | } 20 | 21 | &.lastHalf { 22 | opacity: 0; 23 | transform: translateX(50%); 24 | transition: opacity 0.5s ease, transform 0.5s ease; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Message/Message.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { observer } from 'utils'; 4 | import { useLocalization } from 'hooks'; 5 | 6 | function Message(props) { 7 | const { filename, messages, text, values } = props; 8 | 9 | const getLn = useLocalization(filename, messages); 10 | 11 | return getLn(text, values); 12 | } 13 | 14 | const ConnectedMessage = observer(Message); 15 | 16 | export function init(filename, messages) { 17 | return function MessageHoc(props) { 18 | const fullProps = { filename, messages, ...props }; 19 | 20 | return ; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/stores/GlobalStore.js: -------------------------------------------------------------------------------- 1 | import { makeObservable, setTheme } from 'utils'; 2 | import themes from 'styles/themes.scss'; 3 | 4 | const themesList = Object.keys(themes); 5 | 6 | @makeObservable 7 | export class GlobalStore { 8 | /** 9 | * @param rootStore {RootStore} 10 | */ 11 | constructor(rootStore) { 12 | this.rootStore = rootStore; 13 | 14 | this.setTheme(themesList[0]); 15 | } 16 | 17 | shouldAppRender = false; 18 | themesList = themesList; 19 | currentTheme = ''; 20 | 21 | setTheme(theme) { 22 | this.currentTheme = theme; 23 | document.body.className = theme; 24 | setTheme(theme); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/validateObjects.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | export function validateObjects({ validatorsObject, targetObject }, otherArg) { 4 | if (!_.isPlainObject(validatorsObject)) { 5 | throw new Error(`validateObjects: validatorsObject is not an object`); 6 | } 7 | 8 | if (!_.isPlainObject(targetObject)) { 9 | throw new Error(`validateObjects: targetObject is not an object`); 10 | } 11 | 12 | Object.entries(validatorsObject).forEach(([paramName, validator]) => { 13 | const paramValue = targetObject[paramName]; 14 | 15 | if (!validator(paramValue, otherArg)) { 16 | throw new Error(paramName); 17 | } 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/stores/RatesStore.js: -------------------------------------------------------------------------------- 1 | import { makeObservable } from 'utils'; 2 | import { apiRoutes, request } from 'api'; 3 | 4 | @makeObservable 5 | export class RatesStore { 6 | constructor(rootStore) { 7 | this.rootStore = rootStore; 8 | } 9 | 10 | rates = {}; 11 | 12 | fetchRates() { 13 | if (this.rates.length > 0) { 14 | return Promise.resolve(); 15 | } 16 | 17 | return request(apiRoutes.rates) 18 | .then(this.fetchRatesSuccess) 19 | .catch(this.fetchRatesError); 20 | } 21 | fetchRatesSuccess(data) { 22 | this.rates = data.rates; 23 | } 24 | fetchRatesError(error) { 25 | console.error(error); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /webpack-custom/plugins/pluginAnalyzer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://github.com/webpack-contrib/webpack-bundle-analyzer 3 | * 4 | */ 5 | 6 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 7 | 8 | const { getParamAsNumber } = require('../utils/envParams'); 9 | 10 | module.exports = new BundleAnalyzerPlugin({ 11 | analyzerMode: 'server', 12 | analyzerHost: '127.0.0.1', 13 | analyzerPort: getParamAsNumber('BUNDLE_ANALYZER_PORT'), 14 | reportFilename: 'report.html', 15 | defaultSizes: 'parsed', 16 | openAnalyzer: false, 17 | generateStatsFile: false, 18 | statsFilename: 'stats.json', 19 | statsOptions: null, 20 | logLevel: 'silent', 21 | }); 22 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | <%= htmlWebpackPlugin.options.title %> 11 | 12 | 15 | 16 | 17 |
18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /src/pages/Error404/Error404.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { routes } from 'routes'; 4 | import { LinkConnected } from 'components/Link'; 5 | 6 | import styles from './Error404.scss'; 7 | 8 | export function Error404() { 9 | return ( 10 |
11 |
404
12 |
13 | Try{' '} 14 | 21 | this link 22 | 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/stores/RootStore.js: -------------------------------------------------------------------------------- 1 | import { I18nStore } from './I18nStore'; 2 | import { RatesStore } from './RatesStore'; 3 | import { GlobalStore } from './GlobalStore'; 4 | import { RouterStore } from './RouterStore'; 5 | import { CurrentTPStore } from './CurrentTPStore'; 6 | import { MarketsListStore } from './MarketsListStore'; 7 | 8 | /** 9 | * @name RootStore 10 | */ 11 | export class RootStore { 12 | constructor() { 13 | this.i18n = new I18nStore(this); 14 | this.rates = new RatesStore(this); 15 | this.global = new GlobalStore(this); 16 | this.router = new RouterStore(this); 17 | this.currentTP = new CurrentTPStore(this); 18 | this.marketsList = new MarketsListStore(this); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/MarketDetailed.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { GlobalLoader } from 'components/GlobalLoader'; 4 | 5 | import { ChartConnected } from './Chart'; 6 | import { TPInfoConnected } from './TPInfo'; 7 | import { OrderBookConnected } from './OrderBook'; 8 | import { MarketsListConnected } from './MarketList'; 9 | 10 | import styles from './MarketDetailed.scss'; 11 | 12 | export function MarketDetailed({ isLoading }) { 13 | return ( 14 |
15 | 16 | 17 | 18 | 19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /webpack-custom/rules/ruleSass.js: -------------------------------------------------------------------------------- 1 | const loaderCss = require('../loaders/loaderCss'); 2 | const loaderStyle = require('../loaders/loaderStyle'); 3 | const loaderPostcss = require('../loaders/loaderPostcss'); 4 | const loaderExtractCss = require('../loaders/loaderExtractCss'); 5 | 6 | const { sourcePath, themesPath } = require('../utils/paths'); 7 | const { getParamAsBoolean } = require('../utils/envParams'); 8 | 9 | module.exports = { 10 | test: /\.scss$/, 11 | include: [sourcePath], 12 | exclude: [themesPath], 13 | use: [ 14 | !getParamAsBoolean('CSS_EXTRACT') && loaderStyle, 15 | getParamAsBoolean('CSS_EXTRACT') && loaderExtractCss, 16 | loaderCss, 17 | loaderPostcss, 18 | ].filter(Boolean), 19 | }; 20 | -------------------------------------------------------------------------------- /webpack-custom/config/configDevServer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/configuration/dev-server 3 | * 4 | */ 5 | 6 | const { getParamAsBoolean, getParamAsNumber } = require('../utils/envParams'); 7 | 8 | module.exports = { 9 | hot: getParamAsBoolean('HOT_RELOAD'), 10 | port: getParamAsNumber('DEV_SERVER_PORT'), 11 | stats: 'errors-only', 12 | overlay: true, 13 | headers: { 14 | 'Access-Control-Allow-Origin': '*', 15 | 'Access-Control-Allow-Credentials': 'true', 16 | }, 17 | compress: true, 18 | watchOptions: { 19 | aggregateTimeout: getParamAsNumber('AGGREGATION_TIMEOUT'), 20 | }, 21 | clientLogLevel: 'none', // Disable [HRM] logging to console 22 | historyApiFallback: true, 23 | }; 24 | -------------------------------------------------------------------------------- /src/localization/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "src\\pages\\MarketDetailed\\TPInfo\\TPInfo.js": { 3 | "lastPrice": "Last price", 4 | "change24h": "24h Change", 5 | "high24h": "24h High", 6 | "low24h": "24h Low" 7 | }, 8 | "src\\pages\\MarketDetailed\\MarketList\\MarketList.js": { 9 | "pair": "Pair", 10 | "price": "Price", 11 | "change": "Change", 12 | "marketsList": "Markets list" 13 | }, 14 | "src\\pages\\MarketDetailed\\OrderBook\\OrderBook.js": { 15 | "orderBook": "Order book", 16 | "price": "Price", 17 | "amount": "Amount", 18 | "total": "Total" 19 | }, 20 | "src\\pages\\MarketDetailed\\Chart\\Chart.js": { 21 | "chart": "Chart", 22 | "month": "{count} {count: month,months,months}" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/localization/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "src\\pages\\MarketDetailed\\TPInfo\\TPInfo.js": { 3 | "lastPrice": "Текущая цена", 4 | "change24h": "Изменение 24ч", 5 | "high24h": "Высшая цена 24ч", 6 | "low24h": "Низшая цена 24ч" 7 | }, 8 | "src\\pages\\MarketDetailed\\MarketList\\MarketList.js": { 9 | "pair": "Пара", 10 | "price": "Цена", 11 | "change": "Изменение", 12 | "marketsList": "Рынки" 13 | }, 14 | "src\\pages\\MarketDetailed\\OrderBook\\OrderBook.js": { 15 | "orderBook": "Список предложений", 16 | "price": "Цена", 17 | "amount": "Кол-во", 18 | "total": "Всего" 19 | }, 20 | "src\\pages\\MarketDetailed\\Chart\\Chart.js": { 21 | "chart": "График", 22 | "month": "{count} {count: месяц,месяцa,месяцев}" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /eslint-custom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-custom", 3 | "version": "0.0.1", 4 | "description": "Custom linter rules for this project", 5 | "author": "Dmitry Kazakov", 6 | "license": "MIT", 7 | "dependencies": { 8 | "babel-eslint": "10.0.1", 9 | "eslint": "5.16.0", 10 | "eslint-config-prettier": "4.2.0", 11 | "eslint-plugin-cypress": "2.2.1", 12 | "eslint-plugin-import": "2.17.2", 13 | "eslint-plugin-prettier": "3.0.1", 14 | "eslint-plugin-react": "7.12.4", 15 | "eslint-plugin-react-hooks": "1.6.0", 16 | "prettier": "1.17.0", 17 | "prettier-eslint": "8.8.2", 18 | "stylelint": "10.0.1", 19 | "stylelint-config-prettier": "5.1.0", 20 | "stylelint-prettier": "1.0.6", 21 | "stylelint-scss": "3.6.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /eslint-custom/rules/react.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | react/display-name: off 3 | react/forbid-prop-types: off 4 | react/jsx-closing-bracket-location: off 5 | react/jsx-filename-extension: off 6 | react/jsx-first-prop-new-line: off 7 | react/jsx-indent-props: [off, error] 8 | react/jsx-indent: [off, error] 9 | react/jsx-no-target-blank: off 10 | react/jsx-uses-react: warn 11 | react/jsx-uses-vars: warn 12 | react/no-array-index-key: off 13 | react/no-danger: off 14 | react/no-did-mount-set-state: off 15 | react/no-find-dom-node: off 16 | react/no-multi-comp: off 17 | react/no-string-refs: off 18 | react/no-typos: warn 19 | react/no-unescaped-entities: warn 20 | react/no-unused-prop-types: warn 21 | react/no-unused-state: warn 22 | react/prop-types: off 23 | react/require-default-props: off 24 | -------------------------------------------------------------------------------- /src/stores/I18nStore.js: -------------------------------------------------------------------------------- 1 | import { makeObservable } from 'utils'; 2 | import ru from 'localization/ru.json'; 3 | import en from 'localization/en.json'; 4 | 5 | const languages = { 6 | ru, 7 | en, 8 | }; 9 | 10 | const languagesList = Object.keys(languages); 11 | 12 | @makeObservable 13 | export class I18nStore { 14 | /** 15 | * @param rootStore {RootStore} 16 | */ 17 | constructor(rootStore) { 18 | this.rootStore = rootStore; 19 | 20 | setTimeout(() => { 21 | this.setLocalization('ru'); 22 | }, 500); 23 | } 24 | 25 | i18n = {}; 26 | languagesList = languagesList; 27 | currentLanguage = ''; 28 | 29 | setLocalization(language) { 30 | this.currentLanguage = language; 31 | this.i18n = languages[language]; 32 | this.rootStore.global.shouldAppRender = true; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/GlobalLoader/GlobalLoader.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import styles from './GlobalLoader.scss'; 5 | 6 | const loaderElement = document.getElementById('topLoader'); 7 | 8 | export function GlobalLoader({ isLoading }) { 9 | const ref = React.useRef(); 10 | 11 | React.useEffect(() => { 12 | if (isLoading) { 13 | loaderElement.className = styles.loader; 14 | 15 | ref.current = setTimeout(() => { 16 | loaderElement.className = cn(styles.loader, styles.firstHalf); 17 | }, 100); 18 | } else { 19 | clearTimeout(ref.current); 20 | 21 | loaderElement.className = cn(styles.loader, styles.lastHalf); 22 | } 23 | 24 | return () => { 25 | clearTimeout(ref.current); 26 | }; 27 | }); 28 | 29 | return null; 30 | } 31 | -------------------------------------------------------------------------------- /eslint-custom/rules/stylelint.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | at-rule-empty-line-before: 3 | - always 4 | - 5 | except: 6 | - blockless-after-same-name-blockless 7 | - first-nested 8 | ignore: 9 | - after-comment 10 | declaration-empty-line-before: 11 | - always 12 | - 13 | except: 14 | - after-declaration 15 | - first-nested 16 | ignore: 17 | - after-comment 18 | - inside-single-line-block 19 | max-empty-lines: 1 20 | rule-empty-line-before: 21 | - always-multi-line 22 | - 23 | except: 24 | - first-nested 25 | ignore: 26 | - after-comment 27 | scss/dollar-variable-empty-line-before: 28 | - always 29 | - 30 | except: 31 | - after-dollar-variable 32 | - first-nested 33 | ignore: 34 | - after-comment 35 | -------------------------------------------------------------------------------- /src/autorun.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | 3 | import { autorun } from 'mobx'; 4 | 5 | import { renderToDOM } from 'utils'; 6 | import App from 'components/App'; 7 | 8 | const loggingEnabled = false; 9 | 10 | function logReason(autorunName, reaction) { 11 | if (!loggingEnabled || reaction.observing.length === 0) { 12 | return false; 13 | } 14 | 15 | const logString = reaction.observing.reduce( 16 | (str, { name, value }) => `${str}${name} changed to ${value}; `, 17 | '' 18 | ); 19 | 20 | console.log(`autorun-${autorunName}`, logString); 21 | } 22 | 23 | /** 24 | * @param store {RootStore} 25 | */ 26 | export function initAutorun(store) { 27 | autorun(reaction => { 28 | if (store.global.shouldAppRender) { 29 | renderToDOM(App); 30 | } 31 | 32 | logReason('shouldAppRender', reaction); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/styles/constants.scss: -------------------------------------------------------------------------------- 1 | // Blue 2 | $B100: var(--b100); 3 | $B300: var(--b300); 4 | $B500: var(--b500); 5 | $B500a3: var(--b500a3); 6 | $B900: var(--b900); 7 | 8 | // Grey 9 | $N0: var(--n0); 10 | $N100: var(--n100); 11 | $N10: var(--n10); 12 | $N10a3: var(--n10a3); 13 | $N20: var(--n20); 14 | $N30: var(--n30); 15 | $N500: var(--n500); 16 | $N600: var(--n600); 17 | $N900: var(--n900); 18 | 19 | // Green 20 | $G400: var(--g400); 21 | $G500: var(--g500); 22 | $G500a1: var(--g500a1); 23 | $G500a2: var(--g500a2); 24 | 25 | // Red 26 | $R400: var(--r400); 27 | $R500: var(--r500); 28 | $R500a1: var(--r500a1); 29 | $R500a2: var(--r500a2); 30 | 31 | // Other 32 | $border_color: $N30; 33 | $border_radius: 4px; 34 | $col_spacing: 8px; 35 | $col_width: 100% / 8; 36 | $light_bg_color: $N10; 37 | $line_height: 20px; 38 | $table_col_padding_side: 5px; 39 | $table_col_padding_top: 2px; 40 | $trans: 0.2s ease; 41 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/config/chartConfig.js: -------------------------------------------------------------------------------- 1 | import configChart from './configChart'; 2 | import configYAxis from './configYAxis'; 3 | import configLegend from './configLegend'; 4 | import configCredits from './configCredits'; 5 | import configTooltip from './configTooltip'; 6 | import configNavigator from './configNavigator'; 7 | import configScrollbar from './configScrollbar'; 8 | import configRangeSelector from './configRangeSelector'; 9 | import configSeries from './configSeries'; 10 | 11 | /** 12 | * @docs: https://api.highcharts.com/highstock 13 | * 14 | */ 15 | 16 | export const chartConfig = { 17 | chart: configChart, 18 | yAxis: configYAxis, 19 | legend: configLegend, 20 | credits: configCredits, 21 | tooltip: configTooltip, 22 | navigator: configNavigator, 23 | scrollbar: configScrollbar, 24 | rangeSelector: configRangeSelector, 25 | series: configSeries, 26 | }; 27 | -------------------------------------------------------------------------------- /src/api/utils/makeRequestUrl.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import { omitParam } from './omitParam'; 4 | import { objectToEncoded } from 'utils'; 5 | 6 | export function makeRequestUrl(route, params) { 7 | return function promiseCallback() { 8 | let requestUrl = route.url; 9 | 10 | if (_.isFunction(requestUrl)) { 11 | requestUrl = requestUrl(params); 12 | } 13 | 14 | if (_.isPlainObject(params)) { 15 | // Пропустим вставку в URL параметров, валидаторы для которых === omitParam 16 | const clearedValidators = _.omitBy( 17 | route.params, 18 | validator => validator === omitParam 19 | ); 20 | const clearedParams = _.pick(params, Object.keys(clearedValidators)); 21 | 22 | if (_.size(clearedParams) > 0) { 23 | requestUrl += `?${objectToEncoded(clearedParams)}`; 24 | } 25 | } 26 | 27 | return requestUrl; 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/config/configTooltip.js: -------------------------------------------------------------------------------- 1 | export default { 2 | shape: 'square', 3 | borderWidth: 0, 4 | useHTML: true, 5 | padding: 0, 6 | shadow: false, 7 | animation: false, 8 | hideDelay: 0, 9 | positioner(width, height, point) { 10 | const { chart } = this; 11 | let position = null; 12 | 13 | if (point.isHeader) { 14 | position = { 15 | x: Math.max( 16 | // Left side limit 17 | chart.plotLeft, 18 | Math.min( 19 | point.plotX + chart.plotLeft - width / 2, 20 | // Right side limit 21 | chart.chartWidth - width - chart.marginRight 22 | ) 23 | ), 24 | y: point.plotY, 25 | }; 26 | } else { 27 | position = { 28 | x: point.series.chart.plotLeft, 29 | y: point.series.yAxis.top - chart.plotTop, 30 | }; 31 | } 32 | 33 | return position; 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/config/configRangeSelector.js: -------------------------------------------------------------------------------- 1 | import { messages } from '../messages'; 2 | 3 | export default { 4 | inputEnabled: false, 5 | selected: 1, 6 | height: 24, 7 | labelStyle: { 8 | display: 'none', 9 | }, 10 | buttonPosition: { 11 | align: 'right', 12 | }, 13 | buttonSpacing: 0, 14 | buttons: [ 15 | { 16 | type: 'month', 17 | text: messages.month, 18 | count: 1, 19 | dataGrouping: { 20 | forced: true, 21 | units: [['day', [1]]], 22 | }, 23 | }, 24 | { 25 | type: 'month', 26 | text: messages.month, 27 | count: 3, 28 | dataGrouping: { 29 | forced: true, 30 | units: [['day', [1]]], 31 | }, 32 | }, 33 | { 34 | type: 'month', 35 | text: messages.month, 36 | count: 6, 37 | dataGrouping: { 38 | forced: true, 39 | units: [['week', [1]]], 40 | }, 41 | }, 42 | ], 43 | }; 44 | -------------------------------------------------------------------------------- /tests/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /eslint-custom/rules/possible-errors.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | no-await-in-loop: error 3 | no-compare-neg-zero: off 4 | no-cond-assign: error 5 | no-console: off 6 | no-constant-condition: error 7 | no-control-regex: error 8 | no-debugger: error 9 | no-dupe-args: error 10 | no-dupe-keys: error 11 | no-duplicate-case: error 12 | no-empty-character-class: error 13 | no-empty: error 14 | no-ex-assign: error 15 | no-extra-boolean-cast: off 16 | no-extra-parens: warn 17 | no-extra-semi: error 18 | no-func-assign: error 19 | no-inner-declarations: error 20 | no-invalid-regexp: error 21 | no-irregular-whitespace: error 22 | no-obj-calls: error 23 | no-prototype-builtins: off 24 | no-regex-spaces: error 25 | no-sparse-arrays: error 26 | no-template-curly-in-string: error 27 | no-unexpected-multiline: error 28 | no-unreachable: error 29 | no-unsafe-finally: error 30 | no-unsafe-negation: error 31 | use-isnan: error 32 | valid-jsdoc: off 33 | valid-typeof: error 34 | -------------------------------------------------------------------------------- /src/components/Tabs/Tabs.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import { routes } from 'routes'; 5 | 6 | import { LinkConnected } from 'components/Link'; 7 | 8 | import styles from './Tabs.scss'; 9 | 10 | export function Tabs({ listArray, currentPair, currentTab }) { 11 | const tabStyle = { width: `${100 / listArray.length}%` }; 12 | 13 | return ( 14 |
15 | {listArray.map(symbol => ( 16 | 30 | {symbol} 31 | 32 | ))} 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/api/utils/metrics.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | let metricsArray = []; 4 | let sendMetricsCallback = null; 5 | 6 | export function startMetrics(route, apiRoutes) { 7 | return function promiseCallback(data) { 8 | clearTimeout(sendMetricsCallback); 9 | const apiRouteName = _.findKey(apiRoutes, route); 10 | 11 | metricsArray.push({ 12 | id: apiRouteName, 13 | time: new Date().getTime(), 14 | }); 15 | 16 | return data; 17 | }; 18 | } 19 | 20 | export function stopMetrics(route, apiRoutes) { 21 | return function promiseCallback(data) { 22 | const apiRouteName = _.findKey(apiRoutes, route); 23 | const metricsData = _.find(metricsArray, ['id', apiRouteName]); 24 | 25 | metricsData.time = new Date().getTime() - metricsData.time; 26 | 27 | clearTimeout(sendMetricsCallback); 28 | sendMetricsCallback = setTimeout(() => { 29 | console.log('Metrics sent:', metricsArray); 30 | metricsArray = []; 31 | }, 2000); 32 | 33 | return data; 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /webpack-custom/loaders/loaderPostcss.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://github.com/postcss/postcss-loader 3 | * 4 | */ 5 | 6 | const { stylesPath } = require('../utils/paths'); 7 | 8 | module.exports = { 9 | loader: 'postcss-loader', 10 | options: { 11 | parser: 'postcss-scss', 12 | plugins: () => [ 13 | // https://github.com/postcss/postcss-import 14 | require('postcss-import')({ 15 | path: [stylesPath], 16 | }), 17 | 18 | // https://github.com/mummybot/postcss-strip-inline-comments 19 | require('postcss-strip-inline-comments')(), 20 | 21 | // https://github.com/jonathantneal/postcss-advanced-variables 22 | require('postcss-advanced-variables')(), 23 | 24 | // https://github.com/MadLittleMods/postcss-css-variables 25 | require('postcss-custom-properties')({ 26 | // preserve: true, 27 | }), 28 | 29 | require('postcss-nested')(), 30 | require('postcss-automath')(), 31 | require('autoprefixer')(), 32 | ], 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /src/api/utils/validateRequestParams.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import { omitParam } from './omitParam'; 4 | import { validateObjects } from 'utils'; 5 | 6 | export function validateRequestParams(route, params) { 7 | return function promiseCallback() { 8 | if (!_.isPlainObject(params)) { 9 | return Promise.resolve(); 10 | } 11 | 12 | try { 13 | // Пропустим валидацию для параметров, у которых валидатор === omitParam 14 | const clearedValidators = _.omitBy( 15 | route.params, 16 | validator => validator === omitParam 17 | ); 18 | 19 | validateObjects({ 20 | validatorsObject: clearedValidators, 21 | targetObject: params, 22 | }); 23 | } catch (error) { 24 | if (error.message.indexOf('validateObjects') !== -1) { 25 | throw error; 26 | } 27 | 28 | throw new Error( 29 | `request: param ${error.message} has wrong value. Requested url: ${ 30 | route.url 31 | }` 32 | ); 33 | } 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/getUrlParams.js: -------------------------------------------------------------------------------- 1 | export function getUrlParams() { 2 | const search = location.search.substring(1); 3 | const params = {}; 4 | 5 | try { 6 | if (!search.length) { 7 | return params; 8 | } 9 | const urlSearchArr = decodeURIComponent(search) 10 | .replace(/"/g, '\\"') 11 | .split('&'); 12 | 13 | for (let i = 0; i < urlSearchArr.length; i++) { 14 | const param = urlSearchArr[i].split('='); 15 | // eslint-disable-next-line 16 | const key = param[0]; 17 | // eslint-disable-next-line 18 | let val = param[1]; 19 | 20 | if (val.match(/^\d+$/)) { 21 | val = parseInt(val, 10); 22 | } else if (val.match(/^\d+\.\d+$/)) { 23 | val = parseFloat(val); 24 | } 25 | 26 | if (val === 'false') { 27 | val = false; 28 | } 29 | if (val === 'true') { 30 | val = true; 31 | } 32 | 33 | params[key] = val; 34 | } 35 | } catch (err) { 36 | console.error(err); 37 | } 38 | 39 | return params; 40 | } 41 | -------------------------------------------------------------------------------- /webpack-custom/utils/envParams.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://github.com/mrsteele/dotenv-webpack 3 | * 4 | */ 5 | 6 | const path = require('path'); 7 | const Dotenv = require('dotenv-webpack'); 8 | 9 | const { rootPath } = require('./paths'); 10 | 11 | let envParams = null; 12 | 13 | function setEnvParams() { 14 | if (!envParams) { 15 | envParams = new Dotenv({ 16 | path: path.resolve(rootPath, '.frontend.env'), 17 | safe: path.resolve(rootPath, '.frontend.env.example'), 18 | systemvars: false, 19 | defaults: false, 20 | }); 21 | } 22 | } 23 | 24 | function getParam(param) { 25 | setEnvParams(); 26 | 27 | return envParams.definitions[`process.env.${param}`].slice(1).slice(0, -1); 28 | } 29 | 30 | function getParamAsNumber(param) { 31 | return Number(getParam(param)); 32 | } 33 | 34 | function getParamAsBoolean(param) { 35 | return getParam(param) === 'true'; 36 | } 37 | 38 | module.exports = { 39 | getParam, 40 | getParamAsNumber, 41 | getParamAsBoolean, 42 | isProduction: getParam('NODE_ENV') === 'production', 43 | }; 44 | -------------------------------------------------------------------------------- /eslint-custom/rules/ecmascript-6.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | arrow-body-style: [error, as-needed] 3 | arrow-parens: [warn, as-needed] 4 | arrow-spacing: error 5 | constructor-super: error 6 | generator-star-spacing: [error, after] 7 | no-class-assign: error 8 | no-confusing-arrow: 9 | - error 10 | - allowParens: true 11 | no-const-assign: error 12 | no-dupe-class-members: error 13 | no-duplicate-imports: error 14 | no-new-symbol: error 15 | no-restricted-imports: error 16 | no-this-before-super: error 17 | no-useless-computed-key: error 18 | no-useless-constructor: error 19 | no-useless-rename: error 20 | no-var: error 21 | object-shorthand: error 22 | prefer-arrow-callback: off 23 | prefer-const: error 24 | prefer-destructuring: warn 25 | prefer-numeric-literals: error 26 | prefer-rest-params: error 27 | prefer-spread: error 28 | prefer-template: error 29 | require-yield: error 30 | rest-spread-spacing: error 31 | sort-imports: off 32 | symbol-description: error 33 | template-curly-spacing: error 34 | yield-star-spacing: error 35 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | export const routes = { 2 | marketDetailed: { 3 | name: 'marketDetailed', 4 | path: '/market/:market/:pair', 5 | masks: { 6 | pair: /^[a-zA-Z]{3,5}-[a-zA-Z]{3}$/, 7 | market: /^[a-zA-Z]{3,4}$/, 8 | }, 9 | beforeEnter(route, store) { 10 | const { 11 | params: { pair, market }, 12 | } = route; 13 | const [symbol, tradedCurrency] = pair.split('-'); 14 | const prevMarket = store.marketsList.currentMarket; 15 | 16 | function optimisticallyUpdate() { 17 | store.marketsList.currentMarket = market; 18 | } 19 | 20 | return Promise.resolve() 21 | .then(optimisticallyUpdate) 22 | .then(store.marketsList.fetchSymbolsList) 23 | .then(store.rates.fetchRates) 24 | .then(() => store.marketsList.fetchMarketList(market, prevMarket)) 25 | .then(() => 26 | store.currentTP.fetchSymbol({ 27 | symbol, 28 | tradedCurrency, 29 | }) 30 | ) 31 | .catch(error => { 32 | console.error(error); 33 | }); 34 | }, 35 | }, 36 | error404: { 37 | name: 'error404', 38 | path: '/error404', 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/OrderBook/OrderBook.scss: -------------------------------------------------------------------------------- 1 | @import "mixins.scss"; 2 | 3 | .block { 4 | @include block($col_width * 2); 5 | @include table(); 6 | 7 | .bar { 8 | position: absolute; 9 | z-index: -1; 10 | height: 100%; 11 | background: red; 12 | top: 0; 13 | right: 0; 14 | } 15 | 16 | .tableBody { 17 | .row { 18 | &.sell { 19 | .cell:first-child { 20 | color: $R500; 21 | } 22 | 23 | .bar { 24 | background: linear-gradient($R500a2, $R500a1); 25 | } 26 | } 27 | 28 | &.buy { 29 | .cell:first-child { 30 | color: $G500; 31 | } 32 | 33 | .bar { 34 | background: linear-gradient($G500a2, $G500a1); 35 | } 36 | } 37 | } 38 | } 39 | 40 | .currentPriceRow { 41 | border-top: 1px solid $border_color; 42 | border-bottom: 1px solid $border_color; 43 | background: $light_bg_color; 44 | font-size: 14px; 45 | font-weight: bold; 46 | padding: $table_col_padding_top $table_col_padding_side; 47 | 48 | &.down { 49 | color: $R500; 50 | } 51 | 52 | &.up { 53 | color: $G500; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/cypress/integration/mixed.js: -------------------------------------------------------------------------------- 1 | describe('Market Listing good scenarios', () => { 2 | it('Lots of mixed tests', () => { 3 | cy.visit('/market/usd/bch-usd'); 4 | cy.location('pathname').should('equal', '/market/usd/bch-usd'); 5 | 6 | // Проверка ответа на запрос, хотя для этого уже есть валидаторы 7 | cy.wait('@symbolsList') 8 | .its('response.body') 9 | .should(data => { 10 | expect(data).to.be.an('array'); 11 | }); 12 | 13 | // Дожидаемся всех запросов 14 | cy.wait('@rates'); 15 | cy.wait('@marketsList'); 16 | cy.wait('@symbolInfo'); 17 | cy.wait('@chartData'); 18 | 19 | // Проверяем переход на другую торгуемую валюту 20 | cy.get('#marketTab-eth').click(); 21 | cy.location('pathname').should('equal', '/market/eth/bch-usd'); 22 | cy.wait('@rates'); 23 | cy.wait('@marketsList'); 24 | 25 | // Проверяем смену локализации 26 | cy.contains('Рынки'); 27 | cy.get('#langSwitcher-en').click(); 28 | cy.contains('Markets list'); 29 | 30 | // Проверяем смену темы 31 | cy.get('body').should('have.class', 'light'); 32 | cy.get('#themeSwitcher-dark').click(); 33 | cy.get('body').should('have.class', 'dark'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /eslint-custom/rules/variables.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | 3 | # require or disallow initialization in variable declarations 4 | init-declarations: [error, always] 5 | 6 | # disallow catch clause parameters from shadowing variables in the outer scope 7 | no-catch-shadow: error 8 | 9 | # disallow deleting variables 10 | no-delete-var: error 11 | 12 | # disallow labels that share a name with a variable 13 | no-label-var: error 14 | 15 | # disallow specified global variables 16 | no-restricted-globals: warn 17 | 18 | # disallow variable declarations from shadowing variables declared in the outer scope 19 | no-shadow: error 20 | 21 | # disallow identifiers from shadowing restricted names 22 | no-shadow-restricted-names: error 23 | 24 | # disallow the use of undeclared variables unless mentioned in /*global */ comments 25 | no-undef: error 26 | 27 | # disallow initializing variables to undefined 28 | no-undef-init: error 29 | 30 | # disallow the use of undefined as an identifier 31 | no-undefined: off 32 | 33 | # disallow unused variables 34 | no-unused-vars: [warn, { "args": "after-used", "vars": "local" }] 35 | 36 | # disallow the use of variables before they are defined 37 | no-use-before-define: off 38 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Exchange 11 | 12 | 15 | 16 | 17 |
18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/MarketList/Togglers.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import { useStore } from 'hooks'; 5 | import { observer } from 'utils'; 6 | 7 | import styles from './MarketList.scss'; 8 | 9 | function Togglers() { 10 | const { 11 | i18n: { languagesList, currentLanguage, setLocalization }, 12 | global: { currentTheme, themesList, setTheme }, 13 | } = useStore(); 14 | 15 | return ( 16 |
17 | {themesList.map(theme => ( 18 |
setTheme(theme)} 27 | /> 28 | ))} 29 | {languagesList.map(lang => ( 30 |
setLocalization(lang)} 39 | /> 40 | ))} 41 |
42 | ); 43 | } 44 | 45 | export const TogglersConnected = observer(Togglers); 46 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/OrderBook/OffersTable.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import { precise } from 'utils'; 5 | 6 | import styles from './OrderBook.scss'; 7 | 8 | export function OffersTable({ 9 | dataArray, 10 | dataTotal, 11 | rowClassName, 12 | reverseTotal, 13 | maxTotal, 14 | tradedCurrency, 15 | }) { 16 | let currentTotal = reverseTotal ? dataTotal : 0; 17 | 18 | return ( 19 |
20 | {dataArray.map(({ price, amount, total }, index) => { 21 | if (reverseTotal) { 22 | if (index > 0) { 23 | currentTotal -= total; 24 | } 25 | } else { 26 | currentTotal += total; 27 | } 28 | 29 | const barStyle = { 30 | width: `${(currentTotal / maxTotal) * 60}%`, 31 | }; 32 | 33 | return ( 34 |
35 |
{precise(price, tradedCurrency)}
36 |
{precise(amount, tradedCurrency)}
37 |
{currentTotal.toFixed(4)}
38 |
39 |
40 | ); 41 | })} 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /eslint-custom/eslint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'babel-eslint', 3 | env: { 4 | browser: true, 5 | node: true, 6 | es6: true, 7 | 'cypress/globals': true, 8 | }, 9 | settings: { 10 | react: { 11 | version: 'detect', 12 | }, 13 | }, 14 | extends: [ 15 | './rules/best-practices.yaml', 16 | './rules/ecmascript-6.yaml', 17 | './rules/node-js-and-common-js.yaml', 18 | './rules/possible-errors.yaml', 19 | './rules/strict-mode.yaml', 20 | './rules/stylistic-issues.yaml', 21 | './rules/variables.yaml', 22 | './rules/react.yaml', 23 | 'prettier', 24 | 'prettier/babel', 25 | 'prettier/react', 26 | ], 27 | plugins: ['react', 'prettier', 'import', 'react-hooks', 'cypress'], 28 | rules: { 29 | 'prettier/prettier': [ 30 | 'warn', 31 | { 32 | printWidth: 80, 33 | tabWidth: 2, 34 | singleQuote: true, 35 | semi: true, 36 | trailingComma: 'es5', 37 | bracketSpacing: true, 38 | jsxBracketSameLine: false, 39 | arrowParens: 'avoid', 40 | proseWrap: 'never', 41 | }, 42 | ], 43 | }, 44 | parserOptions: { 45 | ecmaFeatures: { 46 | legacyDecorators: true, 47 | }, 48 | }, 49 | globals: { 50 | SENTRY_URL: true, 51 | HOT_RELOAD: true, 52 | PUBLIC_URL: true, 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exchange_habr", 3 | "homepage": "https://dkazakov8.github.io/exchange_habr/dist", 4 | "version": "1.0.0", 5 | "description": "An example of building Frontend for Exchange platform", 6 | "author": "Dmitry Kazakov", 7 | "license": "MIT", 8 | "scripts": { 9 | "upd": "yarn install --no-lockfile", 10 | "dev": "webpack-dev-server --config webpack-custom/webpack.config.js", 11 | "build": "webpack --config webpack-custom/webpack.config.js", 12 | "format:js": "eslint --ignore-path .formatignore --ext .js -c ./eslint-custom/eslint.config.js --fix", 13 | "format:style": "stylelint --ignore-path .formatignore --config ./eslint-custom/stylelint.config.js --fix" 14 | }, 15 | "browserslist": [ 16 | "last 2 versions", 17 | "not dead" 18 | ], 19 | "husky": { 20 | "hooks": { 21 | "pre-commit": "npm run format:js -- --max-warnings=0 ./ && npm run format:style ./**/*.scss" 22 | } 23 | }, 24 | "dependencies": { 25 | "@hot-loader/react-dom": "16.8.6", 26 | "@sentry/browser": "5.1.1", 27 | "classnames": "2.2.6", 28 | "eslint-custom": "file:./eslint-custom", 29 | "highcharts": "7.1.1", 30 | "husky": "2.1.0", 31 | "lodash": "4.17.11", 32 | "mobx": "5.9.4", 33 | "mobx-react-lite": "1.3.0", 34 | "react": "16.8.6", 35 | "webpack-custom": "file:./webpack-custom" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /webpack-custom/config/configOptimization.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @docs: https://webpack.js.org/configuration/optimization 3 | * 4 | */ 5 | 6 | const TerserPlugin = require('terser-webpack-plugin'); 7 | 8 | module.exports = { 9 | runtimeChunk: { 10 | name: 'runtime', 11 | }, 12 | chunkIds: 'named', 13 | moduleIds: 'hashed', 14 | mergeDuplicateChunks: true, 15 | splitChunks: { 16 | cacheGroups: { 17 | lodash: { 18 | test: module => module.context.indexOf('node_modules\\lodash') !== -1, 19 | name: 'lodash', 20 | chunks: 'all', 21 | enforce: true, 22 | }, 23 | sentry: { 24 | test: module => module.context.indexOf('node_modules\\@sentry') !== -1, 25 | name: 'sentry', 26 | chunks: 'all', 27 | enforce: true, 28 | }, 29 | highcharts: { 30 | test: module => 31 | module.context.indexOf('node_modules\\highcharts') !== -1, 32 | name: 'highcharts', 33 | chunks: 'all', 34 | enforce: true, 35 | }, 36 | vendor: { 37 | test: module => module.context.indexOf('node_modules') !== -1, 38 | priority: -1, 39 | name: 'vendor', 40 | chunks: 'all', 41 | enforce: true, 42 | }, 43 | }, 44 | }, 45 | minimizer: [ 46 | new TerserPlugin({ 47 | terserOptions: { 48 | keep_fnames: true, 49 | }, 50 | }), 51 | ], 52 | }; 53 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/MarketList/MarketList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import { observer } from 'utils'; 5 | import { useLocalization } from 'hooks'; 6 | 7 | import { TogglersConnected } from './Togglers'; 8 | import { PairsListConnected } from './PairsList'; 9 | import { MarketTabsConnected } from './MarketTabs'; 10 | 11 | import styles from './MarketList.scss'; 12 | import { messages } from './messages'; 13 | 14 | function MarketsList() { 15 | const getLn = useLocalization(__filename, messages); 16 | 17 | const numericCellClassName = cn(styles.cell, styles.alignRight); 18 | 19 | return ( 20 |
21 |
22 |
23 | {getLn(messages.marketsList)} 24 | 25 |
26 | 27 |
28 |
29 |
{getLn(messages.pair)}
30 |
{getLn(messages.price)}
31 |
{getLn(messages.change)}
32 |
33 | 34 |
35 |
36 |
37 | ); 38 | } 39 | 40 | export const MarketsListConnected = observer(MarketsList); 41 | -------------------------------------------------------------------------------- /src/utils/observer.js: -------------------------------------------------------------------------------- 1 | import { useObserver } from 'mobx-react-lite'; 2 | import React from 'react'; 3 | import _ from 'lodash'; 4 | 5 | function copyStaticProperties(base, target) { 6 | const hoistBlackList = { 7 | $$typeof: true, 8 | render: true, 9 | compare: true, 10 | type: true, 11 | }; 12 | 13 | Object.keys(base).forEach(key => { 14 | if (base.hasOwnProperty(key) && !hoistBlackList[key]) { 15 | Object.defineProperty( 16 | target, 17 | key, 18 | Object.getOwnPropertyDescriptor(base, key) 19 | ); 20 | } 21 | }); 22 | } 23 | 24 | export function observer(baseComponent, options) { 25 | const baseComponentName = baseComponent.displayName || baseComponent.name; 26 | 27 | function wrappedComponent(props, ref) { 28 | return useObserver(function applyObserver() { 29 | return baseComponent(props, ref); 30 | }, baseComponentName); 31 | } 32 | wrappedComponent.displayName = baseComponentName; 33 | 34 | let memoComponent = null; 35 | if (HOT_RELOAD === 'true') { 36 | memoComponent = wrappedComponent; 37 | } else if (_.get(options, 'forwardRef')) { 38 | memoComponent = React.memo(React.forwardRef(wrappedComponent)); 39 | } else { 40 | memoComponent = React.memo(wrappedComponent); 41 | } 42 | 43 | copyStaticProperties(baseComponent, memoComponent); 44 | memoComponent.displayName = baseComponentName; 45 | 46 | return memoComponent; 47 | } 48 | -------------------------------------------------------------------------------- /src/components/Link/Link.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _ from 'lodash'; 3 | 4 | import { useStore } from 'hooks'; 5 | import { observer } from 'utils'; 6 | 7 | function checkRouteParamsWithMasks(route, params) { 8 | if (route.masks) { 9 | Object.entries(route.masks).forEach(([paramName, paramMask]) => { 10 | const value = _.get(params, paramName); 11 | 12 | if (paramMask && !paramMask.test(value)) { 13 | console.error( 14 | `checkRouteParamsWithMasks: wrong param for ${paramName} in Link to ${ 15 | route.name 16 | }: ${value}` 17 | ); 18 | } 19 | }); 20 | } 21 | } 22 | 23 | function Link(props) { 24 | const store = useStore(); 25 | const { currentRoute } = store.router; 26 | const { route, params, children, onClick, ...otherProps } = props; 27 | 28 | checkRouteParamsWithMasks(route, params); 29 | 30 | const filledPath = store.router.replaceDynamicParams(route, params); 31 | 32 | return ( 33 | { 36 | e.preventDefault(); 37 | 38 | if (currentRoute.isLoading) { 39 | return false; 40 | } 41 | 42 | store.router.goTo(route, params); 43 | 44 | if (onClick) { 45 | onClick(); 46 | } 47 | }} 48 | {...otherProps} 49 | > 50 | {children} 51 | 52 | ); 53 | } 54 | 55 | export const LinkConnected = observer(Link); 56 | -------------------------------------------------------------------------------- /webpack-custom/utils/sassVariablesLoader.js: -------------------------------------------------------------------------------- 1 | function convertSourceToJsObject(source) { 2 | const themesObject = {}; 3 | const fullThemesArray = source.match(/\.([^}]|\s)*}/g) || []; 4 | 5 | fullThemesArray.forEach(fullThemeStr => { 6 | const theme = fullThemeStr.match(/\.\w+\s{/g)[0].replace(/\W/g, ''); 7 | themesObject[theme] = {}; 8 | 9 | const variablesMatches = fullThemeStr.match(/--(.*:[^;]*)/g) || []; 10 | 11 | variablesMatches.forEach(varMatch => { 12 | const [key, value] = varMatch.split(': '); 13 | themesObject[theme][key] = value; 14 | }); 15 | }); 16 | 17 | return themesObject; 18 | } 19 | 20 | function checkThemesEquality(themes) { 21 | const themesArray = Object.keys(themes); 22 | 23 | themesArray.forEach(themeStr => { 24 | const themeObject = themes[themeStr]; 25 | const otherThemesArray = themesArray.filter(t => t !== themeStr); 26 | 27 | Object.keys(themeObject).forEach(variableName => { 28 | otherThemesArray.forEach(otherThemeStr => { 29 | const otherThemeObject = themes[otherThemeStr]; 30 | 31 | if (!otherThemeObject[variableName]) { 32 | throw new Error( 33 | `checkThemesEquality: theme ${otherThemeStr} has no variable ${variableName}` 34 | ); 35 | } 36 | }); 37 | }); 38 | }); 39 | } 40 | 41 | module.exports = function sassVariablesLoader(source) { 42 | const themes = convertSourceToJsObject(source); 43 | 44 | checkThemesEquality(themes); 45 | 46 | return `module.exports = ${JSON.stringify(themes)}`; 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/Router/Router.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _ from 'lodash'; 3 | 4 | import { useStore } from 'hooks'; 5 | import { observer } from 'utils'; 6 | import { routeComponents } from 'routeComponents'; 7 | 8 | function getRouteComponent(route, isLoading) { 9 | const Component = routeComponents[route.name]; 10 | 11 | if (!Component) { 12 | console.error( 13 | `getRouteComponent: component for ${ 14 | route.name 15 | } is not defined in routeComponents` 16 | ); 17 | 18 | return null; 19 | } 20 | 21 | return ; 22 | } 23 | 24 | function useBeforeEnter() { 25 | const store = useStore(); 26 | const { currentRoute } = store.router; 27 | 28 | React.useEffect(() => { 29 | if (currentRoute.isLoading) { 30 | const beforeEnter = _.get(currentRoute, 'beforeEnter'); 31 | 32 | if (_.isFunction(beforeEnter)) { 33 | Promise.resolve() 34 | .then(() => beforeEnter(currentRoute, store)) 35 | .then(() => { 36 | currentRoute.isLoading = false; 37 | }) 38 | .catch(error => console.error(error)); 39 | } else { 40 | currentRoute.isLoading = false; 41 | } 42 | } 43 | }); 44 | 45 | return currentRoute.isLoading; 46 | } 47 | 48 | function Router() { 49 | const { 50 | router: { currentRoute }, 51 | } = useStore(); 52 | const isLoading = useBeforeEnter(); 53 | 54 | return getRouteComponent(currentRoute, isLoading); 55 | } 56 | 57 | export const RouterConnected = observer(Router); 58 | -------------------------------------------------------------------------------- /src/utils/makeObservable.js: -------------------------------------------------------------------------------- 1 | import { action, computed, decorate, observable } from 'mobx'; 2 | 3 | export function makeObservable(target) { 4 | /** 5 | * Для методов - биндим контекст this + все изменения сторов 6 | * выполняем в одной транзакции 7 | * 8 | * Для геттеров - оборачиваем в computed 9 | * 10 | */ 11 | 12 | const classPrototype = target.prototype; 13 | const methodsAndGetters = Object.getOwnPropertyNames(classPrototype).filter( 14 | methodName => methodName !== 'constructor' 15 | ); 16 | 17 | for (const methodName of methodsAndGetters) { 18 | const descriptor = Object.getOwnPropertyDescriptor( 19 | classPrototype, 20 | methodName 21 | ); 22 | 23 | descriptor.value = decorate(classPrototype, { 24 | [methodName]: 25 | typeof descriptor.value === 'function' ? action.bound : computed, 26 | }); 27 | } 28 | 29 | return (...constructorArguments) => { 30 | /** 31 | * Параметры, за исключением rootStore, трансформируем в 32 | * observable 33 | * 34 | */ 35 | 36 | const store = new target(...constructorArguments); 37 | const staticProperties = Object.keys(store); 38 | 39 | staticProperties.forEach(propName => { 40 | if (propName === 'rootStore') { 41 | return false; 42 | } 43 | 44 | const descriptor = Object.getOwnPropertyDescriptor(store, propName); 45 | 46 | Object.defineProperty( 47 | store, 48 | propName, 49 | observable(store, propName, descriptor) 50 | ); 51 | }); 52 | 53 | return store; 54 | }; 55 | } 56 | -------------------------------------------------------------------------------- /webpack-custom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-custom", 3 | "version": "1.0.0", 4 | "description": "Custom webpack rules for this project", 5 | "author": "Dmitry Kazakov", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@babel/core": "7.4.4", 9 | "@babel/plugin-proposal-class-properties": "7.4.4", 10 | "@babel/plugin-proposal-decorators": "7.4.4", 11 | "@babel/preset-env": "7.4.4", 12 | "@babel/preset-react": "7.0.0", 13 | "@cypress/webpack-preprocessor": "4.0.3", 14 | "autoprefixer": "9.5.1", 15 | "babel-loader": "8.0.5", 16 | "babel-plugin-lodash": "3.3.4", 17 | "circular-dependency-plugin": "5.0.2", 18 | "clean-webpack-plugin": "2.0.1", 19 | "css-loader": "2.1.1", 20 | "dotenv-webpack": "1.7.0", 21 | "html-webpack-plugin": "3.2.0", 22 | "mini-css-extract-plugin": "0.6.0", 23 | "postcss-advanced-variables": "3.0.0", 24 | "postcss-automath": "1.0.1", 25 | "postcss-custom-properties": "8.0.10", 26 | "postcss-import": "12.0.1", 27 | "postcss-loader": "3.0.0", 28 | "postcss-nested": "4.1.2", 29 | "postcss-scss": "2.0.0", 30 | "postcss-strip-inline-comments": "0.1.5", 31 | "react-hot-loader": "4.8.4", 32 | "speed-measure-webpack-plugin": "1.3.1", 33 | "style-loader": "0.23.1", 34 | "svg-inline-loader": "0.8.0", 35 | "terser-webpack-plugin": "1.2.3", 36 | "thread-loader": "2.1.2", 37 | "url-loader": "1.1.2", 38 | "webpack": "4.30.0", 39 | "webpack-bundle-analyzer": "3.3.2", 40 | "webpack-cli": "3.3.1", 41 | "webpack-dev-server": "3.3.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/styles/themes.scss: -------------------------------------------------------------------------------- 1 | .light { 2 | --n0: rgb(255, 255, 255); 3 | --n100: rgb(186, 186, 186); 4 | --n10: rgb(249, 249, 249); 5 | --n10a3: rgba(249, 249, 249, 0.3); 6 | --n20: rgb(245, 245, 245); 7 | --n30: rgb(221, 221, 221); 8 | --n500: rgb(136, 136, 136); 9 | --n600: rgb(102, 102, 102); 10 | --n900: rgb(0, 0, 0); 11 | 12 | --b100: rgb(219, 237, 251); 13 | --b300: rgb(179, 214, 252); 14 | --b500: rgb(14, 123, 249); 15 | --b500a3: rgba(14, 123, 249, 0.3); 16 | --b900: rgb(32, 39, 57); 17 | 18 | --g400: rgb(71, 215, 141); 19 | --g500: rgb(61, 189, 125); 20 | --g500a1: rgba(61, 189, 125, 0.1); 21 | --g500a2: rgba(61, 189, 125, 0.2); 22 | 23 | --r400: rgb(255, 100, 100); 24 | --r500: rgb(255, 0, 0); 25 | --r500a1: rgba(255, 0, 0, 0.1); 26 | --r500a2: rgba(255, 0, 0, 0.2); 27 | } 28 | 29 | .dark { 30 | --n0: rgb(25, 32, 48); 31 | --n100: rgb(114, 126, 151); 32 | --n10: rgb(39, 46, 62); 33 | --n10a3: rgba(39, 46, 62, 0.3); 34 | --n20: rgb(25, 44, 74); 35 | --n30: rgb(67, 75, 111); 36 | --n500: rgb(117, 128, 154); 37 | --n600: rgb(255, 255, 255); 38 | --n900: rgb(255, 255, 255); 39 | 40 | --b100: rgb(219, 237, 251); 41 | --b300: rgb(39, 46, 62); 42 | --b500: rgb(14, 123, 249); 43 | --b500a3: rgba(14, 123, 249, 0.3); 44 | --b900: rgb(32, 39, 57); 45 | 46 | --g400: rgb(0, 220, 103); 47 | --g500: rgb(0, 197, 96); 48 | --g500a1: rgba(0, 197, 96, 0.1); 49 | --g500a2: rgba(0, 197, 96, 0.2); 50 | 51 | --r400: rgb(248, 23, 1); 52 | --r500: rgb(221, 23, 1); 53 | --r500a1: rgba(221, 23, 1, 0.1); 54 | --r500a2: rgba(221, 23, 1, 0.2); 55 | } 56 | -------------------------------------------------------------------------------- /webpack-custom/webpack.config.js: -------------------------------------------------------------------------------- 1 | const pluginHot = require('./plugins/pluginHot'); 2 | const pluginHtml = require('./plugins/pluginHtml'); 3 | const pluginClean = require('./plugins/pluginClean'); 4 | const pluginDefine = require('./plugins/pluginDefine'); 5 | const pluginExtract = require('./plugins/pluginExtract'); 6 | const pluginAnalyzer = require('./plugins/pluginAnalyzer'); 7 | const pluginSpeedMeasure = require('./plugins/pluginSpeedMeasure'); 8 | const pluginCircularDependency = require('./plugins/pluginCircularDependency'); 9 | 10 | const { 11 | getParamAsBoolean, 12 | getParam, 13 | isProduction, 14 | } = require('./utils/envParams'); 15 | 16 | module.exports = pluginSpeedMeasure.wrap({ 17 | mode: getParam('NODE_ENV'), 18 | node: require('./config/configNode'), 19 | stats: require('./config/configStats'), 20 | entry: require('./config/configEntry'), 21 | output: require('./config/configOutput'), 22 | module: require('./config/configModule'), 23 | resolve: require('./config/configResolve'), 24 | devtool: require('./config/configDevTool'), 25 | devServer: require('./config/configDevServer'), 26 | performance: require('./config/configPerformance'), 27 | optimization: require('./config/configOptimization'), 28 | plugins: [ 29 | isProduction && pluginClean, 30 | pluginHtml, 31 | pluginDefine, 32 | getParamAsBoolean('HOT_RELOAD') && pluginHot, 33 | getParamAsBoolean('CSS_EXTRACT') && pluginExtract, 34 | getParamAsBoolean('BUNDLE_ANALYZER') && pluginAnalyzer, 35 | getParamAsBoolean('CIRCULAR_CHECK') && pluginCircularDependency, 36 | ].filter(Boolean), 37 | }); 38 | -------------------------------------------------------------------------------- /src/api/utils/validateResponse.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import { validateObjects } from 'utils'; 4 | 5 | export function validateResponse(route, requestParams) { 6 | return function promiseCallback(responseData) { 7 | // валидация ответов в виде массива 8 | if (_.isPlainObject(route.responseArray)) { 9 | if (!_.isArray(responseData)) { 10 | throw new Error('request: response expected to be an array'); 11 | } 12 | 13 | responseData.forEach((responseItem, index) => { 14 | try { 15 | validateObjects( 16 | { 17 | validatorsObject: route.responseArray, 18 | targetObject: responseItem, 19 | }, 20 | requestParams 21 | ); 22 | } catch (error) { 23 | throw new Error( 24 | `request: responseItem[${index}] param ${ 25 | error.message 26 | } has wrong value. Requested url: ${route.url}` 27 | ); 28 | } 29 | }); 30 | } 31 | 32 | // валидация ответов в виде объекта 33 | else if (_.isPlainObject(route.responseObject)) { 34 | try { 35 | validateObjects( 36 | { 37 | validatorsObject: route.responseObject, 38 | targetObject: responseData, 39 | }, 40 | requestParams 41 | ); 42 | } catch (error) { 43 | throw new Error( 44 | `request: response param ${ 45 | error.message 46 | } has wrong value. Requested url: ${route.url}` 47 | ); 48 | } 49 | } 50 | 51 | return responseData; 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/MarketList/MarketList.scss: -------------------------------------------------------------------------------- 1 | @import "mixins.scss"; 2 | 3 | .block { 4 | @include block($col_width * 2); 5 | @include table; 6 | 7 | float: left; 8 | padding: $col_spacing; 9 | 10 | .row { 11 | user-select: none; 12 | 13 | &:hover { 14 | background: $N20; 15 | } 16 | 17 | &.active { 18 | background: $N20 !important; 19 | cursor: default; 20 | font-weight: bold; 21 | } 22 | 23 | .cell:first-child { 24 | text-transform: uppercase; 25 | } 26 | } 27 | 28 | .themeBlock { 29 | float: right; 30 | 31 | .themeToggler { 32 | @include inline-top; 33 | 34 | width: 16px; 35 | height: 16px; 36 | border: 1px solid #000; 37 | cursor: pointer; 38 | margin-right: 10px; 39 | margin-top: 2px; 40 | transition: border-color $trans; 41 | 42 | &.light { 43 | background: #fff; 44 | } 45 | 46 | &.dark { 47 | background: #000; 48 | margin-right: 20px; 49 | } 50 | 51 | &:hover, 52 | &.active { 53 | border-color: $B500; 54 | } 55 | } 56 | 57 | .lnToggler { 58 | @include inline-top; 59 | 60 | width: 32px; 61 | height: 16px; 62 | cursor: pointer; 63 | margin-right: 10px; 64 | margin-top: 2px; 65 | opacity: 0.5; 66 | transition: opacity $trans; 67 | border: 1px solid #000; 68 | background-size: 100% 100%; 69 | 70 | &.ru { 71 | background-image: url("assets/russia-flag-icon-32.png"); 72 | } 73 | 74 | &.en { 75 | background-image: url("assets/united-kingdom-flag-icon-32.png"); 76 | margin-right: 0; 77 | } 78 | 79 | &:hover, 80 | &.active { 81 | opacity: 1; 82 | } 83 | } 84 | } 85 | } 86 | 87 | .alignRight { 88 | text-align: right; 89 | } 90 | -------------------------------------------------------------------------------- /dist/runtime.d370529eaf8353423ae4.js: -------------------------------------------------------------------------------- 1 | !function(e){function webpackJsonpCallback(r){for(var n,u,o=r[0],c=r[1],p=r[2],i=0,l=[];i { 17 | messageWithValues = formattedMessage.replace(`{${paramName}}`, value); 18 | }); 19 | 20 | return messageWithValues; 21 | } 22 | 23 | function replacePlurals(values, formattedMessage) { 24 | if (!_.isPlainObject(values)) { 25 | return formattedMessage; 26 | } 27 | 28 | let messageWithPlurals = formattedMessage; 29 | 30 | Object.entries(values).forEach(([paramName, value]) => { 31 | const pluralPattern = new RegExp(`{${paramName}:\\s([^}]*)}`); 32 | const pluralMatch = formattedMessage.match(pluralPattern); 33 | 34 | if (pluralMatch && pluralMatch[1]) { 35 | messageWithPlurals = formattedMessage.replace( 36 | pluralPattern, 37 | declOfNum(value, pluralMatch[1].split(',')) 38 | ); 39 | } 40 | }); 41 | 42 | return messageWithPlurals; 43 | } 44 | 45 | export function useLocalization(filename, messages) { 46 | const { 47 | i18n: { i18n, currentLanguage }, 48 | } = useStore(); 49 | 50 | return function getLn(text, values) { 51 | const key = _.findKey(messages, message => message === text); 52 | const localizedText = _.get(i18n, [filename, key]); 53 | 54 | if (!localizedText && showNoTextMessage) { 55 | console.error( 56 | `useLocalization: no localization for lang '${currentLanguage}' in ${filename} ${key}` 57 | ); 58 | } 59 | 60 | let formattedMessage = localizedText || text; 61 | formattedMessage = replaceDynamicParams(values, formattedMessage); 62 | formattedMessage = replacePlurals(values, formattedMessage); 63 | 64 | return formattedMessage; 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/MarketList/PairsList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import { useStore } from 'hooks'; 5 | import { detectNilEquality, formatPercentage, precise, observer } from 'utils'; 6 | import { LinkConnected } from 'components/Link'; 7 | 8 | import styles from './MarketList.scss'; 9 | import { routes } from 'routes'; 10 | 11 | function PairsList() { 12 | const { 13 | marketsList: { list }, 14 | currentTP, 15 | } = useStore(); 16 | const numericCellClassName = cn(styles.cell, styles.alignRight); 17 | 18 | return ( 19 |
20 | {list.map((TPInfo, index) => { 21 | const { 22 | currency, 23 | tradedCurrency, 24 | lastPrice, 25 | change24hPercentage, 26 | } = TPInfo; 27 | const highlightedCellClassName = detectNilEquality({ 28 | num: change24hPercentage, 29 | onMore: styles.up, 30 | onLess: styles.down, 31 | }); 32 | 33 | const isActive = 34 | currency === currentTP.currency && 35 | tradedCurrency === currentTP.tradedCurrency; 36 | 37 | return ( 38 | 47 |
48 | {currency} / {tradedCurrency} 49 |
50 |
51 | {precise(lastPrice, tradedCurrency)} 52 |
53 |
54 | {formatPercentage(change24hPercentage)} 55 |
56 |
57 | ); 58 | })} 59 |
60 | ); 61 | } 62 | 63 | export const PairsListConnected = observer(PairsList); 64 | -------------------------------------------------------------------------------- /src/stores/MarketsListStore.js: -------------------------------------------------------------------------------- 1 | import { makeObservable } from 'utils'; 2 | import { request, apiRoutes } from 'api'; 3 | 4 | @makeObservable 5 | export class MarketsListStore { 6 | /** 7 | * @param rootStore {RootStore} 8 | */ 9 | constructor(rootStore) { 10 | this.rootStore = rootStore; 11 | } 12 | 13 | list = []; 14 | symbolsList = []; 15 | currentMarket = null; 16 | availableMarkets = ['btc', 'eth', 'usd']; 17 | 18 | fetchSymbolsList() { 19 | if (this.symbolsList.length > 0) { 20 | return Promise.resolve(); 21 | } 22 | 23 | return request(apiRoutes.symbolsList) 24 | .then(this.fetchSymbolsSuccess) 25 | .catch(this.fetchSymbolsError); 26 | } 27 | fetchSymbolsSuccess(data) { 28 | this.symbolsList = data; 29 | } 30 | fetchSymbolsError(error) { 31 | console.error(error); 32 | } 33 | 34 | fetchMarketList(market, prevMarket) { 35 | const requestParams = { 36 | vs_currency: market.toLocaleLowerCase(), 37 | order: 'market_cap_desc', 38 | per_page: 13, 39 | page: 1, 40 | sparkline: false, 41 | }; 42 | 43 | return request(apiRoutes.marketsList, requestParams) 44 | .then(data => this.fetchMarketListSuccess(data, requestParams)) 45 | .catch(error => this.fetchMarketListError(error, prevMarket)); 46 | } 47 | fetchMarketListSuccess(data, requestParams) { 48 | let hasDuplicatedValues = false; 49 | 50 | const listData = data.reduce((arr, symbolData) => { 51 | const { symbol, current_price, price_change_percentage_24h } = symbolData; 52 | 53 | if (symbol === requestParams.vs_currency) { 54 | hasDuplicatedValues = true; 55 | 56 | return arr; 57 | } 58 | 59 | arr.push({ 60 | currency: symbol, 61 | tradedCurrency: requestParams.vs_currency, 62 | change24hPercentage: price_change_percentage_24h, 63 | lastPrice: current_price, 64 | }); 65 | 66 | return arr; 67 | }, []); 68 | 69 | this.list = hasDuplicatedValues ? listData : listData.slice(1); 70 | } 71 | fetchMarketListError(error, prevMarket) { 72 | this.currentMarket = prevMarket; 73 | 74 | console.error(error); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/styles/reset.scss: -------------------------------------------------------------------------------- 1 | @-webkit-keyframes bugfix { 2 | from { 3 | padding: 0; 4 | } 5 | 6 | to { 7 | padding: 0; 8 | } 9 | } 10 | 11 | *, 12 | *:before, 13 | *:after { 14 | -webkit-box-sizing: border-box; 15 | -moz-box-sizing: border-box; 16 | box-sizing: border-box; 17 | } 18 | 19 | html, 20 | body { 21 | height: 100%; 22 | -webkit-text-size-adjust: 100%; 23 | -ms-text-size-adjust: 100%; 24 | -webkit-animation: bugfix infinite 1s; 25 | font-family: "PT Sans", sans-serif; 26 | } 27 | 28 | html, 29 | body, 30 | div, 31 | span, 32 | cite, 33 | code, 34 | figure, 35 | h1, 36 | h2, 37 | h3, 38 | h4, 39 | h5, 40 | h6, 41 | p, 42 | blockquote, 43 | pre, 44 | small, 45 | sub, 46 | sup, 47 | b, 48 | i, 49 | a, 50 | ol, 51 | ul, 52 | li, 53 | footer, 54 | fieldset, 55 | form, 56 | label, 57 | legend, 58 | table, 59 | caption, 60 | tr, 61 | th, 62 | td, 63 | audio, 64 | video { 65 | margin: 0; 66 | padding: 0; 67 | border: 0; 68 | font-size: 100%; 69 | font: inherit; 70 | vertical-align: baseline; 71 | background: transparent; 72 | } 73 | 74 | b { 75 | font-weight: bold; 76 | } 77 | 78 | i { 79 | font-style: italic; 80 | } 81 | 82 | input, 83 | textarea, 84 | select { 85 | font: inherit; 86 | font-size: 100%; 87 | margin: 0; 88 | line-height: normal; 89 | } 90 | 91 | input, 92 | select { 93 | vertical-align: top; 94 | -webkit-appearance: none; 95 | 96 | &::-moz-focus-inner { 97 | padding: 0; 98 | border: 0; 99 | } 100 | } 101 | 102 | input[type="search"] { 103 | -webkit-appearance: textfield; 104 | 105 | &::-webkit-search-cancel-button, 106 | &::-webkit-search-decoration { 107 | -webkit-appearance: none; 108 | } 109 | } 110 | 111 | audio, 112 | video { 113 | display: inline-block; 114 | } 115 | 116 | sup { 117 | vertical-align: text-top; 118 | } 119 | 120 | sub { 121 | vertical-align: text-bottom; 122 | } 123 | 124 | img { 125 | border: 0; 126 | vertical-align: top; 127 | } 128 | 129 | table { 130 | border-collapse: collapse; 131 | border-spacing: 0; 132 | } 133 | 134 | textarea { 135 | overflow: auto; 136 | vertical-align: top; 137 | } 138 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/Chart.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Highcharts from 'highcharts/highstock'; 3 | import cn from 'classnames'; 4 | 5 | import { observer } from 'utils'; 6 | import { useLocalization, useStore } from 'hooks'; 7 | 8 | import { chartConfig } from './config/chartConfig'; 9 | import fill from './assets/fill.svg'; 10 | import styles from './Chart.scss'; 11 | import { messages } from './messages'; 12 | 13 | function Chart() { 14 | const { 15 | currentTP: { chartPrices, chartVolumes }, 16 | i18n: { currentLanguage }, 17 | } = useStore(); 18 | const chartEl = React.useRef(null); 19 | const chartInstance = React.useRef(null); 20 | 21 | const getLn = useLocalization(__filename, messages); 22 | 23 | React.useEffect(() => { 24 | const localizedButtons = chartConfig.rangeSelector.buttons.map( 25 | buttonConfig => ({ 26 | ...buttonConfig, 27 | text: getLn(buttonConfig.text, { 28 | count: buttonConfig.count, 29 | }), 30 | }) 31 | ); 32 | const rangeSelector = { 33 | ...chartConfig.rangeSelector, 34 | buttons: localizedButtons, 35 | }; 36 | 37 | if (!chartInstance.current) { 38 | chartInstance.current = Highcharts.stockChart(chartEl.current, { 39 | ...chartConfig, 40 | rangeSelector, 41 | }); 42 | } else { 43 | chartInstance.current.series[0].setData(chartPrices); 44 | chartInstance.current.series[1].setData(chartVolumes); 45 | 46 | chartInstance.current.update({ 47 | rangeSelector, 48 | }); 49 | } 50 | }, [chartPrices, currentLanguage]); 51 | 52 | return ( 53 |
54 |
55 |
56 | {getLn(messages.chart)}{' '} 57 | 63 | (by highcharts) 64 | 65 |
66 |
67 |
71 |
72 |
73 | ); 74 | } 75 | 76 | export const ChartConnected = observer(Chart); 77 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/OrderBook/OrderBook.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import { detectNilEquality, precise, observer } from 'utils'; 5 | import { useLocalization, useStore } from 'hooks'; 6 | import { OffersTable } from './OffersTable'; 7 | 8 | import styles from './OrderBook.scss'; 9 | import { messages } from './messages'; 10 | 11 | function OrderBook() { 12 | const { 13 | currentTP: { 14 | currency, 15 | tradedCurrency, 16 | lastPrice, 17 | offersSell, 18 | offersBuy, 19 | offersSellTotal, 20 | offersBuyTotal, 21 | }, 22 | } = useStore(); 23 | const getLn = useLocalization(__filename, messages); 24 | 25 | const currentPriceClassname = detectNilEquality({ 26 | num: lastPrice, 27 | onLess: styles.down, 28 | onMore: styles.up, 29 | }); 30 | 31 | const maxTotal = Math.max(offersSellTotal, offersBuyTotal); 32 | 33 | return ( 34 |
35 |
36 |
{getLn(messages.orderBook)}
37 |
38 |
39 |
40 | {getLn(messages.price)} ({tradedCurrency}) 41 |
42 |
43 | {getLn(messages.amount)} ({currency}) 44 |
45 |
46 | {getLn(messages.total)} ({tradedCurrency}) 47 |
48 |
49 | 57 |
58 | {precise(lastPrice, tradedCurrency)} 59 |
60 | 67 |
68 |
69 |
70 | ); 71 | } 72 | 73 | export const OrderBookConnected = observer(OrderBook); 74 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/TPInfo/TPInfo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import { detectNilEquality, formatPercentage, precise, observer } from 'utils'; 5 | import { useLocalization, useStore } from 'hooks'; 6 | 7 | import styles from './TPInfo.scss'; 8 | import { messages } from './messages'; 9 | 10 | function TPInfo() { 11 | const { 12 | currentTP: { 13 | usdValue, 14 | name, 15 | lastPrice, 16 | change24h, 17 | change24hPercentage, 18 | high24h, 19 | low24h, 20 | tradedCurrency, 21 | }, 22 | } = useStore(); 23 | const getLn = useLocalization(__filename, messages); 24 | 25 | const columnsArray = [ 26 | { 27 | label: getLn(messages.lastPrice), 28 | data: precise(lastPrice, tradedCurrency), 29 | appendElement: 30 | tradedCurrency === 'usd' ? null : ( 31 |
{usdValue}
32 | ), 33 | }, 34 | { 35 | label: getLn(messages.change24h), 36 | data: precise(change24h, tradedCurrency), 37 | classname: detectNilEquality({ 38 | num: change24hPercentage, 39 | onMore: styles.up, 40 | onLess: styles.down, 41 | }), 42 | appendElement: ( 43 |
44 | {formatPercentage(change24hPercentage)} 45 |
46 | ), 47 | }, 48 | { 49 | label: getLn(messages.high24h), 50 | data: precise(high24h, tradedCurrency), 51 | }, 52 | { 53 | label: getLn(messages.low24h), 54 | data: precise(low24h, tradedCurrency), 55 | }, 56 | ]; 57 | 58 | return ( 59 |
60 |
61 |
{name}
62 |
63 | {columnsArray.map(({ label, data, classname, appendElement }, i) => ( 64 |
65 |
{label}
66 |
67 | {data} 68 | {appendElement} 69 |
70 |
71 | ))} 72 |
73 |
74 |
75 |
76 | ); 77 | } 78 | 79 | export const TPInfoConnected = observer(TPInfo); 80 | -------------------------------------------------------------------------------- /eslint-custom/rules/stylistic-issues.yaml: -------------------------------------------------------------------------------- 1 | rules: 2 | array-bracket-spacing: [error, never] 3 | block-spacing: [error, always] 4 | brace-style: [error, 1tbs] 5 | camelcase: off 6 | capitalized-comments: off 7 | comma-dangle: [error, always-multiline] 8 | comma-spacing: error 9 | comma-style: [error, last] 10 | computed-property-spacing: [error, never] 11 | consistent-this: [error, that] 12 | eol-last: off 13 | func-call-spacing: [error, never] 14 | func-name-matching: off 15 | func-names: [error, always] 16 | func-style: [error, declaration] 17 | id-match: off 18 | indent: 19 | - error 20 | - 2 21 | - SwitchCase: 1 22 | jsx-quotes: [error, prefer-double] 23 | key-spacing: error 24 | keyword-spacing: 25 | - error 26 | - 27 | before: true 28 | after: true 29 | line-comment-position: off 30 | linebreak-style: off 31 | lines-around-comment: off 32 | lines-around-directive: error 33 | max-depth: 34 | - error 35 | - max: 4 36 | max-len: 37 | - warn 38 | - 39 | code: 100 40 | ignoreComments: true 41 | ignorePattern: "^import\\W.*" 42 | max-lines: off 43 | max-nested-callbacks: 44 | - error 45 | - max: 5 46 | max-params: 47 | - error 48 | - max: 8 49 | max-statements: 50 | - error 51 | - max: 50 52 | max-statements-per-line: 53 | - error 54 | - max: 1 55 | multiline-ternary: ["error", "always"] 56 | new-cap: off 57 | new-parens: error 58 | newline-per-chained-call: 59 | - error 60 | - ignoreChainWithDepth: 3 61 | no-array-constructor: error 62 | no-bitwise: error 63 | no-continue: error 64 | no-lonely-if: error 65 | no-mixed-operators: warn 66 | no-mixed-spaces-and-tabs: error 67 | no-multi-assign: error 68 | no-multiple-empty-lines: 69 | - error 70 | - 71 | max: 1 72 | maxBOF: 0 73 | maxEOF: 0 74 | no-negated-condition: off 75 | no-nested-ternary: off 76 | no-new-object: error 77 | no-plusplus: off 78 | no-restricted-syntax: 79 | - error 80 | - WithStatement 81 | no-tabs: error 82 | no-ternary: off 83 | no-trailing-spaces: error 84 | no-underscore-dangle: off 85 | no-unneeded-ternary: error 86 | no-whitespace-before-property: error 87 | nonblock-statement-body-position: off 88 | object-curly-newline: 89 | - error 90 | - multiline: true 91 | object-curly-spacing: [error, always] 92 | object-property-newline: off 93 | one-var: [error, never] 94 | one-var-declaration-per-line: error 95 | operator-assignment: [error, always] 96 | operator-linebreak: 97 | - error 98 | - after 99 | - overrides: 100 | "?": before 101 | ":": before 102 | padded-blocks: [error, never] 103 | quote-props: [error, as-needed] 104 | quotes: [error, single] 105 | require-jsdoc: off 106 | semi: off 107 | semi-spacing: off 108 | sort-keys: off 109 | sort-vars: off 110 | space-before-blocks: error 111 | space-before-function-paren: [error, never] 112 | space-in-parens: [error, never] 113 | space-infix-ops: error 114 | space-unary-ops: error 115 | spaced-comment: [error, always] 116 | template-tag-spacing: [error, never] 117 | unicode-bom: off 118 | wrap-regex: error 119 | -------------------------------------------------------------------------------- /src/styles/layout.scss: -------------------------------------------------------------------------------- 1 | @import "constants.scss"; 2 | @import "mixins.scss"; 3 | 4 | @mixin order-book-cell-widths { 5 | float: left; 6 | 7 | &:nth-child(1) { 8 | width: 40%; 9 | } 10 | 11 | &:nth-child(2) { 12 | width: 30%; 13 | } 14 | 15 | &:nth-child(3) { 16 | width: 30%; 17 | } 18 | } 19 | 20 | @mixin block($width) { 21 | float: left; 22 | width: $width; 23 | padding: $col_spacing; 24 | 25 | .blockInner { 26 | border: 1px solid $border_color; 27 | border-radius: $border_radius; 28 | overflow: hidden; 29 | position: relative; 30 | } 31 | 32 | .header { 33 | font-weight: bold; 34 | border-bottom: 1px solid $border_color; 35 | padding: $table_col_padding_top $table_col_padding_side; 36 | font-size: 13px; 37 | color: $N900; 38 | } 39 | 40 | .body { 41 | @include clearfix; 42 | } 43 | } 44 | 45 | @mixin table() { 46 | .tableHeader { 47 | @include clearfix; 48 | 49 | border-bottom: 1px solid $border_color; 50 | 51 | .cell { 52 | @include order-book-cell-widths; 53 | 54 | background: $light_bg_color; 55 | color: $N600; 56 | font-size: 11px; 57 | padding: $table_col_padding_top $table_col_padding_side; 58 | } 59 | } 60 | 61 | .tableBody { 62 | .row { 63 | @include clearfix; 64 | 65 | display: block; 66 | border-bottom: 1px solid $N20; 67 | position: relative; 68 | height: $line_height + $table_col_padding_top * 2; 69 | color: $N600; 70 | 71 | &:last-child { 72 | border-bottom: 0; 73 | } 74 | } 75 | 76 | .cell { 77 | @include order-book-cell-widths; 78 | 79 | font-size: 11px; 80 | padding: $table_col_padding_top $table_col_padding_side; 81 | 82 | &.down { 83 | color: $R500; 84 | } 85 | 86 | &.up { 87 | color: $G500; 88 | } 89 | } 90 | } 91 | } 92 | 93 | @mixin tabs() { 94 | @include clearfix; 95 | 96 | border-bottom: 1px solid $B300; 97 | width: 100%; 98 | text-transform: uppercase; 99 | 100 | .tab { 101 | float: left; 102 | padding: $table_col_padding_top + 1 0; 103 | cursor: pointer; 104 | user-select: none; 105 | font-size: 12px; 106 | color: $N100; 107 | transition: color $trans; 108 | border-left: 1px solid transparent; 109 | border-right: 1px solid transparent; 110 | position: relative; 111 | margin: -1px 0; 112 | text-align: center; 113 | 114 | &:after { 115 | content: ""; 116 | position: absolute; 117 | display: block; 118 | width: 100%; 119 | height: 1px; 120 | background: none; 121 | top: 0; 122 | left: 0; 123 | z-index: 1; 124 | } 125 | 126 | &:hover { 127 | color: $B500; 128 | } 129 | 130 | &.active { 131 | color: $B500 !important; 132 | background: $light_bg_color; 133 | border-color: $B300; 134 | cursor: default; 135 | font-weight: bold; 136 | 137 | &:after { 138 | background: $B300; 139 | } 140 | } 141 | 142 | &:first-child { 143 | border-left: 0 !important; 144 | } 145 | 146 | &:last-child { 147 | border-right: 0 !important; 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/stores/RouterStore.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import { makeObservable } from 'utils'; 4 | import { routes } from 'routes'; 5 | 6 | @makeObservable 7 | export class RouterStore { 8 | /** 9 | * @param rootStore {RootStore} 10 | */ 11 | constructor(rootStore) { 12 | this.rootStore = rootStore; 13 | this.currentRoute = this._fillRouteSchemaFromUrl(); 14 | 15 | window.addEventListener('popstate', () => { 16 | this.currentRoute = this._fillRouteSchemaFromUrl(); 17 | }); 18 | } 19 | 20 | currentRoute = null; 21 | 22 | _fillRouteSchemaFromUrl() { 23 | const { pathname } = window.location; 24 | const pathnameArray = pathname.split('/'); 25 | const routeName = this._getRouteNameMatchingUrl(pathnameArray); 26 | 27 | if (!routeName) { 28 | // Default page 29 | if (pathname.indexOf(PUBLIC_URL) === 0) { 30 | return this.goTo(routes.marketDetailed, { 31 | market: 'usd', 32 | pair: 'eth-usd', 33 | }); 34 | } 35 | 36 | const currentRoute = routes.error404; 37 | window.history.pushState(null, null, currentRoute.path); 38 | 39 | return currentRoute; 40 | } 41 | 42 | const route = routes[routeName]; 43 | const routePathnameArray = route.path.split('/'); 44 | 45 | const params = {}; 46 | 47 | routePathnameArray.forEach((pathParam, i) => { 48 | const urlParam = pathnameArray[i]; 49 | 50 | if (pathParam.indexOf(':') === 0) { 51 | const paramName = pathParam.replace(':', ''); 52 | params[paramName] = urlParam; 53 | } 54 | }); 55 | 56 | return Object.assign({}, route, { params, isLoading: true }); 57 | } 58 | 59 | _getRouteNameMatchingUrl(pathnameArray) { 60 | return _.findKey(routes, route => { 61 | const routePathnameArray = route.path.split('/'); 62 | 63 | if (routePathnameArray.length !== pathnameArray.length) { 64 | return false; 65 | } 66 | 67 | for (let i = 0; i < routePathnameArray.length; i++) { 68 | const pathParam = routePathnameArray[i]; 69 | const urlParam = pathnameArray[i]; 70 | 71 | if (pathParam.indexOf(':') !== 0) { 72 | if (pathParam !== urlParam) { 73 | return false; 74 | } 75 | } else { 76 | const paramName = pathParam.replace(':', ''); 77 | const paramMask = _.get(route.masks, paramName); 78 | 79 | if (paramMask && !paramMask.test(urlParam)) { 80 | return false; 81 | } 82 | } 83 | } 84 | 85 | return true; 86 | }); 87 | } 88 | 89 | replaceDynamicParams(route, params) { 90 | return Object.entries(params).reduce((pathname, [paramName, value]) => { 91 | return pathname.replace(`:${paramName}`, value); 92 | }, route.path); 93 | } 94 | 95 | goTo(route, params) { 96 | if (this.currentRoute && route.name === this.currentRoute.name) { 97 | if (_.isEqual(this.currentRoute.params, params)) { 98 | return null; 99 | } 100 | 101 | this.currentRoute.isLoading = true; 102 | this.currentRoute.params = params; 103 | 104 | const newPathname = this.replaceDynamicParams(this.currentRoute, params); 105 | 106 | window.history.pushState(null, null, newPathname); 107 | 108 | return null; 109 | } 110 | 111 | const newPathname = this.replaceDynamicParams(route, params); 112 | 113 | window.history.pushState(null, null, newPathname); 114 | 115 | this.currentRoute = this._fillRouteSchemaFromUrl(); 116 | 117 | return this.currentRoute; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/pages/MarketDetailed/Chart/Chart.scss: -------------------------------------------------------------------------------- 1 | @import "mixins.scss"; 2 | 3 | .block { 4 | @include block($col_width * 4); 5 | 6 | padding: $col_spacing; 7 | 8 | .header.absolute { 9 | border-bottom: 0; 10 | position: absolute; 11 | z-index: 3; 12 | top: 0; 13 | left: 0; 14 | } 15 | 16 | .credits { 17 | color: $N100; 18 | font-weight: normal; 19 | font-size: 12px; 20 | } 21 | } 22 | 23 | .chart { 24 | :global { 25 | .highcharts-root { 26 | font-family: "Roboto", sans-serif !important; 27 | font-size: 12px !important; 28 | line-height: $line_height; 29 | } 30 | 31 | .highcharts-background { 32 | fill: $N0; 33 | } 34 | 35 | .highcharts-scrollbar { 36 | transform: translate(0, 351px) !important; 37 | } 38 | 39 | .highcharts-scrollbar-button, 40 | .highcharts-scrollbar-thumb { 41 | fill: $border_color; 42 | cursor: pointer; 43 | transition: fill $trans; 44 | 45 | &:hover { 46 | fill: $N100; 47 | } 48 | } 49 | 50 | .highcharts-scrollbar-track { 51 | stroke: $border_color; 52 | fill: none; 53 | } 54 | 55 | .highcharts-scrollbar-rifles { 56 | cursor: pointer; 57 | } 58 | 59 | .highcharts-button { 60 | $rightOffset: 67px; 61 | 62 | .highcharts-button-box { 63 | fill: none; 64 | } 65 | 66 | text { 67 | fill: $N100 !important; 68 | transition: fill $trans; 69 | font-size: 12px; 70 | } 71 | 72 | &.highcharts-button-hover { 73 | text { 74 | fill: $B500 !important; 75 | } 76 | } 77 | 78 | &.highcharts-button-pressed { 79 | text { 80 | fill: $B500 !important; 81 | font-weight: bold; 82 | } 83 | } 84 | 85 | &:nth-of-type(1) { 86 | transform: translateX($rightOffset - 67px); 87 | } 88 | 89 | &:nth-of-type(2) { 90 | transform: translateX($rightOffset); 91 | } 92 | 93 | &:nth-of-type(3) { 94 | transform: translateX($rightOffset + 70px); 95 | } 96 | } 97 | 98 | .highcharts-tooltip { 99 | .highcharts-tooltip-box { 100 | > span { 101 | background: $N20; 102 | padding: $table_col_padding_side; 103 | font-family: inherit !important; 104 | font-size: 12px !important; 105 | line-height: $line_height; 106 | color: $N900 !important; 107 | } 108 | } 109 | } 110 | 111 | .highcharts-point { 112 | stroke-width: 0.5; 113 | stroke: $N600; 114 | 115 | &.highcharts-point-up { 116 | fill: $G500; 117 | 118 | &.highcharts-point-hover { 119 | fill: $G400; 120 | } 121 | } 122 | 123 | &.highcharts-point-down { 124 | fill: $R500; 125 | 126 | &.highcharts-point-hover { 127 | fill: $R400; 128 | } 129 | } 130 | 131 | &.highcharts-color-1 { 132 | stroke-width: 0; 133 | fill: $border_color; 134 | 135 | &.highcharts-point-hover { 136 | fill: $N100; 137 | } 138 | } 139 | } 140 | 141 | .highcharts-series-1 { 142 | path:first-child { 143 | fill: url(#chartVolumeGradient) !important; 144 | } 145 | 146 | path:nth-child(2) { 147 | stroke: $B500 !important; 148 | stroke-width: 1; 149 | } 150 | } 151 | 152 | .highcharts-grid-line { 153 | stroke: $N20; 154 | } 155 | 156 | .highcharts-axis-labels { 157 | text { 158 | fill: $N900 !important; 159 | } 160 | } 161 | } 162 | } 163 | 164 | .svgGradient { 165 | position: absolute; 166 | left: -99999px; 167 | 168 | stop:first-child { 169 | stop-color: $B500a3; 170 | } 171 | 172 | stop:last-child { 173 | stop-color: $N0; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/api/_api.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import { 4 | omitParam, 5 | validateRequestParams, 6 | makeRequestUrl, 7 | makeRequest, 8 | validateResponse, 9 | startMetrics, 10 | stopMetrics, 11 | } from 'api/utils'; 12 | 13 | export const apiRoutes = { 14 | symbolInfo: { 15 | url: params => `https://api.coingecko.com/api/v3/coins/${params.id}`, 16 | params: { 17 | id: omitParam, 18 | localization: _.isBoolean, 19 | community_data: _.isBoolean, 20 | developer_data: _.isBoolean, 21 | tickers: _.isBoolean, 22 | }, 23 | responseObject: { 24 | id: _.isString, 25 | name: _.isString, 26 | symbol: _.isString, 27 | genesis_date: v => _.isString(v) || _.isNil(v), 28 | last_updated: _.isString, 29 | country_origin: _.isString, 30 | 31 | coingecko_rank: _.isNumber, 32 | coingecko_score: _.isNumber, 33 | community_score: _.isNumber, 34 | developer_score: _.isNumber, 35 | liquidity_score: _.isNumber, 36 | market_cap_rank: _.isNumber, 37 | block_time_in_minutes: _.isNumber, 38 | public_interest_score: _.isNumber, 39 | 40 | image: _.isPlainObject, 41 | links: _.isPlainObject, 42 | description: _.isPlainObject, 43 | market_data: _.isPlainObject, 44 | localization(value, requestParams) { 45 | if (requestParams.localization === false) { 46 | return true; 47 | } 48 | 49 | return _.isPlainObject(value); 50 | }, 51 | community_data(value, requestParams) { 52 | if (requestParams.community_data === false) { 53 | return true; 54 | } 55 | 56 | return _.isPlainObject(value); 57 | }, 58 | developer_data(value, requestParams) { 59 | if (requestParams.developer_data === false) { 60 | return true; 61 | } 62 | 63 | return _.isPlainObject(value); 64 | }, 65 | public_interest_stats: _.isPlainObject, 66 | 67 | tickers(value, requestParams) { 68 | if (requestParams.tickers === false) { 69 | return true; 70 | } 71 | 72 | return _.isArray(value); 73 | }, 74 | categories: _.isArray, 75 | status_updates: _.isArray, 76 | }, 77 | }, 78 | symbolsList: { 79 | url: 'https://api.coingecko.com/api/v3/coins/list', 80 | responseArray: { 81 | id: _.isString, 82 | symbol: _.isString, 83 | name: _.isString, 84 | }, 85 | }, 86 | rates: { 87 | url: 'https://api.coingecko.com/api/v3/exchange_rates', 88 | responseObject: { 89 | rates: _.isPlainObject, 90 | }, 91 | }, 92 | chartData: { 93 | url: 'https://api.coingecko.com/api/v3/coins/bitcoin/market_chart', 94 | params: { 95 | vs_currency: _.isString, 96 | id: _.isString, 97 | days: v => _.isNumber(v) || _.isString(v), 98 | }, 99 | responseObject: { 100 | prices: _.isArray, 101 | total_volumes: _.isArray, 102 | }, 103 | }, 104 | marketsList: { 105 | url: 'https://api.coingecko.com/api/v3/coins/markets', 106 | params: { 107 | vs_currency: _.isString, 108 | order: _.isString, 109 | per_page: _.isNumber, 110 | page: _.isNumber, 111 | sparkline: _.isBoolean, 112 | }, 113 | responseArray: { 114 | id: _.isString, 115 | symbol: _.isString, 116 | name: _.isString, 117 | image: _.isString, 118 | current_price: _.isNumber, 119 | market_cap: _.isNumber, 120 | market_cap_rank: _.isNumber, 121 | total_volume: _.isNumber, 122 | high_24h: _.isNumber, 123 | low_24h: _.isNumber, 124 | price_change_24h: _.isNumber, 125 | price_change_percentage_24h: _.isNumber, 126 | market_cap_change_24h: _.isNumber, 127 | market_cap_change_percentage_24h: _.isNumber, 128 | circulating_supply: _.isNumber, 129 | }, 130 | }, 131 | }; 132 | 133 | export function request(route, params) { 134 | return Promise.resolve() 135 | .then(startMetrics(route, apiRoutes)) 136 | .then(validateRequestParams(route, params)) 137 | .then(makeRequestUrl(route, params)) 138 | .then(makeRequest) 139 | .then(validateResponse(route, params)) 140 | .then(stopMetrics(route, apiRoutes)) 141 | .catch(error => { 142 | stopMetrics(route, apiRoutes)(); 143 | 144 | throw error; 145 | }); 146 | } 147 | -------------------------------------------------------------------------------- /src/stores/CurrentTPStore.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import { sum, makeObservable, precise } from 'utils'; 4 | import { apiRoutes, request } from 'api'; 5 | 6 | @makeObservable 7 | export class CurrentTPStore { 8 | /** 9 | * @param rootStore {RootStore} 10 | */ 11 | constructor(rootStore) { 12 | this.rootStore = rootStore; 13 | } 14 | 15 | id = ''; 16 | symbol = ''; 17 | fullName = ''; 18 | currency = ''; 19 | tradedCurrency = ''; 20 | low24h = 0; 21 | high24h = 0; 22 | lastPrice = 0; 23 | marketCap = 0; 24 | change24h = 0; 25 | change24hPercentage = 0; 26 | offersSell = []; 27 | offersBuy = []; 28 | chartPrices = []; 29 | chartVolumes = []; 30 | 31 | get name() { 32 | if (!this.currency) { 33 | return ''; 34 | } 35 | 36 | return `${this.currency} / ${this.tradedCurrency}`; 37 | } 38 | get urlName() { 39 | if (!this.currency) { 40 | return ''; 41 | } 42 | 43 | return `${this.currency.toLocaleLowerCase()}-${this.tradedCurrency.toLocaleLowerCase()}`; 44 | } 45 | get usdValue() { 46 | const { rates } = this.rootStore; 47 | 48 | if (!this.symbol || !rates.rates[this.symbol]) { 49 | return 0; 50 | } 51 | 52 | const usdRate = rates.rates.usd.value; 53 | const currentRate = rates.rates[this.symbol].value; 54 | 55 | return `$${precise(usdRate / currentRate, 'usd')}`; 56 | } 57 | get offersSellTotal() { 58 | if (this.offersSell.length === 0) { 59 | return 0; 60 | } 61 | 62 | return this.offersSell.map(({ total }) => total).reduce(sum); 63 | } 64 | get offersBuyTotal() { 65 | if (this.offersSell.length === 0) { 66 | return 0; 67 | } 68 | 69 | return this.offersBuy.map(({ total }) => total).reduce(sum); 70 | } 71 | 72 | fetchSymbol(params) { 73 | const { symbol, tradedCurrency } = params; 74 | const { marketsList } = this.rootStore; 75 | 76 | if (this.symbol === symbol) { 77 | return Promise.resolve(); 78 | } 79 | 80 | const symbolData = marketsList.symbolsList.find(v => v.symbol === symbol); 81 | 82 | if (!symbolData) { 83 | console.error(`fetchSymbol: no symbol data for ${symbol}`); 84 | 85 | return Promise.resolve(); 86 | } 87 | 88 | const requestParams = { 89 | id: symbolData.id, 90 | localization: false, 91 | community_data: false, 92 | developer_data: false, 93 | tickers: false, 94 | }; 95 | 96 | return request(apiRoutes.symbolInfo, requestParams) 97 | .then(data => this.fetchSymbolSuccess(data, tradedCurrency)) 98 | .catch(this.fetchSymbolError); 99 | } 100 | fetchSymbolSuccess(data, tradedCurrency) { 101 | const { 102 | id, 103 | symbol, 104 | name, 105 | market_data: { 106 | high_24h, 107 | low_24h, 108 | price_change_24h_in_currency, 109 | price_change_percentage_24h_in_currency, 110 | market_cap, 111 | current_price, 112 | }, 113 | } = data; 114 | 115 | this.id = id; 116 | this.symbol = symbol; 117 | this.fullName = name; 118 | this.currency = symbol; 119 | this.tradedCurrency = tradedCurrency; 120 | this.lastPrice = current_price[tradedCurrency]; 121 | this.high24h = high_24h[tradedCurrency]; 122 | this.low24h = low_24h[tradedCurrency]; 123 | this.change24h = price_change_24h_in_currency[tradedCurrency]; 124 | this.change24hPercentage = 125 | price_change_percentage_24h_in_currency[tradedCurrency]; 126 | this.marketCap = market_cap[tradedCurrency]; 127 | 128 | this._generateOrders(); 129 | 130 | return this.fetchChartData(); 131 | } 132 | fetchSymbolError(error) { 133 | console.error(error); 134 | } 135 | 136 | fetchChartData() { 137 | const requestParams = { 138 | id: this.id, 139 | vs_currency: this.tradedCurrency, 140 | days: 200, 141 | }; 142 | 143 | return request(apiRoutes.chartData, requestParams) 144 | .then(this.fetchChartDataSuccess) 145 | .catch(this.fetchChartDataError); 146 | } 147 | fetchChartDataSuccess(data) { 148 | const prices = []; 149 | 150 | for (let i = 0; i < data.prices.length; i += 1) { 151 | const prevClose = _.get(data.prices[i - 1], '[1]') || 0; 152 | const [date, close] = data.prices[i]; 153 | const open = prevClose; 154 | 155 | let high = _.random(0.00001, open / 10); 156 | if (open > close) { 157 | high += open; 158 | } else { 159 | high += close; 160 | } 161 | 162 | let low = _.random(0.00001, open / 10); 163 | if (open > close) { 164 | low = close - low; 165 | } else { 166 | low = open - low; 167 | } 168 | 169 | prices.push([date, open, high, low, close]); 170 | } 171 | 172 | this.chartPrices = prices; 173 | this.chartVolumes = data.total_volumes; 174 | } 175 | fetchChartDataError(error) { 176 | console.error(error); 177 | } 178 | 179 | _generateOrders() { 180 | const offersSell = []; 181 | const offersBuy = []; 182 | 183 | let priceSell = this.lastPrice; 184 | let priceBuy = this.lastPrice; 185 | 186 | _.range(6).forEach(() => { 187 | priceSell += _.random(0.00001, priceSell / 100); 188 | priceBuy -= _.random(0.00001, priceBuy / 100); 189 | 190 | const amountSell = _.random(1, 100); 191 | const amountBuy = _.random(1, 100); 192 | 193 | offersSell.push({ 194 | price: priceSell, 195 | amount: amountSell, 196 | total: priceSell * amountSell, 197 | }); 198 | 199 | offersBuy.push({ 200 | price: priceBuy, 201 | amount: amountBuy, 202 | total: priceBuy * amountBuy, 203 | }); 204 | }); 205 | 206 | this.offersSell = offersSell.sort((a, b) => b.price - a.price); 207 | this.offersBuy = offersBuy; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /dist/app.6aeef33d963c36044219.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Roboto); 2 | @-webkit-keyframes styles__bugfix { 3 | from { 4 | padding: 0; 5 | } 6 | 7 | to { 8 | padding: 0; 9 | } 10 | } 11 | 12 | *, 13 | *:before, 14 | *:after { 15 | box-sizing: border-box; 16 | } 17 | 18 | html, 19 | body { 20 | height: 100%; 21 | -webkit-text-size-adjust: 100%; 22 | -ms-text-size-adjust: 100%; 23 | -webkit-animation: styles__bugfix infinite 1s; 24 | font-family: "PT Sans", sans-serif; 25 | } 26 | 27 | html, 28 | body, 29 | div, 30 | span, 31 | cite, 32 | code, 33 | figure, 34 | h1, 35 | h2, 36 | h3, 37 | h4, 38 | h5, 39 | h6, 40 | p, 41 | blockquote, 42 | pre, 43 | small, 44 | sub, 45 | sup, 46 | b, 47 | i, 48 | a, 49 | ol, 50 | ul, 51 | li, 52 | footer, 53 | fieldset, 54 | form, 55 | label, 56 | legend, 57 | table, 58 | caption, 59 | tr, 60 | th, 61 | td, 62 | audio, 63 | video { 64 | margin: 0; 65 | padding: 0; 66 | border: 0; 67 | font-size: 100%; 68 | font: inherit; 69 | vertical-align: baseline; 70 | background: transparent; 71 | } 72 | 73 | b { 74 | font-weight: bold; 75 | } 76 | 77 | i { 78 | font-style: italic; 79 | } 80 | 81 | input, 82 | textarea, 83 | select { 84 | font: inherit; 85 | font-size: 100%; 86 | margin: 0; 87 | line-height: normal; 88 | } 89 | 90 | input, 91 | select { 92 | vertical-align: top; 93 | -webkit-appearance: none; 94 | } 95 | 96 | input::-moz-focus-inner, select::-moz-focus-inner { 97 | padding: 0; 98 | border: 0; 99 | } 100 | 101 | input[type="search"] { 102 | -webkit-appearance: textfield; 103 | } 104 | 105 | input[type="search"]::-webkit-search-cancel-button, 106 | input[type="search"]::-webkit-search-decoration { 107 | -webkit-appearance: none; 108 | } 109 | 110 | audio, 111 | video { 112 | display: inline-block; 113 | } 114 | 115 | sup { 116 | vertical-align: text-top; 117 | } 118 | 119 | sub { 120 | vertical-align: text-bottom; 121 | } 122 | 123 | img { 124 | border: 0; 125 | vertical-align: top; 126 | } 127 | 128 | table { 129 | border-collapse: collapse; 130 | border-spacing: 0; 131 | } 132 | 133 | textarea { 134 | overflow: auto; 135 | vertical-align: top; 136 | } 137 | 138 | 139 | body { 140 | font-family: "Roboto", sans-serif; 141 | font-size: 14px; 142 | line-height: 20px; 143 | overflow-x: hidden; 144 | background: var(--n0); 145 | }::-moz-selection { 146 | background: var(--n900); 147 | color: var(--n0); 148 | fill: var(--n0); 149 | }::selection { 150 | background: var(--n900); 151 | color: var(--n0); 152 | fill: var(--n0); 153 | }a { 154 | text-decoration: none; 155 | } 156 | 157 | .GlobalLoader__loader { 158 | position: fixed; 159 | z-index: 100; 160 | left: 0; 161 | top: 0; 162 | width: 100%; 163 | height: 6px; 164 | background: var(--g500); 165 | opacity: 0; 166 | -webkit-transform: translateX(-100%); 167 | transform: translateX(-100%); 168 | } 169 | 170 | .GlobalLoader__loader.GlobalLoader__firstHalf { 171 | opacity: 1; 172 | -webkit-transform: translateX(-30%); 173 | transform: translateX(-30%); 174 | transition: opacity 0.5s ease, 175 | -webkit-transform 4s cubic-bezier(0.075, 0.85, 0.085, 0.885); 176 | transition: transform 4s cubic-bezier(0.075, 0.85, 0.085, 0.885), 177 | opacity 0.5s ease; 178 | transition: transform 4s cubic-bezier(0.075, 0.85, 0.085, 0.885), opacity 0.5s ease; 179 | } 180 | 181 | .GlobalLoader__loader.GlobalLoader__lastHalf { 182 | opacity: 0; 183 | -webkit-transform: translateX(50%); 184 | transform: translateX(50%); 185 | transition: opacity 0.5s ease, -webkit-transform 0.5s ease; 186 | transition: opacity 0.5s ease, transform 0.5s ease; 187 | transition: opacity 0.5s ease, transform 0.5s ease, -webkit-transform 0.5s ease; 188 | } 189 | 190 | .Chart__block { 191 | float: left; 192 | width: 50%; 193 | padding: 8px; 194 | 195 | padding: 8px; 196 | } 197 | 198 | .Chart__block .Chart__blockInner { 199 | border: 1px solid var(--n30); 200 | border-radius: 4px; 201 | overflow: hidden; 202 | position: relative; 203 | } 204 | 205 | .Chart__block .Chart__header { 206 | font-weight: bold; 207 | border-bottom: 1px solid var(--n30); 208 | padding: 2px 5px; 209 | font-size: 13px; 210 | color: var(--n900); 211 | } 212 | 213 | .Chart__block .Chart__body:after { 214 | content: ""; 215 | display: table; 216 | clear: both; 217 | } 218 | 219 | .Chart__block .Chart__header.Chart__absolute { 220 | border-bottom: 0; 221 | position: absolute; 222 | z-index: 3; 223 | top: 0; 224 | left: 0; 225 | } 226 | 227 | .Chart__block .Chart__credits { 228 | color: var(--n100); 229 | font-weight: normal; 230 | font-size: 12px; 231 | } 232 | .Chart__chart .highcharts-root { 233 | font-family: "Roboto", sans-serif !important; 234 | font-size: 12px !important; 235 | line-height: 20px; 236 | } 237 | .Chart__chart .highcharts-background { 238 | fill: var(--n0); 239 | } 240 | .Chart__chart .highcharts-scrollbar { 241 | -webkit-transform: translate(0, 351px) !important; 242 | transform: translate(0, 351px) !important; 243 | } 244 | .Chart__chart .highcharts-scrollbar-button, 245 | .Chart__chart .highcharts-scrollbar-thumb { 246 | fill: var(--n30); 247 | cursor: pointer; 248 | transition: fill 0.2s ease; 249 | } 250 | .Chart__chart .highcharts-scrollbar-button:hover, .Chart__chart .highcharts-scrollbar-thumb:hover { 251 | fill: var(--n100); 252 | } 253 | .Chart__chart .highcharts-scrollbar-track { 254 | stroke: var(--n30); 255 | fill: none; 256 | } 257 | .Chart__chart .highcharts-scrollbar-rifles { 258 | cursor: pointer; 259 | } 260 | .Chart__chart .highcharts-button .highcharts-button-box { 261 | fill: none; 262 | } 263 | .Chart__chart .highcharts-button text { 264 | fill: var(--n100) !important; 265 | transition: fill 0.2s ease; 266 | font-size: 12px; 267 | } 268 | .Chart__chart .highcharts-button.highcharts-button-hover text { 269 | fill: var(--b500) !important; 270 | } 271 | .Chart__chart .highcharts-button.highcharts-button-pressed text { 272 | fill: var(--b500) !important; 273 | font-weight: bold; 274 | } 275 | .Chart__chart .highcharts-button:nth-of-type(1) { 276 | -webkit-transform: translateX(67px - 67px); 277 | transform: translateX(0px); 278 | } 279 | .Chart__chart .highcharts-button:nth-of-type(2) { 280 | -webkit-transform: translateX(67px); 281 | transform: translateX(67px); 282 | } 283 | .Chart__chart .highcharts-button:nth-of-type(3) { 284 | -webkit-transform: translateX(67px + 70px); 285 | transform: translateX(137px); 286 | } 287 | .Chart__chart .highcharts-tooltip .highcharts-tooltip-box > span { 288 | background: var(--n20); 289 | padding: 5px; 290 | font-family: inherit !important; 291 | font-size: 12px !important; 292 | line-height: 20px; 293 | color: var(--n900) !important; 294 | } 295 | .Chart__chart .highcharts-point { 296 | stroke-width: 0.5; 297 | stroke: var(--n600); 298 | } 299 | .Chart__chart .highcharts-point.highcharts-point-up { 300 | fill: var(--g500); 301 | } 302 | .Chart__chart .highcharts-point.highcharts-point-up.highcharts-point-hover { 303 | fill: var(--g400); 304 | } 305 | .Chart__chart .highcharts-point.highcharts-point-down { 306 | fill: var(--r500); 307 | } 308 | .Chart__chart .highcharts-point.highcharts-point-down.highcharts-point-hover { 309 | fill: var(--r400); 310 | } 311 | .Chart__chart .highcharts-point.highcharts-color-1 { 312 | stroke-width: 0; 313 | fill: var(--n30); 314 | } 315 | .Chart__chart .highcharts-point.highcharts-color-1.highcharts-point-hover { 316 | fill: var(--n100); 317 | } 318 | .Chart__chart .highcharts-series-1 path:first-child { 319 | fill: url(#chartVolumeGradient) !important; 320 | } 321 | .Chart__chart .highcharts-series-1 path:nth-child(2) { 322 | stroke: var(--b500) !important; 323 | stroke-width: 1; 324 | } 325 | .Chart__chart .highcharts-grid-line { 326 | stroke: var(--n20); 327 | } 328 | .Chart__chart .highcharts-axis-labels text { 329 | fill: var(--n900) !important; 330 | } 331 | .Chart__svgGradient { 332 | position: absolute; 333 | left: -99999px; 334 | } 335 | .Chart__svgGradient stop:first-child { 336 | stop-color: var(--b500a3); 337 | } 338 | .Chart__svgGradient stop:last-child { 339 | stop-color: var(--n0); 340 | } 341 | 342 | .TPInfo__infoWrapper { 343 | float: left; 344 | width: 100%; 345 | } 346 | 347 | .TPInfo__infoWrapper .TPInfo__info { 348 | 349 | padding: 30px 0 12px 0; 350 | } 351 | 352 | .TPInfo__infoWrapper .TPInfo__info:after { 353 | content: ""; 354 | display: table; 355 | clear: both; 356 | } 357 | 358 | .TPInfo__infoWrapper .TPInfo__info .TPInfo__currencyName { 359 | float: left; 360 | width: 25%; 361 | font-weight: bold; 362 | color: var(--n900); 363 | font-size: 16px; 364 | text-transform: uppercase; 365 | padding: 12px 14px 0 14px; 366 | } 367 | 368 | .TPInfo__infoWrapper .TPInfo__info .TPInfo__dataColumns { 369 | float: left; 370 | width: 50%; 371 | display: flex; 372 | flex-flow: row nowrap; 373 | justify-content: space-between; 374 | } 375 | 376 | .TPInfo__infoWrapper .TPInfo__info .TPInfo__dataColumns .TPInfo__column { 377 | padding: 0 14px; 378 | } 379 | 380 | .TPInfo__infoWrapper .TPInfo__info .TPInfo__dataColumns .TPInfo__column .TPInfo__columnLabel { 381 | color: var(--n500); 382 | font-size: 12px; 383 | } 384 | 385 | .TPInfo__infoWrapper .TPInfo__info .TPInfo__dataColumns .TPInfo__column .TPInfo__columnData { 386 | color: var(--n900); 387 | font-weight: bold; 388 | font-size: 12px; 389 | } 390 | 391 | .TPInfo__infoWrapper .TPInfo__info .TPInfo__dataColumns .TPInfo__column .TPInfo__columnData span { 392 | display: inline-block; 393 | vertical-align: top; 394 | } 395 | 396 | .TPInfo__infoWrapper .TPInfo__info .TPInfo__dataColumns .TPInfo__column .TPInfo__columnData .TPInfo__changePercentage { 397 | display: inline-block; 398 | vertical-align: top; 399 | 400 | font-size: 11px; 401 | font-weight: normal; 402 | line-height: 11px; 403 | margin-left: 5px; 404 | } 405 | 406 | .TPInfo__infoWrapper .TPInfo__info .TPInfo__dataColumns .TPInfo__column.TPInfo__up .TPInfo__columnData { 407 | color: var(--g500); 408 | } 409 | 410 | .TPInfo__infoWrapper .TPInfo__info .TPInfo__dataColumns .TPInfo__column.TPInfo__down .TPInfo__columnData { 411 | color: var(--r500); 412 | } 413 | 414 | .TPInfo__infoWrapper .TPInfo__info .TPInfo__options { 415 | float: left; 416 | width: 25%; 417 | font-weight: bold; 418 | color: var(--n900); 419 | font-size: 14px; 420 | padding: 12px 14px 0 14px; 421 | } 422 | 423 | .OrderBook__block { 424 | float: left; 425 | width: 25%; 426 | padding: 8px; 427 | } 428 | 429 | .OrderBook__block .OrderBook__blockInner { 430 | border: 1px solid var(--n30); 431 | border-radius: 4px; 432 | overflow: hidden; 433 | position: relative; 434 | } 435 | 436 | .OrderBook__block .OrderBook__header { 437 | font-weight: bold; 438 | border-bottom: 1px solid var(--n30); 439 | padding: 2px 5px; 440 | font-size: 13px; 441 | color: var(--n900); 442 | } 443 | 444 | .OrderBook__block .OrderBook__body:after { 445 | content: ""; 446 | display: table; 447 | clear: both; 448 | } 449 | 450 | .OrderBook__block .OrderBook__tableHeader { 451 | 452 | border-bottom: 1px solid var(--n30); 453 | } 454 | 455 | .OrderBook__block .OrderBook__tableHeader:after { 456 | content: ""; 457 | display: table; 458 | clear: both; 459 | } 460 | 461 | .OrderBook__block .OrderBook__tableHeader .OrderBook__cell { 462 | float: left; 463 | 464 | background: var(--n10); 465 | color: var(--n600); 466 | font-size: 11px; 467 | padding: 2px 5px; 468 | } 469 | 470 | .OrderBook__block .OrderBook__tableHeader .OrderBook__cell:nth-child(1) { 471 | width: 40%; 472 | } 473 | 474 | .OrderBook__block .OrderBook__tableHeader .OrderBook__cell:nth-child(2) { 475 | width: 30%; 476 | } 477 | 478 | .OrderBook__block .OrderBook__tableHeader .OrderBook__cell:nth-child(3) { 479 | width: 30%; 480 | } 481 | 482 | .OrderBook__block .OrderBook__tableBody .OrderBook__row { 483 | 484 | display: block; 485 | border-bottom: 1px solid var(--n20); 486 | position: relative; 487 | height: 24px; 488 | color: var(--n600); 489 | } 490 | 491 | .OrderBook__block .OrderBook__tableBody .OrderBook__row:after { 492 | content: ""; 493 | display: table; 494 | clear: both; 495 | } 496 | 497 | .OrderBook__block .OrderBook__tableBody .OrderBook__row:last-child { 498 | border-bottom: 0; 499 | } 500 | 501 | .OrderBook__block .OrderBook__tableBody .OrderBook__cell { 502 | float: left; 503 | 504 | font-size: 11px; 505 | padding: 2px 5px; 506 | } 507 | 508 | .OrderBook__block .OrderBook__tableBody .OrderBook__cell:nth-child(1) { 509 | width: 40%; 510 | } 511 | 512 | .OrderBook__block .OrderBook__tableBody .OrderBook__cell:nth-child(2) { 513 | width: 30%; 514 | } 515 | 516 | .OrderBook__block .OrderBook__tableBody .OrderBook__cell:nth-child(3) { 517 | width: 30%; 518 | } 519 | 520 | .OrderBook__block .OrderBook__tableBody .OrderBook__cell.OrderBook__down { 521 | color: var(--r500); 522 | } 523 | 524 | .OrderBook__block .OrderBook__tableBody .OrderBook__cell.OrderBook__up { 525 | color: var(--g500); 526 | } 527 | 528 | .OrderBook__block .OrderBook__bar { 529 | position: absolute; 530 | z-index: -1; 531 | height: 100%; 532 | background: red; 533 | top: 0; 534 | right: 0; 535 | } 536 | 537 | .OrderBook__block .OrderBook__tableBody .OrderBook__row.OrderBook__sell .OrderBook__cell:first-child { 538 | color: var(--r500); 539 | } 540 | 541 | .OrderBook__block .OrderBook__tableBody .OrderBook__row.OrderBook__sell .OrderBook__bar { 542 | background: linear-gradient(var(--r500a2), var(--r500a1)); 543 | } 544 | 545 | .OrderBook__block .OrderBook__tableBody .OrderBook__row.OrderBook__buy .OrderBook__cell:first-child { 546 | color: var(--g500); 547 | } 548 | 549 | .OrderBook__block .OrderBook__tableBody .OrderBook__row.OrderBook__buy .OrderBook__bar { 550 | background: linear-gradient(var(--g500a2), var(--g500a1)); 551 | } 552 | 553 | .OrderBook__block .OrderBook__currentPriceRow { 554 | border-top: 1px solid var(--n30); 555 | border-bottom: 1px solid var(--n30); 556 | background: var(--n10); 557 | font-size: 14px; 558 | font-weight: bold; 559 | padding: 2px 5px; 560 | } 561 | 562 | .OrderBook__block .OrderBook__currentPriceRow.OrderBook__down { 563 | color: var(--r500); 564 | } 565 | 566 | .OrderBook__block .OrderBook__currentPriceRow.OrderBook__up { 567 | color: var(--g500); 568 | } 569 | 570 | .MarketList__block { 571 | float: left; 572 | width: 25%; 573 | padding: 8px; 574 | 575 | float: left; 576 | padding: 8px; 577 | } 578 | 579 | .MarketList__block .MarketList__blockInner { 580 | border: 1px solid var(--n30); 581 | border-radius: 4px; 582 | overflow: hidden; 583 | position: relative; 584 | } 585 | 586 | .MarketList__block .MarketList__header { 587 | font-weight: bold; 588 | border-bottom: 1px solid var(--n30); 589 | padding: 2px 5px; 590 | font-size: 13px; 591 | color: var(--n900); 592 | } 593 | 594 | .MarketList__block .MarketList__body:after { 595 | content: ""; 596 | display: table; 597 | clear: both; 598 | } 599 | 600 | .MarketList__block .MarketList__tableHeader { 601 | 602 | border-bottom: 1px solid var(--n30); 603 | } 604 | 605 | .MarketList__block .MarketList__tableHeader:after { 606 | content: ""; 607 | display: table; 608 | clear: both; 609 | } 610 | 611 | .MarketList__block .MarketList__tableHeader .MarketList__cell { 612 | float: left; 613 | 614 | background: var(--n10); 615 | color: var(--n600); 616 | font-size: 11px; 617 | padding: 2px 5px; 618 | } 619 | 620 | .MarketList__block .MarketList__tableHeader .MarketList__cell:nth-child(1) { 621 | width: 40%; 622 | } 623 | 624 | .MarketList__block .MarketList__tableHeader .MarketList__cell:nth-child(2) { 625 | width: 30%; 626 | } 627 | 628 | .MarketList__block .MarketList__tableHeader .MarketList__cell:nth-child(3) { 629 | width: 30%; 630 | } 631 | 632 | .MarketList__block .MarketList__tableBody .MarketList__row { 633 | 634 | display: block; 635 | border-bottom: 1px solid var(--n20); 636 | position: relative; 637 | height: 24px; 638 | color: var(--n600); 639 | } 640 | 641 | .MarketList__block .MarketList__tableBody .MarketList__row:after { 642 | content: ""; 643 | display: table; 644 | clear: both; 645 | } 646 | 647 | .MarketList__block .MarketList__tableBody .MarketList__row:last-child { 648 | border-bottom: 0; 649 | } 650 | 651 | .MarketList__block .MarketList__tableBody .MarketList__cell { 652 | float: left; 653 | 654 | font-size: 11px; 655 | padding: 2px 5px; 656 | } 657 | 658 | .MarketList__block .MarketList__tableBody .MarketList__cell:nth-child(1) { 659 | width: 40%; 660 | } 661 | 662 | .MarketList__block .MarketList__tableBody .MarketList__cell:nth-child(2) { 663 | width: 30%; 664 | } 665 | 666 | .MarketList__block .MarketList__tableBody .MarketList__cell:nth-child(3) { 667 | width: 30%; 668 | } 669 | 670 | .MarketList__block .MarketList__tableBody .MarketList__cell.MarketList__down { 671 | color: var(--r500); 672 | } 673 | 674 | .MarketList__block .MarketList__tableBody .MarketList__cell.MarketList__up { 675 | color: var(--g500); 676 | } 677 | 678 | .MarketList__block .MarketList__row { 679 | -webkit-user-select: none; 680 | -moz-user-select: none; 681 | -ms-user-select: none; 682 | user-select: none; 683 | } 684 | 685 | .MarketList__block .MarketList__row:hover { 686 | background: var(--n20); 687 | } 688 | 689 | .MarketList__block .MarketList__row.MarketList__active { 690 | background: var(--n20) !important; 691 | cursor: default; 692 | font-weight: bold; 693 | } 694 | 695 | .MarketList__block .MarketList__row .MarketList__cell:first-child { 696 | text-transform: uppercase; 697 | } 698 | 699 | .MarketList__block .MarketList__themeBlock { 700 | float: right; 701 | } 702 | 703 | .MarketList__block .MarketList__themeBlock .MarketList__themeToggler { 704 | display: inline-block; 705 | vertical-align: top; 706 | 707 | width: 16px; 708 | height: 16px; 709 | border: 1px solid #000; 710 | cursor: pointer; 711 | margin-right: 10px; 712 | margin-top: 2px; 713 | transition: border-color 0.2s ease; 714 | } 715 | 716 | .MarketList__block .MarketList__themeBlock .MarketList__themeToggler.MarketList__light { 717 | background: #fff; 718 | } 719 | 720 | .MarketList__block .MarketList__themeBlock .MarketList__themeToggler.MarketList__dark { 721 | background: #000; 722 | margin-right: 20px; 723 | } 724 | 725 | .MarketList__block .MarketList__themeBlock .MarketList__themeToggler:hover, 726 | .MarketList__block .MarketList__themeBlock .MarketList__themeToggler.MarketList__active { 727 | border-color: var(--b500); 728 | } 729 | 730 | .MarketList__block .MarketList__themeBlock .MarketList__lnToggler { 731 | display: inline-block; 732 | vertical-align: top; 733 | 734 | width: 32px; 735 | height: 16px; 736 | cursor: pointer; 737 | margin-right: 10px; 738 | margin-top: 2px; 739 | opacity: 0.5; 740 | transition: opacity 0.2s ease; 741 | border: 1px solid #000; 742 | background-size: 100% 100%; 743 | } 744 | 745 | .MarketList__block .MarketList__themeBlock .MarketList__lnToggler.MarketList__ru { 746 | background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAVCAIAAACor3u9AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjczMEExNkExMEQwRTExRTY4MzM4RUQxQTY1ODVGRkFFIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjczMEExNkEyMEQwRTExRTY4MzM4RUQxQTY1ODVGRkFFIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NzMwQTE2OUYwRDBFMTFFNjgzMzhFRDFBNjU4NUZGQUUiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NzMwQTE2QTAwRDBFMTFFNjgzMzhFRDFBNjU4NUZGQUUiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4XLy0KAAAAV0lEQVR42mL8//8/Ay0BEwONwagFA28By7tPv2hqAaOwx1ra+uDt2++0tYCBhWk0FREIIgZm2lrA//8LbZPpUR0N2vpA5P9n2lrwh8ZxMFqaDrwFAAEGALQmEA+T9rNSAAAAAElFTkSuQmCCOTk5); 747 | } 748 | 749 | .MarketList__block .MarketList__themeBlock .MarketList__lnToggler.MarketList__en { 750 | background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAQCAYAAAB3AH1ZAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjFFODhFQjJFMEQyRDExRTZBMjM0Rjc1Q0QzODFGMTY3IiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjFFODhFQjJGMEQyRDExRTZBMjM0Rjc1Q0QzODFGMTY3Ij4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MUU4OEVCMkMwRDJEMTFFNkEyMzRGNzVDRDM4MUYxNjciIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MUU4OEVCMkQwRDJEMTFFNkEyMzRGNzVDRDM4MUYxNjciLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5XLj5qAAAEj0lEQVR42sRVfUxVZRj/nXPPuZ9A4BZbW5SlAi1GWHDBIIHL5QLNcnWRGeIK1uzCAlfSHK2t9Y/ipmuZE9QtsWmkAhMXeLkffOi88mXMlRGIUdOyeSkj5HK/b885B0zwOl3/9Nzd7d15n/f9/Z7f8/EyN8pME6qS12Ki1hlgH/kNO/eY0X32ChAIAWoeYBgsMpcXLV+/A73tOCZ2NQLktrKuEhZdCUreOEBn5Iv9Q+Qw5wMi1DDkJKA6WYnkkV74/5zG8u1VU5wjRReb0WGN9Jw8hezNG6A/VgHz4DXs3muDvW/8/kQeZAvAGgJel4TKZzhoxwfADdxG9CYj5AV5sAxdY1jjoVFfBVLhSMmFs7UDU+XvIn/mKmxHy9HZUgVdbgLgCQCzXunShwEmlcDyBLwabVXP4jD3LTJH7IhdX4DYL/ejZ1kC8jc3ocDY4Ofg86Hr9Ai6IgWJ0mBKYpF+sgP80RbklxWj6Fg5zAOkyOfzihCRcDzEbwJJjWo+Yl6MmB+ZQUypERxFbB68jt1lh+eVDYrp4uAmmQR1Z1ywnBiAhXKVmZOMykQZ0praID/UDP2WUhR+sQkd5ydR/1mPkPZFJBbWWaTW+9mPImNiEOzFaSiNrxKwHu1CSo0H0b8ArJJLKaUUMSf6fvqDzi67c1kwBBdtqKI0yM18HNGjV+Ca+BnReVnEWCXVIf1vfbgDv+85IBbhY7UmxOyog7g7OIxZXxCaTK3oe8MVRK/jKjy+ANQUMbO4lpzMXx/XLyKwYEEiMufxI8TzYGQyBN1uhIi9jJUumD4/BPflMZGAMikRj2SmilGFFARCP9Gf7uBkDJQKHiwbtoidzLDiybAEHmSsUgFGLrVcyOslQA/+gzlZ/M/GrdxuuvcrSSmkwO3xwU9zgCH5GKVS2vJ4hEKhFAzfmwIINUb+MpYUUlLKAmCpy5QKTkpBmPbhbPqNS9qJcj/nhZyTIfvFFYhTSbmbdQxBw9M6LRVzgh8VoWv4kkggKjvj3yIU9lwuTPc4oIl/CrdWrYDVcR0e6jKVil9ahOCKNzZKLREUJhf1MbFPXxuPD2r0iIQf0+12uFtPIxARAcsqLT7deRE17xmg59g701HDs/imbxL79lpQV6ND4ZonoJGz+PWjXfAq1fDEp6Nh1I8LvaTYrEtqw3lFWBAr0puAGeheTkJnx1b0t23Bet8vcJm2YtZsR//zeagIvoDXGy7jXLck+92BCGtB3LP2MRTRoDG83YzemHgsP34QT5e+gsKxc2iJ+h4t21ZDX6wVh5UYMGFzoFbT6RJRW61HUXocAtZuON+qh1+twUBKHhrH/OgiYNx2iQcotLDPgviN9qCQwWr7EdbeceTnxKOWlDTQ+PV32bG2uRVr1BHorxIU8cFGinDm1iq+QBsHn60HN98UgNUYei4XDWMBdDXeBbz0lbufCUwEIiSv1TYqEcklItX5MDTth++MFVlftSE9MgoXTFqOM7DOm5MbPvFxUZG4lKbHvu+8BPyDOJrFV/BhgcMSUUhErESkR1KkblsBco7o8Xf7Gbx0qnPqHwEGAMUZ6nw0OFRDAAAAAElFTkSuQmCCMjA3OQ==); 751 | margin-right: 0; 752 | } 753 | 754 | .MarketList__block .MarketList__themeBlock .MarketList__lnToggler:hover, 755 | .MarketList__block .MarketList__themeBlock .MarketList__lnToggler.MarketList__active { 756 | opacity: 1; 757 | } 758 | .MarketList__alignRight { 759 | text-align: right; 760 | } 761 | 762 | .Tabs__tabs { 763 | 764 | border-bottom: 1px solid var(--b300); 765 | width: 100%; 766 | text-transform: uppercase; 767 | } 768 | .Tabs__tabs:after { 769 | content: ""; 770 | display: table; 771 | clear: both; 772 | } 773 | .Tabs__tabs .Tabs__tab { 774 | float: left; 775 | padding: 3px 0; 776 | cursor: pointer; 777 | -webkit-user-select: none; 778 | -moz-user-select: none; 779 | -ms-user-select: none; 780 | user-select: none; 781 | font-size: 12px; 782 | color: var(--n100); 783 | transition: color 0.2s ease; 784 | border-left: 1px solid transparent; 785 | border-right: 1px solid transparent; 786 | position: relative; 787 | margin: -1px 0; 788 | text-align: center; 789 | } 790 | .Tabs__tabs .Tabs__tab:after { 791 | content: ""; 792 | position: absolute; 793 | display: block; 794 | width: 100%; 795 | height: 1px; 796 | background: none; 797 | top: 0; 798 | left: 0; 799 | z-index: 1; 800 | } 801 | .Tabs__tabs .Tabs__tab:hover { 802 | color: var(--b500); 803 | } 804 | .Tabs__tabs .Tabs__tab.Tabs__active { 805 | color: var(--b500) !important; 806 | background: var(--n10); 807 | border-color: var(--b300); 808 | cursor: default; 809 | font-weight: bold; 810 | } 811 | .Tabs__tabs .Tabs__tab.Tabs__active:after { 812 | background: var(--b300); 813 | } 814 | .Tabs__tabs .Tabs__tab:first-child { 815 | border-left: 0 !important; 816 | } 817 | .Tabs__tabs .Tabs__tab:last-child { 818 | border-right: 0 !important; 819 | } 820 | 821 | .MarketDetailed__contentWrapper { 822 | padding: 0 20px; 823 | } 824 | 825 | .Error404__contentWrapper { 826 | text-align: center; 827 | } 828 | 829 | .Error404__contentWrapper .Error404__title { 830 | font-size: 40px; 831 | line-height: 46px; 832 | padding-top: 50px; 833 | } 834 | 835 | .Error404__contentWrapper .Error404__text { 836 | padding-top: 20px; 837 | } 838 | 839 | -------------------------------------------------------------------------------- /dist/lodash.b3ab72b27800aa0a5a97.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([["lodash"],{"+6XX":function(t,n,e){var r=e("y1pI");t.exports=function listCacheHas(t){return r(this.__data__,t)>-1}},"+c4W":function(t,n,e){var r=e("711d"),o=e("4/ic"),u=e("9ggG"),a=e("9Nap");t.exports=function property(t){return u(t)?r(a(t)):o(t)}},"/9aa":function(t,n,e){var r=e("NykK"),o=e("ExA7"),u="[object Symbol]";t.exports=function isSymbol(t){return"symbol"==typeof t||o(t)&&r(t)==u}},"03A+":function(t,n,e){var r=e("JTzB"),o=e("ExA7"),u=Object.prototype,a=u.hasOwnProperty,i=u.propertyIsEnumerable,c=r(function(){return arguments}())?r:function(t){return o(t)&&a.call(t,"callee")&&!i.call(t,"callee")};t.exports=c},"0Cz8":function(t,n,e){var r=e("Xi7e"),o=e("ebwN"),u=e("e4Nc"),a=200;t.exports=function stackSet(t,n){var e=this.__data__;if(e instanceof r){var i=e.__data__;if(!o||i.length0){if(++n>=e)return arguments[0]}else n=0;return t.apply(void 0,arguments)}}},"9Nap":function(t,n,e){var r=e("/9aa"),o=1/0;t.exports=function toKey(t){if("string"==typeof t||r(t))return t;var n=t+"";return"0"==n&&1/t==-o?"-0":n}},"9ggG":function(t,n,e){var r=e("Z0cm"),o=e("/9aa"),u=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,a=/^\w*$/;t.exports=function isKey(t,n){if(r(t))return!1;var e=typeof t;return!("number"!=e&&"symbol"!=e&&"boolean"!=e&&null!=t&&!o(t))||a.test(t)||!u.test(t)||null!=n&&t in Object(n)}},A90E:function(t,n,e){var r=e("6sVZ"),o=e("V6Ve"),u=Object.prototype.hasOwnProperty;t.exports=function baseKeys(t){if(!r(t))return o(t);var n=[];for(var e in Object(t))u.call(t,e)&&"constructor"!=e&&n.push(e);return n}},AP2z:function(t,n,e){var r=e("nmnc"),o=Object.prototype,u=o.hasOwnProperty,a=o.toString,i=r?r.toStringTag:void 0;t.exports=function getRawTag(t){var n=u.call(t,i),e=t[i];try{t[i]=void 0;var r=!0}catch(t){}var o=a.call(t);return r&&(n?t[i]=e:delete t[i]),o}},B8du:function(t,n){t.exports=function stubFalse(){return!1}},BiGR:function(t,n,e){var r=e("nmnc"),o=e("03A+"),u=e("Z0cm"),a=r?r.isConcatSpreadable:void 0;t.exports=function isFlattenable(t){return u(t)||o(t)||!!(a&&t&&t[a])}},CH3K:function(t,n){t.exports=function arrayPush(t,n){for(var e=-1,r=n.length,o=t.length;++en){var f=t;t=n,n=f}if(e||t%1||n%1){var s=c();return i(t+s*(n-t+a("1e-"+((s+"").length-1))),n)}return r(t,n)}},DSRE:function(t,n,e){(function(t){var r=e("Kz5y"),o=e("B8du"),u=n&&!n.nodeType&&n,a=u&&"object"==typeof t&&t&&!t.nodeType&&t,i=a&&a.exports===u?r.Buffer:void 0,c=(i?i.isBuffer:void 0)||o;t.exports=c}).call(this,e("YuTi")(t))},E2jh:function(t,n,e){var r,o=e("2gN3"),u=(r=/[^.]+$/.exec(o&&o.keys&&o.keys.IE_PROTO||""))?"Symbol(src)_1."+r:"";t.exports=function isMasked(t){return!!u&&u in t}},EpBk:function(t,n){t.exports=function isKeyable(t){var n=typeof t;return"string"==n||"number"==n||"symbol"==n||"boolean"==n?"__proto__"!==t:null===t}},ExA7:function(t,n){t.exports=function isObjectLike(t){return null!=t&&"object"==typeof t}},FZoo:function(t,n,e){var r=e("MrPd"),o=e("4uTw"),u=e("wJg7"),a=e("GoyQ"),i=e("9Nap");t.exports=function baseSet(t,n,e,c){if(!a(t))return t;for(var f=-1,s=(n=o(n,t)).length,p=s-1,l=t;null!=l&&++f0&&e(f)?n>1?baseFlatten(f,n-1,e,u,a):r(a,f):u||(a[a.length]=f)}return a}},XKAG:function(t,n,e){var r=e("ut/Y"),o=e("MMmD"),u=e("7GkX");t.exports=function createFind(t){return function(n,e,a){var i=Object(n);if(!o(n)){var c=r(e,3);n=u(n),e=function(t){return c(i[t],t,i)}}var f=t(n,e,a);return f>-1?i[c?n[f]:f]:void 0}}},Xi7e:function(t,n,e){var r=e("KMkd"),o=e("adU4"),u=e("tMB7"),a=e("+6XX"),i=e("Z8oC");function ListCache(t){var n=-1,e=null==t?0:t.length;for(this.clear();++nl))return!1;var h=s.get(t);if(h&&s.get(n))return h==n;var y=-1,b=!0,x=e&i?new r:void 0;for(s.set(t,n),s.set(n,t);++y-1&&t%1==0&&t<=e}},tLB3:function(t,n,e){var r=e("GoyQ"),o=e("/9aa"),u=NaN,a=/^\s+|\s+$/g,i=/^[-+]0x[0-9a-f]+$/i,c=/^0b[01]+$/i,f=/^0o[0-7]+$/i,s=parseInt;t.exports=function toNumber(t){if("number"==typeof t)return t;if(o(t))return u;if(r(t)){var n="function"==typeof t.valueOf?t.valueOf():t;t=r(n)?n+"":n}if("string"!=typeof t)return 0===t?t:+t;t=t.replace(a,"");var e=c.test(t);return e||f.test(t)?s(t.slice(2),e?2:8):i.test(t)?u:+t}},tMB7:function(t,n,e){var r=e("y1pI");t.exports=function listCacheGet(t){var n=this.__data__,e=r(n,t);return e<0?void 0:n[e][1]}},tadb:function(t,n,e){var r=e("Cwc5")(e("Kz5y"),"DataView");t.exports=r},u8Dt:function(t,n,e){var r=e("YESw"),o="__lodash_hash_undefined__",u=Object.prototype.hasOwnProperty;t.exports=function hashGet(t){var n=this.__data__;if(r){var e=n[t];return e===o?void 0:e}return u.call(n,t)?n[t]:void 0}},"ut/Y":function(t,n,e){var r=e("ZCpW"),o=e("GDhZ"),u=e("zZ0H"),a=e("Z0cm"),i=e("+c4W");t.exports=function baseIteratee(t){return"function"==typeof t?t:null==t?u:"object"==typeof t?a(t)?o(t[0],t[1]):r(t):i(t)}},vlbB:function(t,n){var e=Math.floor,r=Math.random;t.exports=function baseRandom(t,n){return t+e(r()*(n-t+1))}},"wF/u":function(t,n,e){var r=e("e5cp"),o=e("ExA7");t.exports=function baseIsEqual(t,n,e,u,a){return t===n||(null==t||null==n||!o(t)&&!o(n)?t!=t&&n!=n:r(t,n,e,u,baseIsEqual,a))}},wJg7:function(t,n){var e=9007199254740991,r=/^(?:0|[1-9]\d*)$/;t.exports=function isIndex(t,n){var o=typeof t;return!!(n=null==n?e:n)&&("number"==o||"symbol"!=o&&r.test(t))&&t>-1&&t%1==0&&t