├── .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 | [](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 | Submit
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 | Get nonce token
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 | Get nonce token
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 |
--------------------------------------------------------------------------------