├── .gitignore ├── .prettierrc ├── .stylelintrc ├── README.md ├── config ├── dev.ts ├── model.ts ├── prod.ts └── stage.ts ├── development ├── index.ejs └── webpack.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── components │ ├── counter │ │ ├── index.scss │ │ ├── index.test.tsx │ │ ├── index.tsx │ │ └── messages.ts │ ├── index.scss │ ├── ip-check │ │ ├── index.scss │ │ ├── index.test.tsx │ │ ├── index.tsx │ │ └── messages.ts │ └── seller-store-editor │ │ ├── index.scss │ │ ├── index.test.tsx │ │ └── index.tsx ├── containers │ ├── counter │ │ ├── actions.ts │ │ ├── index.tsx │ │ ├── reducer.test.tsx │ │ ├── reducer.ts │ │ ├── selector.test.ts │ │ └── selector.ts │ ├── ip-check │ │ ├── __snapshots__ │ │ │ └── index.test.tsx.snap │ │ ├── actions.ts │ │ ├── epics.test.ts │ │ ├── epics.ts │ │ ├── index.test.tsx │ │ ├── index.tsx │ │ ├── reducer.test.ts │ │ ├── reducer.ts │ │ ├── selector.test.ts │ │ └── selector.ts │ ├── language-provider │ │ ├── actions.ts │ │ ├── index.test.tsx │ │ ├── index.tsx │ │ ├── reducer.test.ts │ │ ├── reducer.ts │ │ ├── selector.test.ts │ │ └── selector.ts │ └── seller-store-editor │ │ ├── __snapshots__ │ │ └── index.test.tsx.snap │ │ ├── index.test.tsx │ │ └── index.tsx ├── i18n.test.ts ├── i18n.ts ├── polyfill.ts ├── register.ts ├── services │ ├── index.ts │ └── ip-api │ │ ├── config │ │ └── ip-api-config.ts │ │ ├── index.test.ts │ │ ├── index.ts │ │ └── models │ │ └── IPQueryResponse.ts ├── store │ ├── index.ts │ ├── root-action.ts │ ├── root-epic.ts │ ├── root-reducer.ts │ ├── services.ts │ ├── types.d.ts │ └── utils.ts ├── translations │ ├── de.json │ └── tr.json └── webcomponent.tsx ├── tsconfig.json ├── tslint.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # project based ignores 2 | dist/ 3 | 4 | # Created by https://www.gitignore.io/api/vim,node,react,macos,intellij+all 5 | # Edit at https://www.gitignore.io/?templates=vim,node,react,macos,intellij+all 6 | 7 | ### Intellij+all ### 8 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 9 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 10 | 11 | # User-specific stuff 12 | .idea/**/workspace.xml 13 | .idea/**/tasks.xml 14 | .idea/**/usage.statistics.xml 15 | .idea/**/dictionaries 16 | .idea/**/shelf 17 | 18 | # Generated files 19 | .idea/**/contentModel.xml 20 | 21 | # Sensitive or high-churn files 22 | .idea/**/dataSources/ 23 | .idea/**/dataSources.ids 24 | .idea/**/dataSources.local.xml 25 | .idea/**/sqlDataSources.xml 26 | .idea/**/dynamic.xml 27 | .idea/**/uiDesigner.xml 28 | .idea/**/dbnavigator.xml 29 | 30 | # Gradle 31 | .idea/**/gradle.xml 32 | .idea/**/libraries 33 | 34 | # Gradle and Maven with auto-import 35 | # When using Gradle or Maven with auto-import, you should exclude module files, 36 | # since they will be recreated, and may cause churn. Uncomment if using 37 | # auto-import. 38 | # .idea/modules.xml 39 | # .idea/*.iml 40 | # .idea/modules 41 | # *.iml 42 | # *.ipr 43 | 44 | # CMake 45 | cmake-build-*/ 46 | 47 | # Mongo Explorer plugin 48 | .idea/**/mongoSettings.xml 49 | 50 | # File-based project format 51 | *.iws 52 | 53 | # IntelliJ 54 | out/ 55 | 56 | # mpeltonen/sbt-idea plugin 57 | .idea_modules/ 58 | 59 | # JIRA plugin 60 | atlassian-ide-plugin.xml 61 | 62 | # Cursive Clojure plugin 63 | .idea/replstate.xml 64 | 65 | # Crashlytics plugin (for Android Studio and IntelliJ) 66 | com_crashlytics_export_strings.xml 67 | crashlytics.properties 68 | crashlytics-build.properties 69 | fabric.properties 70 | 71 | # Editor-based Rest Client 72 | .idea/httpRequests 73 | 74 | # Android studio 3.1+ serialized cache file 75 | .idea/caches/build_file_checksums.ser 76 | 77 | ### Intellij+all Patch ### 78 | # Ignores the whole .idea folder and all .iml files 79 | # See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 80 | 81 | .idea/ 82 | 83 | # Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 84 | 85 | *.iml 86 | modules.xml 87 | .idea/misc.xml 88 | *.ipr 89 | 90 | # Sonarlint plugin 91 | .idea/sonarlint 92 | 93 | ### macOS ### 94 | # General 95 | .DS_Store 96 | .AppleDouble 97 | .LSOverride 98 | 99 | # Icon must end with two \r 100 | Icon 101 | 102 | # Thumbnails 103 | ._* 104 | 105 | # Files that might appear in the root of a volume 106 | .DocumentRevisions-V100 107 | .fseventsd 108 | .Spotlight-V100 109 | .TemporaryItems 110 | .Trashes 111 | .VolumeIcon.icns 112 | .com.apple.timemachine.donotpresent 113 | 114 | # Directories potentially created on remote AFP share 115 | .AppleDB 116 | .AppleDesktop 117 | Network Trash Folder 118 | Temporary Items 119 | .apdisk 120 | 121 | ### Node ### 122 | # Logs 123 | logs 124 | *.log 125 | npm-debug.log* 126 | yarn-debug.log* 127 | yarn-error.log* 128 | lerna-debug.log* 129 | 130 | # Diagnostic reports (https://nodejs.org/api/report.html) 131 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 132 | 133 | # Runtime data 134 | pids 135 | *.pid 136 | *.seed 137 | *.pid.lock 138 | 139 | # Directory for instrumented libs generated by jscoverage/JSCover 140 | lib-cov 141 | 142 | # Coverage directory used by tools like istanbul 143 | coverage 144 | *.lcov 145 | 146 | # nyc test coverage 147 | .nyc_output 148 | 149 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 150 | .grunt 151 | 152 | # Bower dependency directory (https://bower.io/) 153 | bower_components 154 | 155 | # node-waf configuration 156 | .lock-wscript 157 | 158 | # Compiled binary addons (https://nodejs.org/api/addons.html) 159 | build/Release 160 | 161 | # Dependency directories 162 | node_modules/ 163 | jspm_packages/ 164 | 165 | # TypeScript v1 declaration files 166 | typings/ 167 | 168 | # TypeScript cache 169 | *.tsbuildinfo 170 | 171 | # Optional npm cache directory 172 | .npm 173 | 174 | # Optional eslint cache 175 | .eslintcache 176 | 177 | # Optional REPL history 178 | .node_repl_history 179 | 180 | # Output of 'npm pack' 181 | *.tgz 182 | 183 | # Yarn Integrity file 184 | .yarn-integrity 185 | 186 | # dotenv environment variables file 187 | .env 188 | .env.test 189 | 190 | # parcel-bundler cache (https://parceljs.org/) 191 | .cache 192 | 193 | # next.js build output 194 | .next 195 | 196 | # nuxt.js build output 197 | .nuxt 198 | 199 | # react / gatsby 200 | public/ 201 | 202 | # vuepress build output 203 | .vuepress/dist 204 | 205 | # Serverless directories 206 | .serverless/ 207 | 208 | # FuseBox cache 209 | .fusebox/ 210 | 211 | # DynamoDB Local files 212 | .dynamodb/ 213 | 214 | ### react ### 215 | .DS_* 216 | **/*.backup.* 217 | **/*.back.* 218 | 219 | node_modules 220 | bower_componets 221 | 222 | *.sublime* 223 | 224 | psd 225 | thumb 226 | sketch 227 | 228 | ### Vim ### 229 | # Swap 230 | [._]*.s[a-v][a-z] 231 | [._]*.sw[a-p] 232 | [._]s[a-rt-v][a-z] 233 | [._]ss[a-gi-z] 234 | [._]sw[a-p] 235 | 236 | # Session 237 | Session.vim 238 | Sessionx.vim 239 | 240 | # Temporary 241 | .netrwhist 242 | *~ 243 | # Auto-generated tag files 244 | tags 245 | # Persistent undo 246 | [._]*.un~ 247 | 248 | # End of https://www.gitignore.io/api/vim,node,react,macos,intellij+all 249 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "singleQuote": true, 4 | "printWidth": 120, 5 | "semi": true 6 | } 7 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "stylelint-scss" 4 | ], 5 | "extends": ["stylelint-prettier/recommended"] 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-webcomponent-poc 2 | This repository demonstrates a simple way of setting up a SPA with React/Redux/RxJS/Typescript. Before adopting a different solution, we were using it to embed our React SPA into a Vue application. 3 | 4 | Web Components API was used instead of iframes to re-use existing functionality in the Vue panel with sync calls. When built, this SPA can be used as `` inside your HTML page. See [development/index.ejs](./development/index.ejs) for example. 5 | 6 | ## Summary 7 | Run `npm start` and go to the URL that is printed to the screen. You will see a page that prints your IP to the screen and refreshes it every 1 minute. This page demonstrates managing side effects with redux-observable. You can use the buttons on the page to change your locale. Or change to the other page where you will see a simple counter with redux implemented. 8 | 9 | ## Features 10 | - Shadow DOM for CSS encapsulation 11 | - Redux routing with react-router 12 | - Exposed as Custom Elements 13 | - Hook/FormattedMessage based localization 14 | - Redux with redux-observable for managing side effects 15 | - Unit tests for side-effects/pure components 16 | - Environment based configuration 17 | - Automatic linting with TSLint and Prettier 18 | 19 | ## Structure 20 | ``` 21 | . 22 | ├── config # environment based configuration files 23 | ├── development # webpack config and html file for local development 24 | └── src 25 |    ├── components # pure components with html/css 26 |    ├── containers # components connected to redux 27 |    ├── services # API, Utility services 28 |    ├── store # redux store registration and root types 29 |    ├── translations # translation files for different languages 30 |    ├── polyfill.ts # polyfills for intl 31 |    ├── webcomponent.tsx # rendering SPA to custom element in shadow DOM 32 |    └── register.ts # entrypoint to expose the webcomponent 33 | ``` 34 | 35 | ## Commands 36 | - `npm start` starts a local working environment with webpack-dev-server 37 | - `npm run build` builds and outputs JS files prepend with `PROFILE=dev` e.g. to build with different configs 38 | - `npm run test` runs all unit tests 39 | - `npm run format` formats project source code with tslint and prettier 40 | -------------------------------------------------------------------------------- /config/dev.ts: -------------------------------------------------------------------------------- 1 | import { Config } from './model'; 2 | 3 | export default { 4 | ip: { 5 | url: 'http://ip-api.com' 6 | } 7 | } as Config; 8 | -------------------------------------------------------------------------------- /config/model.ts: -------------------------------------------------------------------------------- 1 | export type Config = { 2 | ip: { 3 | url: string; 4 | }; 5 | }; 6 | -------------------------------------------------------------------------------- /config/prod.ts: -------------------------------------------------------------------------------- 1 | import { Config } from './model'; 2 | 3 | export default { 4 | ip: { 5 | url: 'http://ip-api.com' 6 | } 7 | } as Config; 8 | -------------------------------------------------------------------------------- /config/stage.ts: -------------------------------------------------------------------------------- 1 | import { Config } from './model'; 2 | 3 | export default { 4 | ip: { 5 | url: 'http://ip-api.com' 6 | } 7 | } as Config; 8 | -------------------------------------------------------------------------------- /development/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Seller Store Editor WebComponent 5 | 6 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /development/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpackConfig = require('../webpack.config'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const { join } = require('path'); 4 | 5 | const HTML_TEMPLATE_PATH = join(__dirname, 'index.ejs'); 6 | 7 | module.exports = { 8 | ...webpackConfig, 9 | mode: 'development', 10 | plugins: (webpackConfig.plugins || []).concat([new HtmlWebpackPlugin({ template: HTML_TEMPLATE_PATH })]) 11 | }; 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | clearMocks: true, 4 | coverageDirectory: 'coverage', 5 | collectCoverage: true, 6 | collectCoverageFrom: [ 7 | 'src/**/*.{ts,tsx}', 8 | '!src/**/*.test.{ts,tsx}', 9 | '!src/register.ts', 10 | '!src/webcomponent.tsx', 11 | '!src/polyfill.ts', 12 | '!src/store/**', 13 | '!src/services/index.ts' 14 | ], 15 | testRegex: '.*\\.test\\.tsx?$', 16 | setupFiles: ['raf-polyfill'] 17 | }; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-webcomponent-poc", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "webpack-dev-server --config ./development/webpack.config.js", 8 | "build": "./node_modules/.bin/cross-env NODE_ENV=production ./node_modules/.bin/webpack --progress --hide-modules", 9 | "test": "jest", 10 | "lint": "tslint -p tsconfig.json -c tslint.json", 11 | "lint:fix": "npm run lint -- --fix", 12 | "prettier": "npm run prettier:write -- \"src/**/*.{ts,tsx,scss}\"", 13 | "prettier:write": "prettier --write --loglevel=error", 14 | "format": "npm run prettier && npm run lint:fix" 15 | }, 16 | "devDependencies": { 17 | "@formatjs/intl-pluralrules": "^1.5.2", 18 | "@formatjs/intl-relativetimeformat": "^4.5.9", 19 | "@testing-library/react": "^9.4.0", 20 | "@types/history": "^4.7.5", 21 | "@types/jest": "^25.1.2", 22 | "@types/react": "^16.9.19", 23 | "@types/react-dom": "^16.9.5", 24 | "@types/react-intl": "^3.0.0", 25 | "@types/react-redux": "^7.1.7", 26 | "@types/react-router-dom": "^5.1.3", 27 | "@types/react-test-renderer": "^16.9.2", 28 | "@types/redux-mock-store": "^1.0.2", 29 | "@types/url-join": "^4.0.0", 30 | "axios": "^0.19.2", 31 | "connected-react-router": "^6.7.0", 32 | "cross-env": "^7.0.0", 33 | "css-loader": "^3.4.2", 34 | "history": "^4.10.1", 35 | "html-webpack-plugin": "^3.2.0", 36 | "husky": "^4.2.3", 37 | "jest": "^25.1.0", 38 | "lint-staged": "^10.0.7", 39 | "nock": "^11.8.2", 40 | "node-sass": "^4.13.1", 41 | "prettier": "^1.19.1", 42 | "raf-polyfill": "^1.0.0", 43 | "react": "^16.12.0", 44 | "react-dom": "^16.12.0", 45 | "react-intl": "^3.12.0", 46 | "react-redux": "^7.1.3", 47 | "react-router": "^5.1.2", 48 | "react-router-dom": "^5.1.2", 49 | "react-shadow": "^17.5.0", 50 | "react-test-renderer": "^16.12.0", 51 | "redux": "^4.0.5", 52 | "redux-mock-store": "^1.5.4", 53 | "redux-observable": "^1.2.0", 54 | "reselect": "^4.0.0", 55 | "rxjs": "^6.5.4", 56 | "sass-loader": "^8.0.2", 57 | "serve": "^11.3.0", 58 | "source-map-loader": "^0.2.4", 59 | "stylelint": "^13.2.0", 60 | "stylelint-config-prettier": "^8.0.1", 61 | "stylelint-prettier": "^1.1.2", 62 | "stylelint-scss": "^3.14.2", 63 | "to-string-loader": "^1.1.6", 64 | "ts-jest": "^25.2.0", 65 | "ts-loader": "^6.2.1", 66 | "ts-mockito": "^2.5.0", 67 | "tslint": "^6.0.0", 68 | "tslint-config-prettier": "^1.18.0", 69 | "typesafe-actions": "^5.1.0", 70 | "typescript": "^3.7.5", 71 | "url-join": "^4.0.1", 72 | "webpack": "^4.41.6", 73 | "webpack-cli": "^3.3.11", 74 | "webpack-dev-server": "^3.10.3" 75 | }, 76 | "husky": { 77 | "hooks": { 78 | "pre-commit": "lint-staged" 79 | } 80 | }, 81 | "lint-staged": { 82 | "src/**/*.{ts,tsx}": [ 83 | "npm run prettier:write", 84 | "npm run lint:fix", 85 | "git add" 86 | ], 87 | "src/**/*.scss": [ 88 | "npm run prettier:write", 89 | "git add" 90 | ] 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/components/counter/index.scss: -------------------------------------------------------------------------------- 1 | .counter { 2 | &__container { 3 | display: flex; 4 | width: 100%; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/counter/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentProps } from 'react'; 2 | import { fireEvent, render } from '@testing-library/react'; 3 | import Counter from './index'; 4 | import IntlProvider from 'react-intl/dist/components/provider'; 5 | import { StaticRouter as Router } from 'react-router-dom'; 6 | 7 | const scope = `editor.components.counter`; 8 | const messages = { 9 | [`${scope}.helloText`]: 'hello-text', 10 | [`${scope}.switchButtonText`]: 'switch-button', 11 | [`${scope}.incrementButtonText`]: 'increment-button', 12 | [`${scope}.decrementButtonText`]: 'decrement-button', 13 | [`${scope}.setButtonText`]: `set-button`, 14 | [`${scope}.countText`]: 'count:{count}' 15 | }; 16 | 17 | const renderComponent = (props: Partial> = {}) => 18 | render( 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | 26 | describe('', () => { 27 | it('should render four 26 | 27 | 30 | 33 | 36 | {countText} 37 | 38 | ); 39 | }; 40 | 41 | export default Counter; 42 | -------------------------------------------------------------------------------- /src/components/counter/messages.ts: -------------------------------------------------------------------------------- 1 | import { defineMessages } from 'react-intl'; 2 | 3 | const scope = `editor.components.counter`; 4 | 5 | export default defineMessages({ 6 | helloText: { 7 | id: `${scope}.helloText`, 8 | defaultMessage: 'Sayaç Örneği' 9 | }, 10 | switchButtonText: { 11 | id: `${scope}.switchButtonText`, 12 | defaultMessage: 'Sayfa Değiştir' 13 | }, 14 | incrementButtonText: { 15 | id: `${scope}.incrementButtonText`, 16 | defaultMessage: 'Arttır' 17 | }, 18 | decrementButtonText: { 19 | id: `${scope}.decrementButtonText`, 20 | defaultMessage: 'Azalt' 21 | }, 22 | setButtonText: { 23 | id: `${scope}.setButtonText`, 24 | defaultMessage: `Tanımla` 25 | }, 26 | countText: { 27 | id: `${scope}.countText`, 28 | defaultMessage: 'Şu an sayı: {count}' 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /src/components/index.scss: -------------------------------------------------------------------------------- 1 | @import './ip-check/index'; 2 | @import './counter/index'; 3 | @import './seller-store-editor/index'; 4 | -------------------------------------------------------------------------------- /src/components/ip-check/index.scss: -------------------------------------------------------------------------------- 1 | .ip-check { 2 | &__container { 3 | display: flex; 4 | width: 100%; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/ip-check/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentProps } from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import IPCheck from './index'; 4 | import IntlProvider from 'react-intl/dist/components/provider'; 5 | import { StaticRouter as Router } from 'react-router-dom'; 6 | 7 | const scope = `editor.components.ip-check`; 8 | const messages = { 9 | [`${scope}.loadingText`]: 'loading-text', 10 | [`${scope}.failedText`]: 'failed:{time}', 11 | [`${scope}.successText`]: 'success:{ip}-{time}', 12 | [`${scope}.switchButtonText`]: 'switch' 13 | }; 14 | 15 | const renderComponent = (props: ComponentProps) => 16 | render( 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | 24 | describe('', () => { 25 | it('should loading should render the loading text', async () => { 26 | const { container, findByText } = renderComponent({ loading: true, status: null as any }); 27 | expect(container.querySelector('button')).not.toBeNull(); 28 | expect(await findByText('loading-text')).not.toBeNull(); 29 | }); 30 | 31 | it('should render status text success', () => { 32 | const { container } = renderComponent({ loading: false, status: { ip: '127.0.0.1', success: true, time: 73 } }); 33 | const span = container.querySelector('span'); 34 | 35 | expect(span).not.toBeNull(); 36 | expect(span!.innerHTML.startsWith('success:127.0.0.1-')).toBe(true); 37 | }); 38 | 39 | it('should render status text failed', () => { 40 | const { container } = renderComponent({ loading: false, status: { ip: undefined, success: false, time: 73 } }); 41 | const span = container.querySelector('span'); 42 | 43 | expect(span).not.toBeNull(); 44 | expect(span!.innerHTML.startsWith('failed:')).toBe(true); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/components/ip-check/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { FormattedMessage, useIntl } from 'react-intl'; 4 | import messages from './messages'; 5 | 6 | type StatusProps = { ip?: string; success: boolean; time: number }; 7 | 8 | type Props = { 9 | loading: boolean; 10 | status: StatusProps; 11 | }; 12 | 13 | const Loading = () => ( 14 | 15 | 16 | 17 | ); 18 | 19 | const StatusComponent = ({ ip, success, time }: StatusProps) => { 20 | const message = success ? messages.successText : messages.failedText; 21 | const { formatMessage, formatTime } = useIntl(); 22 | const timeText = formatTime(time); 23 | const text = formatMessage(message, { ip, time: timeText }); 24 | 25 | return {text}; 26 | }; 27 | 28 | const IPCheck = ({ loading, status }: Props) => ( 29 |
30 | {loading ? : } 31 | 32 | 35 | 36 |
37 | ); 38 | 39 | export default IPCheck; 40 | -------------------------------------------------------------------------------- /src/components/ip-check/messages.ts: -------------------------------------------------------------------------------- 1 | import { defineMessages } from 'react-intl'; 2 | 3 | const scope = `editor.components.ip-check`; 4 | 5 | export default defineMessages({ 6 | loadingText: { 7 | id: `${scope}.loadingText`, 8 | defaultMessage: 'IP Kontrol ediliyor...' 9 | }, 10 | failedText: { 11 | id: `${scope}.failedText`, 12 | defaultMessage: "API'ye erişilirken bir hata yaşandı. Adblock kapatmayı denedin mi? Son kontrol: {time}" 13 | }, 14 | successText: { 15 | id: `${scope}.successText`, 16 | defaultMessage: 'IP Adresin: {ip} - Son kontrol: {time}' 17 | }, 18 | switchButtonText: { 19 | id: `${scope}.switchButtonText`, 20 | defaultMessage: 'Sayfa Değiştir' 21 | } 22 | }); 23 | -------------------------------------------------------------------------------- /src/components/seller-store-editor/index.scss: -------------------------------------------------------------------------------- 1 | .editor { 2 | display: flex; 3 | width: 100%; 4 | height: 100vh; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | background-color: #f1f2f7; 9 | 10 | &__content { 11 | color: white; 12 | border-radius: 5px; 13 | background-color: #273142; 14 | display: flex; 15 | flex-direction: column; 16 | justify-content: center; 17 | align-items: center; 18 | width: 500px; 19 | height: 500px; 20 | user-select: none; 21 | 22 | button { 23 | margin: 2px 0; 24 | background-color: orange; 25 | font-size: 14px; 26 | color: white; 27 | border: none; 28 | border-radius: 3px; 29 | width: 200px; 30 | height: 50px; 31 | cursor: pointer; 32 | } 33 | 34 | span { 35 | font-weight: bold; 36 | margin: 30px 0; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/seller-store-editor/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentProps } from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import SellerStoreEditorComponent from './index'; 4 | import IntlProvider from 'react-intl/dist/components/provider'; 5 | import { StaticRouter as Router } from 'react-router-dom'; 6 | 7 | const renderComponent = (props: Partial> = {}) => 8 | render( 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | 16 | describe('', () => { 17 | it('should render with .editor class', () => { 18 | const { container } = renderComponent(); 19 | 20 | expect(container.firstElementChild).not.toBeNull(); 21 | expect(container.firstElementChild?.className).toEqual('editor'); 22 | }); 23 | 24 | it('should render the given children', () => { 25 | const childElement = hello; 26 | const { container } = renderComponent({ children: childElement }); 27 | 28 | expect(container.querySelector('span')).not.toBeNull(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/components/seller-store-editor/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { FC } from 'react'; 3 | 4 | const SellerStoreEditor: FC = ({ children }) => ( 5 |
6 |
{children}
7 |
8 | ); 9 | 10 | export default SellerStoreEditor; 11 | -------------------------------------------------------------------------------- /src/containers/counter/actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from 'typesafe-actions'; 2 | 3 | export const increment = createAction(`editor/containers/counter/INCREMENT`)(); 4 | export const decrement = createAction(`editor/containers/counter/DECREMENT`)(); 5 | export const set = createAction(`editor/containers/counter/SET`, (value: number) => value)(); 6 | -------------------------------------------------------------------------------- /src/containers/counter/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentProps } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import CounterComponent from '../../components/counter'; 4 | import * as actions from './actions'; 5 | import { createSelector } from 'reselect'; 6 | import { countSelector } from './selector'; 7 | 8 | type CounterComponentProps = ComponentProps; 9 | 10 | const dispatchProps = { 11 | increment: actions.increment, 12 | decrement: actions.decrement, 13 | set: actions.set 14 | }; 15 | 16 | type Props = typeof dispatchProps & { 17 | count: CounterComponentProps['count']; 18 | }; 19 | 20 | export const Counter = ({ count, increment, decrement, set }: Props) => ( 21 | set(73)} /> 22 | ); 23 | 24 | const mapStateToProps = createSelector(countSelector, count => ({ count })); 25 | 26 | export default connect(mapStateToProps, dispatchProps)(Counter); 27 | -------------------------------------------------------------------------------- /src/containers/counter/reducer.test.tsx: -------------------------------------------------------------------------------- 1 | import reducer from './reducer'; 2 | import * as actions from './actions'; 3 | 4 | describe('counter@reducer', () => { 5 | it('should return the initial state', () => { 6 | const state = reducer(undefined, {} as any); 7 | expect(state).toEqual({ count: 0 }); 8 | }); 9 | 10 | it('should increment the count', () => { 11 | const state = reducer({ count: 1 }, actions.increment()); 12 | expect(state).toEqual({ count: 2 }); 13 | }); 14 | 15 | it('should decrement the count', () => { 16 | const state = reducer({ count: 1 }, actions.decrement()); 17 | expect(state).toEqual({ count: 0 }); 18 | }); 19 | 20 | it('should set the count', () => { 21 | const state = reducer({ count: 1 }, actions.set(73)); 22 | expect(state).toEqual({ count: 73 }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/containers/counter/reducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import * as actions from './actions'; 3 | import { ActionType, createReducer } from 'typesafe-actions'; 4 | 5 | type EditorActions = ActionType; 6 | 7 | const count = createReducer(0) 8 | .handleAction(actions.increment, state => state + 1) 9 | .handleAction(actions.decrement, state => state - 1) 10 | .handleAction(actions.set, (_, action) => action.payload); 11 | 12 | export default combineReducers({ count }); 13 | -------------------------------------------------------------------------------- /src/containers/counter/selector.test.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from 'MyTypes'; 2 | import { countSelector } from './selector'; 3 | 4 | describe('counter@reducer', () => { 5 | describe('countSelector', () => { 6 | it('should return count', () => { 7 | const state: Partial = { counter: { count: 73 } }; 8 | expect(countSelector(state as RootState)).toBe(73); 9 | }); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/containers/counter/selector.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from 'MyTypes'; 2 | import { createSelector } from 'reselect'; 3 | 4 | const counterSelector = (state: RootState) => state.counter; 5 | export const countSelector = createSelector(counterSelector, counter => counter.count); 6 | -------------------------------------------------------------------------------- /src/containers/ip-check/__snapshots__/index.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should render and match the snapshot 1`] = ` 4 | 7 | 27 | 28 | `; 29 | -------------------------------------------------------------------------------- /src/containers/ip-check/actions.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncAction } from 'typesafe-actions'; 2 | import { IPQueryResponse } from '../../services/ip-api/models/IPQueryResponse'; 3 | 4 | export const lookupAsync = createAsyncAction( 5 | `editor/containers/ip-check/LOOKUP_REQUEST`, 6 | `editor/containers/ip-check/LOOKUP_SUCCESS`, 7 | `editor/containers/ip-check/LOOKUP_FAILED` 8 | )(); 9 | -------------------------------------------------------------------------------- /src/containers/ip-check/epics.test.ts: -------------------------------------------------------------------------------- 1 | import { ipLookupEpic, triggerLookupEpic } from './epics'; 2 | import { take, toArray } from 'rxjs/operators'; 3 | import * as actions from './actions'; 4 | import { instance, mock, reset, verify, when } from 'ts-mockito'; 5 | import IPAPI from '../../services/ip-api'; 6 | import { from, of } from 'rxjs'; 7 | 8 | describe('ip-check@epics', () => { 9 | describe('triggerLookupEpic', () => { 10 | it('should emit lookup request', async () => { 11 | // Arrange 12 | const expectedActions = [actions.lookupAsync.request()]; 13 | const trigger$ = triggerLookupEpic(null as any, null as any, null as any); 14 | // Act 15 | const result = await trigger$.pipe(take(1), toArray()).toPromise(); 16 | // Assert 17 | expect(result).toEqual(expectedActions); 18 | }); 19 | }); 20 | 21 | describe('ipLookupEpic', () => { 22 | const mockIPAPI = mock(IPAPI); 23 | const ipAPI = instance(mockIPAPI); 24 | const dependencies = { ip: ipAPI }; 25 | 26 | afterEach(() => { 27 | reset(mockIPAPI); 28 | }); 29 | 30 | it('should call ip api and create action with the result', async () => { 31 | // Arrange 32 | const action$: any = from([actions.lookupAsync.request()]); 33 | const state$: any = of({}); 34 | const epic$ = ipLookupEpic(action$, state$, dependencies); 35 | const expectedAction = actions.lookupAsync.success({} as any); 36 | 37 | when(mockIPAPI.lookup()).thenResolve({} as any); 38 | 39 | // Act 40 | const result = await epic$.pipe(toArray()).toPromise(); 41 | 42 | // Assert 43 | expect(result).toEqual([expectedAction]); 44 | verify(mockIPAPI.lookup()).once(); 45 | }); 46 | 47 | it('should call ip api and create action with the error', async () => { 48 | // Arrange 49 | const action$: any = from([actions.lookupAsync.request()]); 50 | const state$: any = of({}); 51 | const epic$ = ipLookupEpic(action$, state$, dependencies); 52 | const expectedError = new Error('unknown'); 53 | const expectedAction = actions.lookupAsync.failure(expectedError); 54 | 55 | when(mockIPAPI.lookup()).thenReject(expectedError); 56 | 57 | // Act 58 | const result = await epic$.pipe(toArray()).toPromise(); 59 | 60 | // Assert 61 | expect(result).toEqual([expectedAction]); 62 | verify(mockIPAPI.lookup()).once(); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/containers/ip-check/epics.ts: -------------------------------------------------------------------------------- 1 | import { RootEpic } from 'MyTypes'; 2 | import { from, interval, of } from 'rxjs'; 3 | import { catchError, filter, map, startWith, switchMap } from 'rxjs/operators'; 4 | import { isActionOf } from 'typesafe-actions'; 5 | import * as actions from './actions'; 6 | 7 | export const ipLookupEpic: RootEpic = (action$, state$, { ip }) => 8 | action$.pipe( 9 | filter(isActionOf(actions.lookupAsync.request)), 10 | switchMap(() => 11 | from(ip.lookup()).pipe( 12 | map(response => actions.lookupAsync.success(response)), 13 | catchError(err => of(actions.lookupAsync.failure(err))) 14 | ) 15 | ) 16 | ); 17 | 18 | export const triggerLookupEpic: RootEpic = () => 19 | interval(60 * 1000).pipe( 20 | startWith(null), 21 | map(() => actions.lookupAsync.request()) 22 | ); 23 | -------------------------------------------------------------------------------- /src/containers/ip-check/index.test.tsx: -------------------------------------------------------------------------------- 1 | const mockIPCheckSelector = jest.fn(); 2 | jest.mock('./selector', () => ({ 3 | __esModule: true, 4 | ipCheckSelector: mockIPCheckSelector 5 | })); 6 | 7 | import React from 'react'; 8 | import ReactTestRenderer from 'react-test-renderer/shallow'; 9 | import configureStore from 'redux-mock-store'; 10 | import IPCheck from './index'; 11 | 12 | describe('', () => { 13 | const mockStore = configureStore([]); 14 | 15 | it('should render and match the snapshot', () => { 16 | mockIPCheckSelector.mockReturnValue({ loading: true, status: { ip: '127.0.0.1', success: true, time: 173 } }); 17 | // @ts-ignore 18 | const component = ; 19 | const renderer = ReactTestRenderer.createRenderer(); 20 | renderer.render(component); 21 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/containers/ip-check/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import IPCheck from '../../components/ip-check'; 3 | import { connect } from 'react-redux'; 4 | import { createSelector } from 'reselect'; 5 | import { ipCheckSelector } from './selector'; 6 | 7 | const dispatchProps = {}; 8 | 9 | const mapStateToProps = createSelector(ipCheckSelector, ipCheck => ipCheck); 10 | 11 | export default connect(mapStateToProps, dispatchProps)(IPCheck); 12 | -------------------------------------------------------------------------------- /src/containers/ip-check/reducer.test.ts: -------------------------------------------------------------------------------- 1 | import reducer from './reducer'; 2 | import * as actions from './actions'; 3 | 4 | describe('ip-check@reducer', () => { 5 | it('should return the initial state', () => { 6 | const state = reducer(undefined, {} as any); 7 | expect(state).toEqual({ loading: true, status: {} }); 8 | }); 9 | 10 | it('should change the loading to true', () => { 11 | const state = reducer({ loading: false, status: {} as any }, actions.lookupAsync.request()); 12 | expect(state).toEqual({ loading: true, status: {} }); 13 | }); 14 | 15 | it('should change the loading to false', () => { 16 | const loadingCompleteActions = [actions.lookupAsync.success({} as any), actions.lookupAsync.failure(new Error())]; 17 | 18 | for (const action of loadingCompleteActions) { 19 | const state = reducer({ loading: true, status: {} as any }, action); 20 | expect(state.loading).toBe(false); 21 | } 22 | }); 23 | 24 | it('should change the status to true for success action', () => { 25 | const action = actions.lookupAsync.success({} as any); 26 | const state = reducer({ loading: true, status: {} as any }, action); 27 | expect(state.loading).toBe(false); 28 | expect(state.status.success).toBe(true); 29 | }); 30 | 31 | it('should change the status to false for failed action', () => { 32 | const action = actions.lookupAsync.failure(new Error()); 33 | const state = reducer({ loading: true, status: {} as any }, action); 34 | expect(state.loading).toBe(false); 35 | expect(state.status.success).toBe(false); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/containers/ip-check/reducer.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, createReducer } from 'typesafe-actions'; 2 | import * as actions from './actions'; 3 | import { combineReducers } from 'redux'; 4 | 5 | type Status = { success: boolean; time: number }; 6 | 7 | type ComponentActions = ActionType; 8 | 9 | const loading = createReducer(true) 10 | .handleAction([actions.lookupAsync.request], () => true) 11 | .handleAction([actions.lookupAsync.success, actions.lookupAsync.failure], () => false); 12 | 13 | const status = createReducer({} as any) 14 | .handleAction(actions.lookupAsync.success, (state, action) => ({ 15 | ip: action.payload.query, 16 | success: true, 17 | time: Date.now() 18 | })) 19 | .handleAction(actions.lookupAsync.failure, (state, action) => ({ success: false, time: Date.now() })); 20 | 21 | export default combineReducers({ loading, status }); 22 | -------------------------------------------------------------------------------- /src/containers/ip-check/selector.test.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from 'MyTypes'; 2 | import { ipCheckSelector } from './selector'; 3 | 4 | describe('ip-check@selector', () => { 5 | describe('ipCheckSelector', () => { 6 | it('should return the state', () => { 7 | const ipCheck = {} as any; 8 | const rootState: Partial = { ipCheck }; 9 | expect(ipCheckSelector(rootState as RootState)).toBe(ipCheck); 10 | }); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/containers/ip-check/selector.ts: -------------------------------------------------------------------------------- 1 | import { RootState } from 'MyTypes'; 2 | 3 | export const ipCheckSelector = (state: RootState) => state.ipCheck; 4 | -------------------------------------------------------------------------------- /src/containers/language-provider/actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from 'typesafe-actions'; 2 | 3 | export const setLocale = createAction(`editor/containers/language-provider/SET_LOCALE`, (locale: string) => locale)(); 4 | -------------------------------------------------------------------------------- /src/containers/language-provider/index.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import ConnectedLanguageProvider, { LanguageProvider } from './index'; 4 | import { defineMessages, FormattedMessage } from 'react-intl'; 5 | import { Provider } from 'react-redux'; 6 | import { combineReducers, createStore } from 'redux'; 7 | import reducer from './reducer'; 8 | 9 | const messages = defineMessages({ 10 | someMessage: { 11 | id: 'some.id', 12 | defaultMessage: 'This is some default message', 13 | de: 'Dies ist eine standardnachricht' 14 | } 15 | }); 16 | 17 | describe('', () => { 18 | it('should render its children', () => { 19 | const children =

Test

; 20 | const { container } = render( 21 | 22 | {children} 23 | 24 | ); 25 | expect(container.querySelector('h1')).not.toBeNull(); 26 | }); 27 | }); 28 | 29 | describe('', () => { 30 | it('should render a 44 |
45 | `; 46 | -------------------------------------------------------------------------------- /src/containers/seller-store-editor/index.test.tsx: -------------------------------------------------------------------------------- 1 | const mockLocaleSelector = jest.fn(); 2 | jest.mock('../language-provider/selector', () => ({ 3 | __esModule: true, 4 | localeSelector: mockLocaleSelector 5 | })); 6 | 7 | import React from 'react'; 8 | import ConnectedSellerStoreEditor, { SellerStoreEditor } from './index'; 9 | import ReactTestRenderer from 'react-test-renderer/shallow'; 10 | import configureStore from 'redux-mock-store'; 11 | 12 | const mockStore = configureStore([]); 13 | 14 | describe('', () => { 15 | afterEach(() => { 16 | mockLocaleSelector.mockReset(); 17 | }); 18 | 19 | it('connected component should render and match the snapshot', () => { 20 | mockLocaleSelector.mockReturnValue('de'); 21 | // @ts-ignore 22 | const component = ; 23 | const renderer = ReactTestRenderer.createRenderer(); 24 | renderer.render(component); 25 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 26 | }); 27 | 28 | it('container should render and match the snapshot', () => { 29 | const props: any = { 30 | locale: 'tr', 31 | setLocale: () => undefined 32 | }; 33 | const component = ; 34 | const renderer = ReactTestRenderer.createRenderer(); 35 | renderer.render(component); 36 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/containers/seller-store-editor/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import * as languageActions from '../language-provider/actions'; 4 | import SellerStoreEditorComponent from '../../components/seller-store-editor'; 5 | import { Route, Switch } from 'react-router-dom'; 6 | import IPCheck from '../ip-check'; 7 | import Counter from '../counter'; 8 | import { appLocales } from '../../i18n'; 9 | import { localeSelector } from '../language-provider/selector'; 10 | import { createSelector } from 'reselect'; 11 | 12 | const dispatchProps = { 13 | setLocale: languageActions.setLocale 14 | }; 15 | 16 | type PropsFromState = { 17 | locale: string; 18 | }; 19 | 20 | type Props = typeof dispatchProps & PropsFromState; 21 | 22 | export const SellerStoreEditor: FC = ({ locale, setLocale }) => { 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | const mapStateToProps = createSelector(localeSelector, locale => ({ locale })); 39 | 40 | function getNextLocale(locale: string) { 41 | const idx = appLocales.indexOf(locale); 42 | const nextIdx = idx === -1 ? 0 : (idx + 1) % appLocales.length; 43 | return appLocales[nextIdx]; 44 | } 45 | 46 | export default connect(mapStateToProps, dispatchProps)(SellerStoreEditor); 47 | -------------------------------------------------------------------------------- /src/i18n.test.ts: -------------------------------------------------------------------------------- 1 | import { formatTranslationMessages } from './i18n'; 2 | 3 | jest.mock('./translations/tr.json', () => ({ 4 | message1: 'default message', 5 | message2: 'default message 2' 6 | })); 7 | 8 | const deTranslationMessages = { 9 | message1: 'standardnachricht', 10 | message2: '' 11 | }; 12 | 13 | describe('i18n', () => { 14 | afterAll(() => { 15 | jest.restoreAllMocks(); 16 | }); 17 | 18 | it('should build only defaults when DEFAULT_LOCALE', () => { 19 | const result = formatTranslationMessages('tr', { a: 'a' }); 20 | 21 | expect(result).toEqual({ a: 'a' }); 22 | }); 23 | 24 | it('should combine default locale and current locale when not DEFAULT_LOCALE', () => { 25 | const result = formatTranslationMessages('', deTranslationMessages); 26 | 27 | expect(result).toEqual({ 28 | message1: 'standardnachricht', 29 | message2: 'default message 2' 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/i18n.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * i18n.js 3 | * 4 | * This will setup the i18n language files and locale data for your app. 5 | * 6 | * IMPORTANT: This file is used by the internal build 7 | * script `extract-intl`, and must use CommonJS module syntax 8 | * You CANNOT use import/export in this file. 9 | */ 10 | import trTranslationMessages from './translations/tr.json'; 11 | import deTranslationMessages from './translations/de.json'; 12 | 13 | export const DEFAULT_LOCALE = 'tr'; 14 | export const appLocales = ['tr', 'de']; 15 | 16 | export const formatTranslationMessages = (locale: string, messages: any) => { 17 | const defaultFormattedMessages: any = 18 | locale !== DEFAULT_LOCALE ? formatTranslationMessages(DEFAULT_LOCALE, trTranslationMessages) : {}; 19 | const flattenFormattedMessages = (formattedMessages: any, key: string) => { 20 | const formattedMessage = 21 | !messages[key] && locale !== DEFAULT_LOCALE ? defaultFormattedMessages[key] : messages[key]; 22 | return Object.assign(formattedMessages, { [key]: formattedMessage }); 23 | }; 24 | return Object.keys(messages).reduce(flattenFormattedMessages, {}); 25 | }; 26 | 27 | export const translationMessages = { 28 | tr: formatTranslationMessages('tr', trTranslationMessages), 29 | de: formatTranslationMessages('de', deTranslationMessages) 30 | }; 31 | -------------------------------------------------------------------------------- /src/polyfill.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-var-requires */ 2 | if (!Intl.PluralRules) { 3 | require('@formatjs/intl-pluralrules/polyfill'); 4 | require('@formatjs/intl-pluralrules/dist/locale-data/tr'); 5 | } 6 | 7 | // @ts-ignore 8 | if (!Intl.RelativeTimeFormat) { 9 | require('@formatjs/intl-relativetimeformat/polyfill'); 10 | require('@formatjs/intl-relativetimeformat/dist/locale-data/de'); 11 | } 12 | /* tslint:enable*/ 13 | -------------------------------------------------------------------------------- /src/register.ts: -------------------------------------------------------------------------------- 1 | import SellerStoreEditorWebComponent from './webcomponent'; 2 | import './polyfill'; 3 | 4 | customElements.define('seller-store-editor', SellerStoreEditorWebComponent); 5 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../../config/model'; 2 | 3 | import IPAPI from './ip-api'; 4 | 5 | export default (config: Config) => ({ 6 | ip: new IPAPI(config.ip) 7 | }); 8 | -------------------------------------------------------------------------------- /src/services/ip-api/config/ip-api-config.ts: -------------------------------------------------------------------------------- 1 | export type IPAPIConfig = { 2 | url: string; 3 | }; 4 | -------------------------------------------------------------------------------- /src/services/ip-api/index.test.ts: -------------------------------------------------------------------------------- 1 | import { IPAPIConfig } from './config/ip-api-config'; 2 | import IPAPI from './index'; 3 | import nock from 'nock'; 4 | 5 | describe('IPAPI', () => { 6 | const ipAPIURL = 'http://ip-api'; 7 | const config: IPAPIConfig = { 8 | url: ipAPIURL 9 | }; 10 | const ipAPI = new IPAPI(config); 11 | 12 | it('should return data when lookup returns 200', async () => { 13 | // Arrange 14 | const response: any = { query: '127.0.0.1' }; 15 | nock(ipAPIURL) 16 | .defaultReplyHeaders({ 17 | 'access-control-allow-origin': '*', 18 | 'access-control-allow-headers': 'authorization' 19 | }) 20 | .options('/json') 21 | .reply(204, {}) 22 | .get('/json') 23 | .reply(200, response); 24 | 25 | // Act 26 | const result = await ipAPI.lookup(); 27 | 28 | // Assert 29 | expect(result).toEqual(response); 30 | }); 31 | 32 | it('should throw when lookup returns non 200', async () => { 33 | // Arrange 34 | nock(ipAPIURL) 35 | .defaultReplyHeaders({ 36 | 'access-control-allow-origin': '*', 37 | 'access-control-allow-headers': 'authorization' 38 | }) 39 | .options('/json') 40 | .reply(204, {}) 41 | .get('/json') 42 | .reply(404, {}); 43 | 44 | // Act 45 | const result = ipAPI.lookup(); 46 | 47 | // Assert 48 | await expect(result).rejects.not.toBeNull(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/services/ip-api/index.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import urlJoin from 'url-join'; 3 | import { IPAPIConfig } from './config/ip-api-config'; 4 | import { IPQueryResponse } from './models/IPQueryResponse'; 5 | 6 | export class IPAPI { 7 | constructor(private readonly config: IPAPIConfig) {} 8 | 9 | private getURL(path: string): string { 10 | return urlJoin(this.config.url, path); 11 | } 12 | 13 | async lookup(): Promise { 14 | const url = this.getURL('/json'); 15 | const response = await axios.get(url); 16 | if (response.status !== 200) { 17 | throw new Error('status code != 200'); 18 | } 19 | return response.data; 20 | } 21 | } 22 | 23 | export default IPAPI; 24 | -------------------------------------------------------------------------------- /src/services/ip-api/models/IPQueryResponse.ts: -------------------------------------------------------------------------------- 1 | export type IPQueryResponse = { 2 | status: 'success'; 3 | country: string; 4 | countryCode: string; 5 | region: string; 6 | regionName: string; 7 | city: string; 8 | zip: string; 9 | lat: number; 10 | lon: number; 11 | timezone: string; 12 | isp: string; 13 | org: string; 14 | as: string; 15 | query: string; 16 | }; 17 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createMemoryHistory } from 'history'; 2 | import { applyMiddleware, createStore } from 'redux'; 3 | import { routerMiddleware } from 'connected-react-router'; 4 | import { createEpicMiddleware } from 'redux-observable'; 5 | import { RootAction, RootState, Services } from 'MyTypes'; 6 | 7 | import rootEpic from './root-epic'; 8 | import { composeEnhancers } from './utils'; 9 | import rootReducer from './root-reducer'; 10 | import services from './services'; 11 | 12 | export const epicMiddleware = createEpicMiddleware({ 13 | dependencies: services 14 | }); 15 | export const history = createMemoryHistory(); 16 | 17 | const middlewareArr = [routerMiddleware(history), epicMiddleware]; 18 | const enhancer = composeEnhancers(applyMiddleware(...middlewareArr)); 19 | 20 | const initialState = {}; 21 | export const store = createStore(rootReducer(history), initialState, enhancer); 22 | 23 | epicMiddleware.run(rootEpic); 24 | 25 | export default store; 26 | -------------------------------------------------------------------------------- /src/store/root-action.ts: -------------------------------------------------------------------------------- 1 | import * as languageActions from '../containers/language-provider/actions'; 2 | import * as counterActions from '../containers/counter/actions'; 3 | import * as ipCheckActions from '../containers/ip-check/actions'; 4 | 5 | export default { 6 | counter: counterActions, 7 | ipCheck: ipCheckActions, 8 | language: languageActions 9 | }; 10 | -------------------------------------------------------------------------------- /src/store/root-epic.ts: -------------------------------------------------------------------------------- 1 | import { combineEpics } from 'redux-observable'; 2 | 3 | import * as ipCheckEpics from '../containers/ip-check/epics'; 4 | 5 | export default combineEpics(...Object.values(ipCheckEpics)); 6 | -------------------------------------------------------------------------------- /src/store/root-reducer.ts: -------------------------------------------------------------------------------- 1 | import { History } from 'history'; 2 | import { combineReducers } from 'redux'; 3 | import { connectRouter } from 'connected-react-router'; 4 | import language from '../containers/language-provider/reducer'; 5 | import counter from '../containers/counter/reducer'; 6 | import ipCheck from '../containers/ip-check/reducer'; 7 | 8 | const rootReducer = (history: History) => 9 | combineReducers({ 10 | counter, 11 | ipCheck, 12 | language, 13 | router: connectRouter(history) 14 | }); 15 | 16 | export default rootReducer; 17 | -------------------------------------------------------------------------------- /src/store/services.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../../config/model'; 2 | import createServices from '../services/index'; 3 | // tslint:disable-next-line:no-var-requires 4 | const config: Config = require('@config').default; 5 | 6 | export default createServices(config); 7 | -------------------------------------------------------------------------------- /src/store/types.d.ts: -------------------------------------------------------------------------------- 1 | import { ActionType, StateType } from 'typesafe-actions'; 2 | import { Epic } from 'redux-observable'; 3 | 4 | declare module 'MyTypes' { 5 | export type Services = typeof import('./services').default; 6 | export type Store = StateType; 7 | export type RootState = StateType>; 8 | export type RootAction = ActionType; 9 | export type RootEpic = Epic; 10 | } 11 | 12 | declare module 'typesafe-actions' { 13 | interface Types { 14 | RootAction: ActionType; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/store/utils.ts: -------------------------------------------------------------------------------- 1 | import { compose } from 'redux'; 2 | 3 | export const composeEnhancers = 4 | (process.env.NODE_ENV === 'development' && window && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose; 5 | -------------------------------------------------------------------------------- /src/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.components.ip-check.loadingText": "IP überprüfen...", 3 | "editor.components.ip-check.failedText": "Ihre IP konnte nicht abgerufen werden. Versuchen Sie, Adblock zu deaktivieren. Letzte Überprüfung: {time}", 4 | "editor.components.ip-check.successText": "Ihre IP: {ip} - Letzte Überprüfung: {time}", 5 | "editor.components.ip-check.switchButtonText": "Seite ändern", 6 | "editor.components.counter.helloText": "Gegenbeispiel", 7 | "editor.components.counter.switchButtonText": "Seite ändern", 8 | "editor.components.counter.incrementButtonText": "Zuwachs", 9 | "editor.components.counter.decrementButtonText": "Dekrementieren", 10 | "editor.components.counter.setButtonText": "Einstellen", 11 | "editor.components.counter.countText": "Aktuelle Zählung ist: {count}" 12 | } 13 | -------------------------------------------------------------------------------- /src/translations/tr.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/webcomponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ConnectedRouter } from 'connected-react-router'; 3 | import { render, unmountComponentAtNode } from 'react-dom'; 4 | // @ts-ignore 5 | import root from 'react-shadow'; 6 | import SellerStoreEditorContainer from './containers/seller-store-editor'; 7 | import { Provider } from 'react-redux'; 8 | // @ts-ignore 9 | import styles from './components/index.scss'; 10 | import LanguageProvider from './containers/language-provider'; 11 | import { translationMessages } from './i18n'; 12 | import { history, store } from './store'; 13 | 14 | export default class SellerStoreEditorWebComponent extends HTMLElement { 15 | private readonly observer: MutationObserver; 16 | 17 | constructor() { 18 | super(); 19 | this.observer = new MutationObserver(() => this.update()); 20 | this.observer.observe(this, { attributes: true }); 21 | } 22 | 23 | connectedCallback() { 24 | this.mount(); 25 | } 26 | 27 | disconnectedCallback() { 28 | this.unmount(); 29 | this.observer.disconnect(); 30 | } 31 | 32 | private update() { 33 | this.unmount(); 34 | this.mount(); 35 | } 36 | 37 | private mount() { 38 | render(this.getComponent(), this); 39 | } 40 | 41 | private unmount() { 42 | unmountComponentAtNode(this); 43 | } 44 | 45 | private getComponent() { 46 | return ( 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "MyTypes": ["./src/store/types.d.ts"] 6 | }, 7 | "outDir": "./dist/", 8 | "sourceMap": true, 9 | "target": "es2015", 10 | "module": "commonjs", 11 | "jsx": "react", 12 | "strict": true, 13 | "esModuleInterop": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "allowSyntheticDefaultImports": true, 16 | "resolveJsonModule": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended", 5 | "tslint-config-prettier" 6 | ], 7 | "linterOptions": { 8 | "exclude": ["**/*.json", "**/node_modules/**/*.{js,ts,tsx}"] 9 | }, 10 | "jsRules": { 11 | "no-unused-expression": true 12 | }, 13 | "rules": { 14 | "quotemark": [true, "single", "avoid-escape", "jsx-double"], 15 | "member-access": [false], 16 | "ordered-imports": [false], 17 | "max-line-length": [true, 120], 18 | "member-ordering": [false], 19 | "interface-name": [false], 20 | "arrow-parens": false, 21 | "object-literal-sort-keys": false, 22 | "no-console": true, 23 | "interface-over-type-literal": false 24 | }, 25 | "rulesDirectory": [] 26 | } 27 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | mode: 'production', 5 | entry: path.join(__dirname, './src/register.ts'), 6 | devtool: "source-map", 7 | resolve: { 8 | extensions: [".ts", ".tsx", ".js"], 9 | alias: { 10 | '@config': path.join(__dirname, `config/${process.env.PROFILE || 'dev'}.ts`) 11 | } 12 | }, 13 | output: { 14 | filename: 'seller-store-editor.min.js', 15 | path: path.resolve(__dirname, './dist/'), 16 | publicPath: '/', 17 | jsonpFunction: "sellerStoreEditorWebpackJsonp" 18 | }, 19 | optimization: { 20 | splitChunks: { 21 | chunks: 'all', 22 | }, 23 | }, 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.ts(x?)$/, 28 | exclude: /node_modules/, 29 | use: [ 30 | { 31 | loader: "ts-loader" 32 | } 33 | ] 34 | }, 35 | { 36 | enforce: "pre", 37 | test: /\.js$/, 38 | loader: "source-map-loader" 39 | }, 40 | { 41 | test: /\.s[ac]ss$/i, 42 | use: [ 43 | 'to-string-loader', 44 | 'css-loader', 45 | 'sass-loader', 46 | ], 47 | } 48 | ] 49 | }, 50 | }; 51 | --------------------------------------------------------------------------------