├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE ├── README.md ├── babel.config.js ├── demo ├── demo-class.jsx ├── demo-functional.jsx └── index.js ├── docs ├── demo.js ├── demo.js.map └── index.html ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── script ├── build ├── cibuild ├── demo ├── lint ├── preversion └── test ├── specs ├── .eslintrc.js ├── __snapshots__ │ └── braintree.spec.jsx.snap ├── braintree.spec.jsx └── setupTests.js ├── src ├── api.js ├── braintree.jsx ├── context.js ├── field.jsx └── index.js └── webpack.config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | "extends": "eslint:recommended", 4 | "parser": "babel-eslint", 5 | "parserOptions": { 6 | "ecmaVersion": 6, 7 | "sourceType": "module" 8 | }, 9 | "env": { 10 | "browser": true, 11 | "amd": true, 12 | "node": true 13 | }, 14 | rules: { 15 | 'no-underscore-dangle': 0, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | yarn-error.log 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "v12.16.1" 4 | cache: 5 | yarn: true 6 | directories: 7 | - node_modules 8 | script: npm run ci 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Nathan Stitt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React components to integrate Braintree hosted fields 2 | 3 | A few small React components to make integrating [Braintree's Hosted Fields](https://developers.braintreepayments.com/guides/hosted-fields/) easier. 4 | 5 | [![Build Status](https://travis-ci.org/nathanstitt/react-braintree-fields.svg?branch=master)](https://travis-ci.org/nathanstitt/react-braintree-fields) 6 | 7 | # See also 8 | 9 | I've also written a vendor agnostic library [PaymentFields](https://github.com/nathanstitt/payment-fields) It's an extension of this library to also support Square and Stripe. You might check that out if you ever think you'll need to support additional processors. 10 | 11 | ## Introduction 12 | 13 | ```javascript 14 | import { Braintree, HostedField } from 'react-braintree-fields'; 15 | class MySillyCheckoutForm extends React.PureComponent { 16 | 17 | function onSubmit() { 18 | // could also obtain a reference to the Braintree wrapper element and call `.tokenize()` 19 | this.getToken({ cardholderName: 'My Order Name' }).then((payload) => { 20 | console.log("nonce=" , payload.nonce) 21 | console.log("device_data", this.device_data) 22 | }) 23 | } 24 | 25 | onCardTypeChange() { 26 | this.setState({ card: (1 === cards.length) ? cards[0].type : '' }); 27 | } 28 | 29 | function onFocus(event) { 30 | console.log("number is focused", event); 31 | } 32 | 33 | onError(err) { 34 | console.warn(err); 35 | this.ccNum.focus(); // focus number field 36 | } 37 | 38 | onAuthorizationSuccess() { 39 | this.setState({ isBraintreeReady : true }); 40 | } 41 | 42 | onDataCollectorInstanceReady(err, dataCollectorInstance) { 43 | if(!err) this.device_data = dataCollectorInstance.deviceData 44 | } 45 | 46 | render() { 47 | return ( 48 | (this.getToken = ref)} 56 | styles={{ 57 | 'input': { 58 | 'font-size': '14px', 59 | 'font-family': 'helvetica, tahoma, calibri, sans-serif', 60 | 'color': '#3a3a3a' 61 | }, 62 | ':focus': { 63 | 'color': 'black' 64 | } 65 | }} 66 | > 67 |
68 | (this.ccNum = ccNum)} /> 69 | 70 | 71 |
72 | 73 |
74 | ); 75 | } 76 | } 77 | ``` 78 | 79 | See [demo site](https://nathanstitt.github.io/react-braintree-fields/) for a working example. It renders [demo/demo-class.jsx](demo/demo-class.jsx) There is also a [functional version](demo/demo-functional.jsx) available that illustrates how to work around the issue of storing a function reference using setState that was discovered in [issue #20](https://github.com/nathanstitt/react-braintree-fields/issues/20) 80 | 81 | ## Braintree Component 82 | 83 | Props: 84 | * authorization: Required, either a [tokenization key or a client token](https://developers.braintreepayments.com/guides/hosted-fields/setup-and-integration/) 85 | * onAuthorizationSuccess: Function that is called after Braintree successfully initializes the form 86 | * styles: Object containing [valid field styles](https://braintree.github.io/braintree-web/3.11.1/module-braintree-web_hosted-fields.html#.create) 87 | * onError: Function that is called if an Braintree error is encountered. 88 | * getTokenRef: A function that will be called once Braintree the API is initialized. It will be called with a function that can be used to initiate tokenization. 89 | * The tokenization function will return a Promise which will be either resolved or rejected. If resolved, the promise payload will contain an object with the `nonce` and other data from Braintree. If rejected it will return the error notice from Braintree 90 | * onDataCollectorInstanceReady: A function that will be called with the results of `Braintree.dataCollector.create`. This can be used in conjunction with [Braintree's Advanced Fraud Tools](https://developers.braintreepayments.com/guides/advanced-fraud-tools/client-side/javascript/v3). 91 | 92 | ## HostedField Component 93 | 94 | Props: 95 | * type: Required, one of: 96 | - 'number', 'expirationDate', 'expirationMonth', 'expirationYear', 'cvv', 'postalCode' 97 | * onBlur 98 | * onFocus 99 | * onEmpty 100 | * onNotEmpty 101 | * onValidityChange 102 | * onCardTypeChange - accepted on any field, but will only be called by type="number" 103 | * placeholder - A string to that will be displayed in the input while it's empty 104 | * formatInput 105 | * maxlength, 106 | * minlength 107 | * select 108 | * options - an object containing any other valid Braintree options such as maskInput 109 | * prefill 110 | 111 | See the [Braintree api docs](https://braintree.github.io/braintree-web/3.33.0/module-braintree-web_hosted-fields.html#%7Efield) for more details 112 | 113 | Fields also have "focus" and "clear" methods. These may be called by obtaining a reference to the field. 114 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-react', 4 | [ 5 | '@babel/preset-env', { 6 | loose: true, 7 | targets: { 8 | esmodules: true, 9 | }, 10 | }, 11 | ], 12 | ], 13 | plugins: [ 14 | ['@babel/plugin-proposal-class-properties', { loose: true }], 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /demo/demo-class.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Braintree, HostedField } from '../src/index'; 3 | 4 | class Demo extends React.PureComponent { 5 | 6 | constructor(props) { 7 | super(props); 8 | this.numberField = React.createRef(); 9 | this.braintree = React.createRef(); 10 | [ 11 | 'onError', 12 | 'getToken', 13 | 'onCardTypeChange', 14 | 'onAuthorizationSuccess', 15 | ].forEach(prop => (this[prop] = this[prop].bind(this))); 16 | } 17 | 18 | state = {} 19 | 20 | onError(error) { 21 | this.setState({ error: error.message || String(error) }); 22 | } 23 | 24 | getToken() { 25 | this.tokenize().then( 26 | token => this.setState({ token, error: null }), 27 | ).catch(error => this.onError(error)); 28 | } 29 | 30 | onCardTypeChange({ cards }) { 31 | if (1 === cards.length) { 32 | const [card] = cards; 33 | 34 | this.setState({ card: card.type }); 35 | 36 | if (card.code && card.code.name) { 37 | this.cvvField.setPlaceholder(card.code.name); 38 | } else { 39 | this.cvvField.setPlaceholder('CVV'); 40 | } 41 | } else { 42 | this.setState({ card: '' }); 43 | this.cvvField.setPlaceholder('CVV'); 44 | } 45 | } 46 | 47 | state = { 48 | numberFocused: false, 49 | } 50 | 51 | componentDidMount() { 52 | this.setState({ authorization: 'sandbox_g42y39zw_348pk9cgf3bgyw2b' }); 53 | } 54 | 55 | renderResult(title, obj) { 56 | if (!obj) { return null; } 57 | return ( 58 |
59 | {title}: 60 |
{JSON.stringify(obj, null, 4)}
61 |
62 | ); 63 | } 64 | 65 | onAuthorizationSuccess() { 66 | this.numberField.current.focus(); 67 | } 68 | 69 | render() { 70 | return ( 71 |
72 |

Braintree Hosted Fields Demo

73 | {this.renderResult('Error', this.state.error)} 74 | {this.renderResult('Token', this.state.token)} 75 | 76 | (this.tokenize = t)} 82 | onCardTypeChange={this.onCardTypeChange} 83 | styles={{ 84 | input: { 85 | 'font-size': '14px', 86 | 'font-family': 'helvetica, tahoma, calibri, sans-serif', 87 | color: '#7d6b6b', 88 | }, 89 | ':focus': { 90 | color: 'black', 91 | }, 92 | }} 93 | > 94 |
95 | Number: 96 | this.setState({ numberFocused: false })} 99 | onFocus={() => this.setState({ numberFocused: true })} 100 | className={this.state.numberFocused ? 'focused' : ''} 101 | prefill="4111 1111 1111 1111" 102 | ref={this.numberField} 103 | /> 104 |

Card type: {this.state.card}

105 | Name: 106 | 107 | Date: 108 | 109 | Month: 110 | 111 | Year: 112 | 113 | CVV: 114 | { this.cvvField = cvvField; }} /> 115 | Zip: 116 | 117 |
118 |
119 |
120 | 121 |
122 |
123 | ); 124 | } 125 | 126 | } 127 | 128 | export default Demo; 129 | -------------------------------------------------------------------------------- /demo/demo-functional.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Braintree, HostedField } from '../src/index'; 3 | 4 | const Demo = () => { 5 | const [tokenize, setTokenizeFunc] = React.useState(); 6 | const [cardType, setCardType] = React.useState(''); 7 | const [error, setError] = React.useState(null); 8 | const [token, setToken] = React.useState(null); 9 | const [focusedFieldName, setFocusedField] = React.useState(''); 10 | const numberField = React.useRef(); 11 | const cvvField = React.useRef(); 12 | const cardholderNameField = React.useRef(); 13 | 14 | const onAuthorizationSuccess = () => { 15 | numberField.current.focus(); 16 | }; 17 | 18 | const onDataCollectorInstanceReady = (collector) => { 19 | // DO SOMETHING with Braintree collector as needed 20 | }; 21 | 22 | const handleError = (newError) => { 23 | setError(newError.message || String(newError)); 24 | }; 25 | 26 | const onFieldBlur = (field, event) => setFocusedField(''); 27 | const onFieldFocus = (field, event) => setFocusedField(event.emittedBy); 28 | 29 | const onCardTypeChange = ({ cards }) => { 30 | if (1 === cards.length) { 31 | const [card] = cards; 32 | 33 | setCardType(card.type); 34 | 35 | if (card.code && card.code.name) { 36 | cvvField.current.setPlaceholder(card.code.name); 37 | } else { 38 | cvvField.current.setPlaceholder('CVV'); 39 | } 40 | } else { 41 | setCardType(''); 42 | cvvField.current.setPlaceholder('CVV'); 43 | } 44 | }; 45 | 46 | const getToken = () => { 47 | tokenize() 48 | .then(setToken) 49 | .catch(handleError); 50 | }; 51 | 52 | const renderResult = (title, obj) => { 53 | if (!obj) { return null; } 54 | return ( 55 |
56 | {title}: 57 |
{JSON.stringify(obj, null, 4)}
58 |
59 | ); 60 | }; 61 | 62 | return ( 63 |
64 | setTokenizeFunc(() => ref)} 72 | styles={{ 73 | input: { 74 | 'font-size': 'inherit', 75 | }, 76 | ':focus': { 77 | color: 'blue', 78 | }, 79 | }} 80 | > 81 | {renderResult('Error', error)} 82 | {renderResult('Token', token)} 83 | 84 |
85 | Number: 86 | 94 |

Card type: {cardType}

95 | Name: 96 | 104 | Date: 105 | 111 | Month: 112 | 113 | Year: 114 | 115 | CVV: 116 | 117 | Zip: 118 | 119 |
120 | 121 |
122 |
123 | 124 |
125 |
126 | ); 127 | }; 128 | 129 | export default Demo; 130 | -------------------------------------------------------------------------------- /demo/index.js: -------------------------------------------------------------------------------- 1 | import { render } from 'react-dom'; 2 | import React from 'react'; 3 | import { Braintree, HostedField } from '../src/index'; 4 | import Demo from './demo-class.jsx' 5 | //import Demo from './demo-functional.jsx' 6 | 7 | render(React.createElement(Demo), document.getElementById('root')); 8 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | React Braintree Hosted Fields Demo 5 | 6 | 31 | 32 | 33 | 34 | Source code for demo is located at github 35 |
36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/y5/944f45_151d7bmfwyqwkmt7c0000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | // collectCoverage: false, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | // coverageDirectory: null, 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: null, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: null, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: null, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: null, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // An array of directory names to be searched recursively up from the requiring module's location 64 | // moduleDirectories: [ 65 | // "node_modules" 66 | // ], 67 | 68 | // An array of file extensions your modules use 69 | // moduleFileExtensions: [ 70 | // "js", 71 | // "json", 72 | // "jsx", 73 | // "ts", 74 | // "tsx", 75 | // "node" 76 | // ], 77 | 78 | // A map from regular expressions to module names that allow to stub out resources with a single module 79 | // moduleNameMapper: {}, 80 | 81 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 82 | // modulePathIgnorePatterns: [], 83 | 84 | // Activates notifications for test results 85 | // notify: false, 86 | 87 | // An enum that specifies notification mode. Requires { notify: true } 88 | // notifyMode: "failure-change", 89 | 90 | // A preset that is used as a base for Jest's configuration 91 | // preset: null, 92 | 93 | // Run tests from one or more projects 94 | // projects: null, 95 | 96 | // Use this configuration option to add custom reporters to Jest 97 | // reporters: undefined, 98 | 99 | // Automatically reset mock state between every test 100 | // resetMocks: false, 101 | 102 | // Reset the module registry before running each individual test 103 | // resetModules: false, 104 | 105 | // A path to a custom resolver 106 | // resolver: null, 107 | 108 | // Automatically restore mock state between every test 109 | // restoreMocks: false, 110 | 111 | // The root directory that Jest should scan for tests and modules within 112 | // rootDir: null, 113 | 114 | // A list of paths to directories that Jest should use to search for files in 115 | // roots: [ 116 | // "" 117 | // ], 118 | 119 | // Allows you to use a custom runner instead of Jest's default test runner 120 | // runner: "jest-runner", 121 | 122 | // The paths to modules that run some code to configure or set up the testing environment before each test 123 | // setupFiles: [], 124 | 125 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 126 | setupFilesAfterEnv: [ 127 | './specs/setupTests.js' 128 | ], 129 | 130 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 131 | // snapshotSerializers: [], 132 | 133 | // The test environment that will be used for testing 134 | // testEnvironment: "jest-environment-jsdom", 135 | 136 | // Options that will be passed to the testEnvironment 137 | // testEnvironmentOptions: {}, 138 | 139 | // Adds a location field to test results 140 | // testLocationInResults: false, 141 | 142 | // The glob patterns Jest uses to detect test files 143 | // testMatch: [ 144 | // "**/__tests__/**/*.[jt]s?(x)", 145 | // "**/?(*.)+(spec|test).[tj]s?(x)" 146 | // ], 147 | 148 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 149 | // testPathIgnorePatterns: [ 150 | // "/node_modules/" 151 | // ], 152 | 153 | // The regexp pattern or array of patterns that Jest uses to detect test files 154 | // testRegex: [], 155 | 156 | // This option allows the use of a custom results processor 157 | // testResultsProcessor: null, 158 | 159 | // This option allows use of a custom test runner 160 | // testRunner: "jasmine2", 161 | 162 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 163 | // testURL: "http://localhost", 164 | 165 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 166 | // timers: "real", 167 | 168 | // A map from regular expressions to paths to transformers 169 | // transform: null, 170 | 171 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 172 | // transformIgnorePatterns: [ 173 | // "/node_modules/" 174 | // ], 175 | 176 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 177 | // unmockedModulePathPatterns: undefined, 178 | 179 | // Indicates whether each individual test should be reported during the run 180 | // verbose: null, 181 | 182 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 183 | // watchPathIgnorePatterns: [], 184 | 185 | // Whether to use watchman for file crawling 186 | // watchman: true, 187 | }; 188 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-braintree-fields", 3 | "version": "2.0.0", 4 | "description": "React component for braintree hosted fields", 5 | "browser": "dist/build.full.js", 6 | "main": "dist/build.full.js", 7 | "module": "dist/build.module.js", 8 | "jsnext:main": "dist/build.module.js", 9 | "repository": "https://github.com/nathanstitt/react-braintree-fields.git", 10 | "author": "Nathan Stitt", 11 | "license": "MIT", 12 | "peerDependencies": { 13 | "react": "16 - 18" 14 | }, 15 | "scripts": { 16 | "test": "jest", 17 | "build": "./script/build", 18 | "demo": "$(npm bin)/webpack serve", 19 | "ci": "./script/cibuild", 20 | "preversion": "./script/preversion" 21 | }, 22 | "keywords": [ 23 | "react", 24 | "braintree", 25 | "hosted fields" 26 | ], 27 | "devDependencies": { 28 | "@babel/core": "^7.13.15", 29 | "@babel/plugin-proposal-class-properties": "^7.13.0", 30 | "@babel/preset-env": "^7.13.15", 31 | "@babel/preset-react": "^7.13.13", 32 | "@testing-library/jest-dom": "^5.16.5", 33 | "@testing-library/react": "^14.0.0", 34 | "babel-core": "^7.0.0-bridge.0", 35 | "babel-loader": "^8.2.2", 36 | "babelrc-rollup": "^3.0.0", 37 | "enzyme": "^3.11.0", 38 | "enzyme-adapter-react-16": "^1.15.6", 39 | "eslint": "^7.24.0", 40 | "eslint-config-argosity": "^3.1.0", 41 | "eslint-plugin-react": "^7.23.2", 42 | "jest": "^26.6.3", 43 | "jest-cli": "^26.6.3", 44 | "pretty": "^2.0.0", 45 | "prop-types": "^15.7.2", 46 | "raf": "^3.4.1", 47 | "react": "^18.0.0", 48 | "react-dom": "^18.0.0", 49 | "react-test-renderer": "^18.2.0", 50 | "rollup": "^2.45.2", 51 | "rollup-plugin-babel": "^4.4.0", 52 | "webpack": "^5.32.0", 53 | "webpack-cli": "^4.6.0", 54 | "webpack-dev-server": "^3.11.2" 55 | }, 56 | "dependencies": { 57 | "braintree-web": "^3.90.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | 3 | const pkg = require('./package.json'); 4 | 5 | const external = ['react', 'prop-types', 'braintree-web/data-collector', 'braintree-web/client', 'braintree-web/hosted-fields']; 6 | 7 | const plugins = [ 8 | babel(), 9 | ]; 10 | 11 | const globals = { 12 | react: 'React', 13 | invariant: 'invariant', 14 | 'prop-types': 'PropTypes', 15 | 'braintree-web/client': 'Braintree', 16 | 'braintree-web/hosted-fields': 'BraintreeHostedFields', 17 | 'braintree-web/data-collector': 'BraintreeDataCollector', 18 | }; 19 | 20 | const input = 'src/index.js'; 21 | 22 | export default [ 23 | { 24 | input, 25 | plugins, 26 | external, 27 | output: { 28 | format: 'umd', 29 | name: 'react-braintree-fields', 30 | sourcemap: true, 31 | file: pkg.browser, 32 | globals, 33 | }, 34 | }, { 35 | input, 36 | plugins, 37 | external, 38 | output: { 39 | format: 'es', 40 | sourcemap: true, 41 | file: pkg.module, 42 | globals, 43 | }, 44 | }, 45 | ]; 46 | -------------------------------------------------------------------------------- /script/build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | script/lint 6 | 7 | script/test 8 | 9 | $(npm bin)/rollup -c 10 | 11 | $(npm bin)/webpack 12 | -------------------------------------------------------------------------------- /script/cibuild: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # script/cibuild: Setup environment for CI to run tests. This is primarily 4 | # designed to run on the continuous integration server. 5 | 6 | set -e 7 | 8 | 9 | script/lint 10 | 11 | # run tests. 12 | script/test 13 | -------------------------------------------------------------------------------- /script/demo: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | $(npm bin)/webpack-dev-server --hot --inline 4 | -------------------------------------------------------------------------------- /script/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # script/lint: Check JS files for code that doesn't follow the rulez 4 | 5 | set -e 6 | 7 | $(npm bin)/eslint src/ specs/ 8 | -------------------------------------------------------------------------------- /script/preversion: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if [ -n "$(git status --porcelain)" ]; then 6 | echo "tree is dirty, please commit changes before building" 7 | exit 1 8 | fi 9 | 10 | script/lint 11 | 12 | script/test 13 | 14 | $(npm bin)/rollup -c 15 | 16 | $(npm bin)/webpack 17 | 18 | if [ -n "$(git status --porcelain)" ]; then 19 | echo "Adding docs" 20 | git add docs/* 21 | git commit -m 'Update demo files for ghpages' 22 | fi 23 | 24 | 25 | 26 | echo "Done" 27 | -------------------------------------------------------------------------------- /script/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # script/test: Run test suite for application. Optionally pass in a path to an 4 | # individual test file to run a single test. 5 | 6 | 7 | set -e 8 | 9 | cd "$(dirname "$0")/.." 10 | 11 | [ -z "$DEBUG" ] || set -x 12 | 13 | echo "Tests started at…" 14 | date "+%H:%M:%S" 15 | 16 | $(npm bin)/jest $@ 17 | -------------------------------------------------------------------------------- /specs/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "globals": { 3 | "beforeEach": false, 4 | "afterEach": false, 5 | "describe": false, 6 | "xdescribe": false, 7 | "fdescribe": false, 8 | "it": false, 9 | "xit": false, 10 | "fit": false, 11 | "expect": false, 12 | "shallow": false, 13 | "console": false, 14 | "jest": false, 15 | }, 16 | "rules": { 17 | "no-console": 0, 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /specs/__snapshots__/braintree.spec.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Braintree hosted fields can set token ref after render 1`] = ` 4 | Array [ 5 | Array [ 6 | Object { 7 | "client": [MockFunction], 8 | "fields": Object { 9 | "cardholderName": Object { 10 | "formatInput": undefined, 11 | "maxlength": undefined, 12 | "minlength": undefined, 13 | "placeholder": "name", 14 | "prefill": undefined, 15 | "select": undefined, 16 | "selector": "#braintree-field-wrapper-1", 17 | }, 18 | "cvv": Object { 19 | "formatInput": undefined, 20 | "maxlength": undefined, 21 | "minlength": undefined, 22 | "placeholder": "cvv", 23 | "prefill": undefined, 24 | "select": undefined, 25 | "selector": "#braintree-field-wrapper-6", 26 | }, 27 | "expirationDate": Object { 28 | "formatInput": undefined, 29 | "maxlength": undefined, 30 | "minlength": undefined, 31 | "placeholder": "date", 32 | "prefill": undefined, 33 | "select": undefined, 34 | "selector": "#braintree-field-wrapper-3", 35 | }, 36 | "expirationMonth": Object { 37 | "formatInput": undefined, 38 | "maxlength": undefined, 39 | "minlength": undefined, 40 | "placeholder": "month", 41 | "prefill": undefined, 42 | "select": undefined, 43 | "selector": "#braintree-field-wrapper-4", 44 | }, 45 | "expirationYear": Object { 46 | "formatInput": undefined, 47 | "maxlength": undefined, 48 | "minlength": undefined, 49 | "placeholder": "year", 50 | "prefill": undefined, 51 | "select": undefined, 52 | "selector": "#braintree-field-wrapper-5", 53 | }, 54 | "number": Object { 55 | "formatInput": undefined, 56 | "maxlength": undefined, 57 | "minlength": undefined, 58 | "placeholder": "cc #", 59 | "prefill": undefined, 60 | "select": undefined, 61 | "selector": "#braintree-field-wrapper-2", 62 | }, 63 | "postalCode": Object { 64 | "formatInput": undefined, 65 | "maxlength": undefined, 66 | "minlength": undefined, 67 | "placeholder": "zip", 68 | "prefill": undefined, 69 | "select": undefined, 70 | "selector": "#braintree-field-wrapper-7", 71 | }, 72 | }, 73 | "styles": Object { 74 | "foo": "bar", 75 | }, 76 | }, 77 | [Function], 78 | ], 79 | ] 80 | `; 81 | 82 | exports[`Braintree hosted fields can set token ref during render 1`] = ` 83 | Array [ 84 | Array [ 85 | Object { 86 | "client": [MockFunction], 87 | "fields": Object { 88 | "cardholderName": Object { 89 | "formatInput": undefined, 90 | "maxlength": undefined, 91 | "minlength": undefined, 92 | "placeholder": "name", 93 | "prefill": undefined, 94 | "select": undefined, 95 | "selector": "#braintree-field-wrapper-1", 96 | }, 97 | "cvv": Object { 98 | "formatInput": undefined, 99 | "maxlength": undefined, 100 | "minlength": undefined, 101 | "placeholder": "cvv", 102 | "prefill": undefined, 103 | "select": undefined, 104 | "selector": "#braintree-field-wrapper-6", 105 | }, 106 | "expirationDate": Object { 107 | "formatInput": undefined, 108 | "maxlength": undefined, 109 | "minlength": undefined, 110 | "placeholder": "date", 111 | "prefill": undefined, 112 | "select": undefined, 113 | "selector": "#braintree-field-wrapper-3", 114 | }, 115 | "expirationMonth": Object { 116 | "formatInput": undefined, 117 | "maxlength": undefined, 118 | "minlength": undefined, 119 | "placeholder": "month", 120 | "prefill": undefined, 121 | "select": undefined, 122 | "selector": "#braintree-field-wrapper-4", 123 | }, 124 | "expirationYear": Object { 125 | "formatInput": undefined, 126 | "maxlength": undefined, 127 | "minlength": undefined, 128 | "placeholder": "year", 129 | "prefill": undefined, 130 | "select": undefined, 131 | "selector": "#braintree-field-wrapper-5", 132 | }, 133 | "number": Object { 134 | "formatInput": undefined, 135 | "maxlength": undefined, 136 | "minlength": undefined, 137 | "placeholder": "cc #", 138 | "prefill": undefined, 139 | "select": undefined, 140 | "selector": "#braintree-field-wrapper-2", 141 | }, 142 | "postalCode": Object { 143 | "formatInput": undefined, 144 | "maxlength": undefined, 145 | "minlength": undefined, 146 | "placeholder": "zip", 147 | "prefill": undefined, 148 | "select": undefined, 149 | "selector": "#braintree-field-wrapper-7", 150 | }, 151 | }, 152 | "styles": Object {}, 153 | }, 154 | [Function], 155 | ], 156 | ] 157 | `; 158 | 159 | exports[`Braintree hosted fields renders and matches snapshot 1`] = ` 160 | "
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
" 169 | `; 170 | -------------------------------------------------------------------------------- /specs/braintree.spec.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from 'react-dom/client' 3 | import { act, render } from "@testing-library/react"; 4 | //import userEvent from '@testing-library/user-event' 5 | import "@testing-library/jest-dom"; 6 | import pretty from "pretty"; 7 | // import React from 'react'; 8 | // import { mount, shallow } from 'enzyme'; 9 | // import renderer from 'react-test-renderer'; 10 | import BraintreeClient from "braintree-web/client"; 11 | import HostedFields from "braintree-web/hosted-fields"; 12 | import { Braintree, HostedField } from "../src/index.js"; 13 | 14 | jest.mock("braintree-web/client"); 15 | jest.mock("braintree-web/hosted-fields"); 16 | jest.useFakeTimers(); 17 | 18 | let getToken; 19 | 20 | const buildTree = ({ 21 | styles = {}, 22 | props = {}, 23 | getInstance, 24 | authorization = "sandbox_g42y39zw_348pk9cgf3bgyw2b", 25 | onAuthorizationSuccess = () => {}, 26 | } = {}) => ( 27 | (getToken = ref)} 34 | > 35 | 40 | 41 | 46 | 51 | 56 | 57 | 58 | 59 | ); 60 | 61 | describe("Braintree hosted fields", () => { 62 | beforeEach(() => { 63 | HostedFields.create = jest.fn((args, cb) => 64 | cb(null, { 65 | on: jest.fn(), 66 | teardown: jest.fn(), 67 | 68 | }) 69 | ); 70 | 71 | }); 72 | afterEach(() => { 73 | BraintreeClient.create.mockClear(); 74 | HostedFields.create.mockClear(); 75 | }); 76 | 77 | it("renders and matches snapshot", () => { 78 | const container = document.createElement("div"); 79 | document.body.appendChild(container); 80 | let root 81 | act(() => { 82 | root = createRoot(container) 83 | root.render(buildTree()) 84 | }); 85 | expect(pretty(container.innerHTML)).toMatchSnapshot() 86 | act(() => root.unmount()) 87 | }); 88 | 89 | it("registers when mounted", () => { 90 | render(buildTree()); 91 | 92 | jest.runAllTimers(); 93 | expect(BraintreeClient.create).toHaveBeenCalledWith( 94 | expect.objectContaining({ 95 | authorization: "sandbox_g42y39zw_348pk9cgf3bgyw2b", 96 | }), 97 | expect.anything() 98 | ); 99 | }); 100 | 101 | it("registers an onAuthorizationSuccess callback when passed", () => { 102 | BraintreeClient.create = jest.fn((args, cb) => cb(null, jest.fn())); 103 | 104 | const onAuthorizationSuccess = jest.fn(); 105 | render(buildTree({ onAuthorizationSuccess })); 106 | jest.runAllTimers(); 107 | expect(onAuthorizationSuccess.mock.calls.length).toEqual(1); 108 | }); 109 | 110 | it("sets styles", () => { 111 | let instance; 112 | const styles = { input: { "font-size": "18px" } }; 113 | render(buildTree({ styles, getInstance: (ref) => { instance = ref } })); 114 | expect(instance.api.styles).toEqual(styles); 115 | }); 116 | 117 | it("forwards events to fields", () => { 118 | const onFocus = jest.fn(); 119 | let instance; 120 | render(buildTree({ props: { number: { onFocus } }, getInstance: (ref) => { 121 | instance = ref 122 | } }) ); 123 | 124 | instance.api.onFieldEvent("onFocus", { 125 | emittedBy: "number", 126 | fields: { number: { foo: "bar" } }, 127 | }); 128 | expect(onFocus).toHaveBeenCalledWith({ foo: "bar" }, expect.anything()); 129 | }); 130 | 131 | it("can set token ref during render", () => { 132 | const clientInstance = jest.fn(); 133 | BraintreeClient.create = jest.fn((args, cb) => cb(null, clientInstance)); 134 | render(buildTree({ authorization: "onetwothree" })); 135 | jest.runAllTimers(); 136 | expect(BraintreeClient.create).toHaveBeenCalledWith( 137 | { authorization: "onetwothree" }, 138 | expect.any(Function) 139 | ); 140 | expect(HostedFields.create.mock.calls).toMatchSnapshot(); 141 | }); 142 | 143 | it("can set token ref after render", () => { 144 | const styles = { foo: "bar" }; 145 | 146 | const { rerender } = render(buildTree({ styles, authorization: "" })); 147 | jest.runAllTimers(); 148 | 149 | const clientInstance = jest.fn(); 150 | BraintreeClient.create = jest.fn((args, cb) => cb(null, clientInstance)); 151 | expect(BraintreeClient.create).not.toHaveBeenCalled(); 152 | expect(HostedFields.create).not.toHaveBeenCalled(); 153 | 154 | rerender(buildTree({ styles, authorization: "blahblahblah" })); 155 | expect(BraintreeClient.create).toHaveBeenCalledWith( 156 | { authorization: "blahblahblah" }, 157 | expect.any(Function) 158 | ); 159 | expect(HostedFields.create.mock.calls).toMatchSnapshot(); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /specs/setupTests.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme' 2 | import ReactSixteenAdapter from 'enzyme-adapter-react-16' 3 | 4 | configure({ adapter: new ReactSixteenAdapter() }) 5 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | import Braintree from 'braintree-web/client' 2 | import HostedFields from 'braintree-web/hosted-fields' 3 | import BraintreeDataCollector from 'braintree-web/data-collector' 4 | import BraintreeThreeDSecure from 'braintree-web/three-d-secure' 5 | 6 | function cap(string) { 7 | return string.charAt(0).toUpperCase() + string.slice(1) 8 | } 9 | 10 | export default class BraintreeClientApi { 11 | 12 | fields = Object.create(null); 13 | 14 | _nextFieldId = 0; 15 | 16 | fieldHandlers = Object.create(null); 17 | 18 | constructor({ 19 | authorization, styles, onAuthorizationSuccess, ...callbacks 20 | }) { 21 | this.styles = styles || {} 22 | this.wrapperHandlers = callbacks || {} 23 | this.setAuthorization(authorization, onAuthorizationSuccess) 24 | } 25 | 26 | setAuthorization(authorization, onAuthorizationSuccess) { 27 | if (!authorization && this.authorization) { 28 | this.teardown() 29 | } else if (authorization && authorization !== this.authorization) { 30 | // fields have not yet checked in, delay setting so they can register 31 | if (0 === Object.keys(this.fields).length && !this.pendingAuthTimer) { 32 | this.pendingAuthTimer = setTimeout(() => { 33 | this.pendingAuthTimer = null 34 | this.setAuthorization(authorization, onAuthorizationSuccess) 35 | }, 5) 36 | return 37 | } 38 | 39 | if (this.authorization) { this.teardown() } 40 | this.authorization = authorization 41 | Braintree.create({ authorization }, (err, clientInstance) => { 42 | if (err) { 43 | this.onError(err) 44 | } else { 45 | this.create(clientInstance, onAuthorizationSuccess) 46 | 47 | if (this.wrapperHandlers.onThreeDSecureReady) { 48 | BraintreeThreeDSecure.create({ 49 | client: clientInstance, 50 | version: 2, 51 | }, this.wrapperHandlers.onThreeDSecureReady) 52 | } 53 | 54 | if (this.wrapperHandlers.onDataCollectorInstanceReady) { 55 | BraintreeDataCollector.create({ 56 | client: clientInstance, 57 | kount: true, 58 | }, this.wrapperHandlers.onDataCollectorInstanceReady) 59 | } 60 | } 61 | }) 62 | } 63 | } 64 | 65 | nextFieldId() { 66 | this._nextFieldId += 1 67 | return this._nextFieldId 68 | } 69 | 70 | onError(err) { 71 | if (!err) { return } 72 | if (this.wrapperHandlers.onError) { this.wrapperHandlers.onError(err) } 73 | } 74 | 75 | create(client, onAuthorizationSuccess) { 76 | this.client = client 77 | HostedFields.create({ 78 | client, 79 | styles: this.styles, 80 | fields: this.fields, 81 | }, (err, hostedFields) => { 82 | if (err) { 83 | this.onError(err) 84 | return 85 | } 86 | this.hostedFields = hostedFields; 87 | [ 88 | 'blur', 'focus', 'empty', 'notEmpty', 89 | 'cardTypeChange', 'validityChange', 90 | ].forEach((eventName) => { 91 | hostedFields.on(eventName, ev => this.onFieldEvent(`on${cap(eventName)}`, ev)) 92 | }) 93 | this.onError(err) 94 | 95 | if (onAuthorizationSuccess) { 96 | onAuthorizationSuccess() 97 | } 98 | }) 99 | } 100 | 101 | teardown() { 102 | if (this.hostedFields) { this.hostedFields.teardown() } 103 | if (this.pendingAuthTimer) { 104 | clearTimeout(this.pendingAuthTimer) 105 | this.pendingAuthTimer = null 106 | } 107 | } 108 | 109 | checkInField({ 110 | formatInput, 111 | maxlength, 112 | minlength, 113 | placeholder, 114 | select, 115 | type, 116 | prefill, 117 | rejectUnsupportedCards, 118 | id = `braintree-field-wrapper-${this.nextFieldId()}`, 119 | options = {}, 120 | ...handlers 121 | }) { 122 | const onRenderComplete = () => { 123 | this.fieldHandlers[type] = handlers 124 | this.fields[type] = { 125 | formatInput, 126 | maxlength, 127 | minlength, 128 | placeholder, 129 | select, 130 | prefill, 131 | selector: `#${id}`, 132 | ...options, 133 | } 134 | if (('number' === type) && rejectUnsupportedCards) { 135 | this.fields.number.rejectUnsupportedCards = true 136 | } 137 | } 138 | return [id, onRenderComplete] 139 | } 140 | 141 | focusField(fieldType, cb) { 142 | this.hostedFields.focus(fieldType, cb) 143 | } 144 | 145 | clearField(fieldType, cb) { 146 | this.hostedFields.clear(fieldType, cb) 147 | } 148 | 149 | setAttribute(fieldType, name, value) { 150 | this.hostedFields.setAttribute({ 151 | field: fieldType, 152 | attribute: name, 153 | value, 154 | }) 155 | } 156 | 157 | onFieldEvent(eventName, event) { 158 | const fieldHandlers = this.fieldHandlers[event.emittedBy] 159 | if (fieldHandlers && fieldHandlers[eventName]) { 160 | fieldHandlers[eventName](event.fields[event.emittedBy], event) 161 | } 162 | if (this.wrapperHandlers[eventName]) { 163 | this.wrapperHandlers[eventName](event) 164 | } 165 | } 166 | 167 | tokenize(options = {}) { 168 | return new Promise((resolve, reject) => { // eslint-disable-line no-undef 169 | this.hostedFields.tokenize(options, (err, payload) => { 170 | if (err) { 171 | this.onError(err) 172 | reject(err) 173 | } else { 174 | resolve(payload) 175 | } 176 | }) 177 | }) 178 | } 179 | 180 | } 181 | -------------------------------------------------------------------------------- /src/braintree.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Api from './api'; 4 | import { Context } from './context' 5 | 6 | 7 | export default class Braintree extends React.Component { 8 | 9 | static propTypes = { 10 | children: PropTypes.node.isRequired, 11 | onAuthorizationSuccess: PropTypes.func, 12 | authorization: PropTypes.string, 13 | getTokenRef: PropTypes.func, 14 | onValidityChange: PropTypes.func, 15 | onCardTypeChange: PropTypes.func, 16 | onError: PropTypes.func, 17 | styles: PropTypes.object, 18 | className: PropTypes.string, 19 | tagName: PropTypes.string, 20 | } 21 | 22 | static defaultProps = { 23 | tagName: 'div', 24 | } 25 | 26 | constructor(props) { 27 | super(props); 28 | this.api = new Api(props); 29 | this.contextValue = { braintreeApi: this.api } 30 | } 31 | 32 | componentDidMount() { 33 | this.api.setAuthorization(this.props.authorization, this.props.onAuthorizationSuccess); 34 | if (this.props.getTokenRef) { 35 | this.props.getTokenRef(this.api.tokenize.bind(this.api)); 36 | } 37 | } 38 | 39 | componentWillUnmount() { 40 | this.api.teardown(); 41 | } 42 | 43 | componentDidUpdate() { 44 | this.api.setAuthorization(this.props.authorization, this.props.onAuthorizationSuccess); 45 | } 46 | 47 | tokenize(options) { 48 | return this.api.tokenize(options); 49 | } 50 | 51 | render() { 52 | const { className: providedClass, tagName: Tag } = this.props; 53 | let className = 'braintree-hosted-fields-wrapper'; 54 | if (providedClass) { className += ` ${providedClass}`; } 55 | 56 | return ( 57 | 58 | 59 | {this.props.children} 60 | 61 | 62 | ); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Context = React.createContext({ braintreeApi: null }); 4 | -------------------------------------------------------------------------------- /src/field.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Context } from './context' 4 | 5 | export default class BraintreeHostedField extends React.Component { 6 | 7 | static propTypes = { 8 | type: PropTypes.oneOf([ 9 | 'number', 'expirationDate', 'expirationMonth', 'expirationYear', 'cvv', 'postalCode', 'cardholderName', 10 | ]).isRequired, 11 | id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 12 | placeholder: PropTypes.string, 13 | className: PropTypes.string, 14 | onCardTypeChange: PropTypes.func, 15 | onValidityChange: PropTypes.func, 16 | onNotEmpty: PropTypes.func, 17 | onFocus: PropTypes.func, 18 | onEmpty: PropTypes.func, 19 | onBlur: PropTypes.func, 20 | prefill: PropTypes.string, 21 | } 22 | 23 | static contextType = Context 24 | 25 | state = {} 26 | 27 | focus() { 28 | this.context.braintreeApi.focusField(this.props.type); 29 | } 30 | 31 | clear() { 32 | this.context.braintreeApi.clearField(this.props.type); 33 | } 34 | 35 | setPlaceholder(text) { 36 | this.context.braintreeApi.setAttribute(this.props.type, 'placeholder', text); 37 | } 38 | 39 | componentDidMount() { 40 | const [ fieldId, onRenderComplete ] = this.context.braintreeApi.checkInField(this.props); 41 | this.setState({ fieldId }, onRenderComplete); 42 | } 43 | 44 | get className() { 45 | const list = ['braintree-hosted-field']; 46 | if (this.props.className) { list.push(this.props.className); } 47 | return list.join(' '); 48 | } 49 | 50 | render() { 51 | const { fieldId } = this.state; 52 | if (!fieldId) { return null; } 53 | 54 | return
; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Braintree from './braintree.jsx' 2 | import HostedField from './field.jsx' 3 | 4 | export { 5 | Braintree, 6 | HostedField, 7 | } 8 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | const config = { 4 | mode: 'development', 5 | entry: { 6 | demo: __dirname + '/demo/index.js', 7 | }, 8 | output: { 9 | path: __dirname + '/docs', 10 | publicPath: '/docs', 11 | filename: 'demo.js', 12 | }, 13 | module: { 14 | rules: [ 15 | { 16 | loader: 'babel-loader', 17 | test: /\.jsx?$/, 18 | exclude: /node_modules/, 19 | }, 20 | ], 21 | }, 22 | devtool: 'source-map', 23 | plugins: [ 24 | new webpack.DefinePlugin({ 25 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'), 26 | }), 27 | ], 28 | devServer: { 29 | hot: false, 30 | port: 2222, 31 | historyApiFallback: true, 32 | }, 33 | }; 34 | 35 | // console.log(config) 36 | 37 | module.exports = config; 38 | --------------------------------------------------------------------------------