├── .eslintrc.js
├── .gitignore
├── .npmignore
├── .travis.yml
├── CHANGELOG.md
├── README.md
├── babel.config.js
├── demo.jsx
├── docs
├── demo.js
├── demo.js.map
└── index.html
├── jest.config.js
├── package.json
├── rollup.config.js
├── script
├── cibuild
├── demo
├── lint
├── preversion
└── test
├── specs
├── .eslintrc.js
├── __snapshots__
│ └── payment-fields.spec.js.snap
├── braintree-mocks.js
├── invert.js
├── jest-setup.js
├── payment-fields.spec.js
├── square-mocks.js
├── stripe-mocks.js
├── testing-component-factory.jsx
└── vendor-mock.js
├── src
├── api.js
├── braintree.js
├── context.js
├── field.jsx
├── payment-fields.jsx
├── square.js
├── stripe.js
├── util.js
└── vendors.js
├── webpack.config.js
└── yarn.lock
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "extends": "argosity",
3 | "parser": "babel-eslint",
4 | "settings": {
5 | "react": {
6 | "pragma": "React", // Pragma to use, default to "React"
7 | "version": "16.0", // React version, default to the latest React stable release
8 | },
9 | },
10 | rules: {
11 | 'no-underscore-dangle': 0,
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/.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 | - "node"
4 | cache:
5 | yarn: true
6 | directories:
7 | - node_modules
8 | script: npm run ci
9 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change Log
2 |
3 | ## [v0.6.0](https://github.com/nathanstitt/payment-fields/tree/v0.6.0) (2018-02-08) [diff](https://github.com/nathanstitt/payment-fields/compare/v0.5.1...v0.6.0)
4 | * Compile individual scripts in `dist` directory
5 | * Update code to be IE 11 compatible
6 |
7 | ## [v0.5.1](https://github.com/nathanstitt/payment-fields/tree/v0.5.1) (2018-01-14) [diff](https://github.com/nathanstitt/payment-fields/compare/v0.5.0...v0.5.1)
8 | * Export Vendors
9 |
10 | ## [v0.5.0](https://github.com/nathanstitt/payment-fields/tree/v0.5.0) (2017-12-06) [diff](https://github.com/nathanstitt/payment-fields/compare/v0.4.0...v0.5.0)
11 | * Add "isPotentiallyValid" property to events
12 |
13 | ## [v0.4.0](https://github.com/nathanstitt/payment-fields/tree/v0.4.0) (2017-11-28) [diff](https://github.com/nathanstitt/payment-fields/compare/v0.3.0...v0.4.0)
14 | * Use Errors for rejected Promise
15 | * Check vendor property is one of the supported types (Braintree, Square, Stripe)
16 | * only emit form valid if Square reports field's are "completelyValid"
17 |
18 | ## [v0.3.0](https://github.com/nathanstitt/payment-fields/tree/v0.3.0) (2017-10-01) [diff](https://github.com/nathanstitt/payment-fields/compare/v0.2.1...v0.3.0)
19 | * only emit form valid if Square reports field's are "completelyValid"
20 | * Fix validity event on Square
21 | * Better styles
22 |
23 | ## [v0.2.1](https://github.com/nathanstitt/payment-fields/tree/v0.2.1) (2017-10-01) [diff](https://github.com/nathanstitt/payment-fields/compare/v0.2.0...v0.2.1)
24 | * Only set authorization after wrapper is rendered and fields have checked in
25 |
26 | ## [v0.2.0](https://github.com/nathanstitt/payment-fields/tree/v0.2.0) (2017-10-01) [diff](https://github.com/nathanstitt/payment-fields/compare/v0.1.0...v0.2.0)
27 | * Initial working version from https://github.com/nathanstitt/react-braintree-fields
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Integrate Braintree/Stripe/Square payment fields
2 |
3 | A React component to make integrating [Braintree's Hosted Fields](https://developers.braintreepayments.com/guides/hosted-fields/), [Stripe's Elements](https://stripe.com/docs/elements) and [Square's Payment Form](https://docs.connect.squareup.com/articles/adding-payment-form) easier.
4 |
5 | Care is taken so the API is (nearly) identical across the vendors.
6 |
7 | This is intended for a Saas that allows customers to use their own payment processor, as long as it uses the newish "hosted iframe" approach.
8 |
9 | further docs to be written
10 |
11 | [](https://travis-ci.org/nathanstitt/payment-fields)
12 |
13 | See [demo site](https://nathanstitt.github.io/payment-fields/) for a working example. It renders [demo.jsx](demo.jsx)
14 |
15 | ## Example
16 |
17 | Note: methods are removed for brevity and this isn't fully copy & pastable. For a working example see [demo.jsx](demo.jsx)
18 |
19 | ```javascript
20 |
21 | import React from 'react';
22 | import PaymentFields from 'payment-fields';
23 | import PropTypes from 'prop-types';
24 |
25 | class PaymentForm extends React.PureComponent {
26 |
27 | static propTypes = {
28 | vendor: PropTypes.oneOf(['Square', 'Stripe', 'Braintree']).isRequired,
29 | authorization: PropTypes.String.isRequired,
30 | }
31 |
32 | render() {
33 | return (
34 | this.setState({ valid: ev.isValid })}
39 | onCardTypeChange={(c) => this.setState({ card: c.brand })}
40 | onReady={this.onFieldsReady}
41 | styles={{
42 | base: {
43 | 'font-size': '24px',
44 | 'font-family': 'helvetica, tahoma, calibri, sans-serif',
45 | padding: '6px',
46 | color: '#7d6b6b',
47 | },
48 | focus: { color: '#000000' },
49 | valid: { color: '#00bf00' },
50 | invalid: { color: '#a00000' },
51 | }}
52 | >
53 | Form is Valid: {this.state.isValid ? '👍' : '👎'}
54 | Card number ({this.state.card}):
55 |
63 | Date:
64 | CVV:
65 | Zip:
66 |
67 | );
68 | }
69 |
70 | }
71 |
72 | ```
73 |
74 | ## PaymentFields Component
75 |
76 | Props:
77 | * vendor: Required, one of Braintree, Square, or Stripe
78 | * authorization: Required, the string key that corresponds to:
79 | * Braintree: calls it "authorization"
80 | * Square: "applicationId"
81 | * Stripe: the Api Key for Stripe Elements
82 | * onReady: function called once form fields are initialized and ready for input
83 | * onValidityChange: function that is called whenever the card validity changes. May be called repeatedly even if the validity is the same as the previous call. Will be passed a single object with a `isValid` property. The object may have other type specific properties as well.
84 | * onCardTypeChange: Called as soon as the card type is known and whenever it changes. Passed a single object with a `brand` property. The object may have other type specific properties as well.
85 | * onError: A function called whenever an error occurs, typically during tokenization but some vendors (Square at least) will also call it when the fields fail to initialize.
86 | * styles: A object that contains 'base', 'focus', 'valid', and 'invalid' properties. The `PaymentFields` component will convert the styles to match each vendor's method of specifying them and attempt to find the lowest common denominator. `color` and `font-size` are universally supported.
87 | * passThroughStyles: For when the `styles` property doesn't offer enough control. Anything specified here will be passed through to the vendor specific api in place of the `styles`.
88 | * tagName: which element to use as a wrapper element. Defaults to `div`
89 | * className: a className to set on the wrapper element, it's applied in addition to `payment-fields-wrapper`
90 |
91 | ## PaymentFields.Field Component
92 |
93 | Props:
94 | * type: Required, one of 'cardNumber', 'expirationDate', 'cvv', 'postalCode' Depending on fraud settings, some vendors do not require postalCode.
95 | * placeholder: What should be displayed in the field when it's empty and is not focused
96 | * className: a className to set on the placeholder element, some vendors will replace the placeholder with an iframe, while others will render the iframe inside the placeholder. All vendors retain the className property though so it's safe to use this for some styling.
97 | * onValidityChange: A function called when the field's validity changes. Like the onValidityChange on the main PaymentFields wrapper, may be called repeatedly with the same status
98 | * onFocus: A function called when the field is focused. Will be called with the vendor specific event
99 | * onBlur: A function called when the field loses focus. Will be called with the vendor specific event, as well as a `isValid` property that indicates if the field is valid, and `isPotentiallyValid` which is set if the input is possibily valid but still incomplete.
100 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | const config = {
2 | presets: [
3 | [
4 | '@babel/preset-env', {
5 | useBuiltIns: 'entry',
6 | corejs: 2,
7 | targets: '> 1% in US',
8 | },
9 | ], [
10 | '@babel/preset-react', {
11 |
12 | },
13 | ],
14 | ],
15 | plugins: [
16 | '@babel/plugin-proposal-class-properties',
17 | '@babel/plugin-syntax-dynamic-import',
18 | ],
19 | };
20 |
21 | module.exports = config;
22 |
--------------------------------------------------------------------------------
/demo.jsx:
--------------------------------------------------------------------------------
1 | import { render } from 'react-dom';
2 | import React from 'react';
3 |
4 | import Vendors from './src/vendors';
5 | import PaymentFields from './src/payment-fields.jsx';
6 |
7 | export default class PaymentFieldsDemo extends React.PureComponent {
8 |
9 | constructor(props) {
10 | super(props);
11 | [
12 | 'logEvent',
13 | 'getToken',
14 | 'onError',
15 | 'onTypeChange',
16 | 'onFieldsReady',
17 | 'onCardTypeChange',
18 | 'onValidityChange',
19 | 'setAuthorization',
20 | 'onNumberValid',
21 | ].forEach((prop) => { this[prop] = this[prop].bind(this); });
22 | this.state = {
23 | type: 'Stripe',
24 | valid: false,
25 | log: [],
26 | isEnabled: false,
27 | };
28 | }
29 |
30 | logEvent(ev) {
31 | let msg = this.state.type;
32 | if (ev.type) { msg += ` : ${ev.type}`; }
33 | if (ev.field) { msg += ` : ${ev.field}`; }
34 | const { event: _, ...attrs } = ev;
35 | this.setState({
36 | log: [`${msg} : ${JSON.stringify(attrs)}`].concat(this.state.log),
37 | });
38 | }
39 |
40 | onNumberValid(ev) {
41 | this.setState({ numberIsValid: ev.isValid })
42 | }
43 |
44 | onTypeChange(ev) {
45 | this.inputRef.value = '';
46 | this.logEvent({ type: 'teardown' });
47 | this.setState({
48 | tokenize: null, valid: false, ready: false, type: ev.target.value, authorization: '',
49 | });
50 | }
51 |
52 | onError(error) {
53 | this.logEvent(error);
54 | }
55 |
56 | getToken() {
57 | this.state.tokenize().then(
58 | ev => this.logEvent(ev),
59 | ).catch(
60 | error => this.logEvent({ error }),
61 | );
62 | }
63 |
64 | onCardTypeChange(card) {
65 | this.logEvent(card);
66 | this.setState({ card: card.brand });
67 | }
68 |
69 | onFieldsReady({ tokenize }) {
70 | this.logEvent({ type: 'fields ready' });
71 | this.setState({ ready: true, tokenize });
72 | }
73 |
74 | onValidityChange(ev) {
75 | this.logEvent(ev);
76 | this.setState({ valid: ev.isValid });
77 | }
78 |
79 | setAuthorization() {
80 | this.setState({ authorization: this.inputRef.value });
81 | }
82 |
83 | renderFields() {
84 | if (!this.state.authorization) { return null; }
85 | return (
86 |
105 |
106 |
Valid: {this.state.valid ? '👍' : '👎'}
107 | Number: {this.state.numberIsValid ? '👍' : '👎'}
108 |
116 |
Card type: {this.state.card}
117 | Date:
118 |
119 | CVV:
120 |
121 | Zip:
122 |
123 |
124 |
125 |
129 | Get nonce token
130 |
131 |
132 |
133 | );
134 | }
135 |
136 | render() {
137 | const { type } = this.state;
138 |
139 | return (
140 |
141 |
142 | {Object.keys(Vendors).map(k => (
143 |
144 | {k}
148 | ))}
149 |
150 |
Payments Field Demo for: {type}
151 |
152 | Authorization:
153 | { this.inputRef = r; }}
155 | type="text"
156 | className="authorization"
157 | />
158 |
159 | Render
160 |
161 |
162 |
163 | {this.renderFields()}
164 |
165 |
166 | {this.state.log.map((msg, i) => {msg} )}
167 |
168 |
169 | );
170 | }
171 |
172 | }
173 |
174 | render( , document.getElementById('root'));
175 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | React Braintree Hosted Fields Demo
5 |
6 |
40 |
41 |
42 |
43 | Source code for demo is located at github
44 |
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | // jest.config.js
2 | module.exports = {
3 | testURL: 'http://localhost:3000/',
4 | setupFilesAfterEnv: [
5 | 'specs/jest-setup.js',
6 | ],
7 | };
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "payment-fields",
3 | "version": "0.7.1",
4 | "description": "React component for Braintree/Stripe/Square payment fields",
5 | "main": "dist/build.full.js",
6 | "module": "dist/build.module.js",
7 | "jsnext:main": "dist/build.module.js",
8 | "repository": "https://github.com/nathanstitt/payment-fields.git",
9 | "author": "Nathan Stitt",
10 | "license": "MIT",
11 | "peerDependencies": {
12 | "prop-types": ">=15.7",
13 | "react": ">=16.9"
14 | },
15 | "scripts": {
16 | "test": "$(npm bin)/jest",
17 | "ci": "./script/cibuild",
18 | "preversion": "./script/preversion"
19 | },
20 | "keywords": [
21 | "react",
22 | "braintree",
23 | "square",
24 | "stripe",
25 | "iframe",
26 | "hosted fields",
27 | "credit card",
28 | "payments"
29 | ],
30 | "devDependencies": {
31 | "@babel/core": "^7.5.5",
32 | "@babel/node": "^7.5.5",
33 | "@babel/plugin-proposal-decorators": "^7.4.4",
34 | "@babel/plugin-proposal-export-default-from": "^7.5.2",
35 | "@babel/plugin-syntax-dynamic-import": "^7.2.0",
36 | "@babel/plugin-transform-runtime": "^7.5.5",
37 | "@babel/preset-env": "^7.5.5",
38 | "@babel/preset-react": "^7.0.0",
39 | "@babel/runtime": "^7.5.5",
40 | "babel-core": "7.0.0-bridge.0",
41 | "enzyme": "^3.10.0",
42 | "eslint": "^6.1.0",
43 | "eslint-config-argosity": "^1.7.0",
44 | "eslint-plugin-react": "^7.14.3",
45 | "jest": "^24.8.0",
46 | "jest-cli": "^24.8.0",
47 | "react": "^16.8.3",
48 | "react-addons-test-utils": "^15.6.2",
49 | "react-dom": "^16.9.0",
50 | "react-test-renderer": "^16.9.0",
51 | "rollup": "^1.19.4",
52 | "rollup-plugin-babel": "^4.3.3",
53 | "rollup-plugin-node-resolve": "^5.2.0",
54 | "webpack": "^4.39.1",
55 | "webpack-dev-server": "^3.7.2"
56 | },
57 | "dependencies": {
58 | "@babel/plugin-proposal-class-properties": "^7.5.5",
59 | "@babel/plugin-syntax-dynamic-import": "^7.2.0",
60 | "babel-eslint": "^10.0.2",
61 | "babel-loader": "^8.0.6",
62 | "babel-plugin-transform-runtime": "^6.23.0",
63 | "enzyme-adapter-react-16": "^1.14.0",
64 | "eslint-plugin-import": "^2.18.2",
65 | "eslint-plugin-jsx-a11y": "^6.2.3",
66 | "loadjs": "^3.6.1",
67 | "webpack-cli": "^3.3.6"
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import babel from 'rollup-plugin-babel';
2 | const pkg = require('./package.json');
3 | const plugins = [
4 | babel({ exclude: 'node_modules/**' }),
5 | ];
6 |
7 | const input = './src/payment-fields.jsx';
8 | const external = ['react', 'loadjs', 'prop-types'];
9 | const globals = {
10 | react: 'React',
11 | loadjs: 'loadjs',
12 | invariant: 'invariant',
13 | 'prop-types': 'PropTypes',
14 | };
15 |
16 | export default {
17 | input,
18 | plugins,
19 | external,
20 | output: [
21 | {
22 | file: pkg.main,
23 | format: 'umd',
24 | name: 'payment-fields',
25 | moduleName: 'payment-fields',
26 | sourceMap: true,
27 | globals,
28 | },
29 | {
30 | file: pkg.module,
31 | format: 'es',
32 | sourceMap: true,
33 | globals,
34 | },
35 | ],
36 | };
37 |
--------------------------------------------------------------------------------
/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 |
14 | $(npm bin)/jest $@
15 |
--------------------------------------------------------------------------------
/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__/payment-fields.spec.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Braintree Api renders and matches snapshot 1`] = `
4 |
24 | `;
25 |
26 | exports[`Square Api renders and matches snapshot 1`] = `
27 |
47 | `;
48 |
49 | exports[`Stripe Api renders and matches snapshot 1`] = `
50 |
70 | `;
71 |
--------------------------------------------------------------------------------
/specs/braintree-mocks.js:
--------------------------------------------------------------------------------
1 | import loadjs from 'loadjs';
2 | import VendorMock from './vendor-mock';
3 | import { EVENTS_MAP, TYPES_MAP } from '../src/braintree';
4 | import invert from './invert';
5 |
6 | jest.mock('loadjs');
7 |
8 | const INVERTED_TYPES_MAP = invert(TYPES_MAP);
9 |
10 | export default class BraintreeMocks extends VendorMock {
11 |
12 | install(mock) {
13 | this.mocks = {
14 | client: {
15 | create: jest.fn((opts, cb) => cb(null, mock.mocks.hostedFields)),
16 | },
17 | hostedFields: {
18 | on: jest.fn(),
19 | create: jest.fn((opts, cb) => cb(null, mock.mocks.hostedFields)),
20 | tokenize: jest.fn((o, cb) => cb(null, 'bt-token')),
21 | },
22 | };
23 | global.braintree = this.mocks;
24 | }
25 |
26 | reset() {
27 | global.braintree = undefined;
28 | }
29 |
30 | expectInitialized({ authorization, styles = {} }) {
31 | expect(loadjs).toHaveBeenCalledWith(
32 | [
33 | 'https://js.braintreegateway.com/web/3.50.0/js/client.js',
34 | 'https://js.braintreegateway.com/web/3.50.0/js/hosted-fields.js',
35 | ],
36 | expect.anything(),
37 | );
38 | expect(global.braintree.client.create).toHaveBeenCalledWith(
39 | { authorization }, expect.anything(),
40 | );
41 | expect(global.braintree.hostedFields.create).toHaveBeenCalledWith(
42 | expect.objectContaining({
43 | styles: { input: styles.base },
44 | }), expect.anything(),
45 | );
46 | }
47 |
48 | emitFocusEvent(field) {
49 | const emittedBy = INVERTED_TYPES_MAP[field] || field;
50 | this.getCb('focus')({
51 | emittedBy,
52 | type: 'onFocus',
53 | fields: {
54 | [`${emittedBy}`]: { isValid: false, isPotentiallyValid: true },
55 | },
56 | });
57 | }
58 |
59 | emitBlurEvent(field) {
60 | const emittedBy = INVERTED_TYPES_MAP[field] || field;
61 | this.getCb('blur')({
62 | emittedBy,
63 | type: 'onBlur',
64 | fields: {
65 | [`${emittedBy}`]: { isValid: false, isPotentiallyValid: true },
66 | },
67 | });
68 | }
69 |
70 | emitValid(val) {
71 | this.getCb('validityChange')({
72 | emittedBy: 'number',
73 | fields: {
74 | number: { isValid: val },
75 | expirationDate: { isValid: val },
76 | cvv: { isValid: val },
77 | postalCode: { isValid: val },
78 | },
79 | });
80 | }
81 |
82 | getCb(evName) {
83 | const call = this.mocks.hostedFields.on.mock.calls.find((c => c[0] === evName));
84 | return call ? call[1] : null;
85 | }
86 |
87 | expectValidToken(token) {
88 | expect(token).toEqual({ token: 'bt-token' });
89 | }
90 |
91 | }
92 |
--------------------------------------------------------------------------------
/specs/invert.js:
--------------------------------------------------------------------------------
1 | export default function invert(obj) {
2 | return Object.keys(obj).reduce((inverted, key) => {
3 | inverted[obj[key]] = key; // eslint-disable-line no-param-reassign
4 | return inverted;
5 | }, {});
6 | }
7 |
--------------------------------------------------------------------------------
/specs/jest-setup.js:
--------------------------------------------------------------------------------
1 | // setup file
2 | import { configure } from 'enzyme';
3 | import Adapter from 'enzyme-adapter-react-16';
4 |
5 | configure({ adapter: new Adapter() });
6 |
--------------------------------------------------------------------------------
/specs/payment-fields.spec.js:
--------------------------------------------------------------------------------
1 | import { mount } from 'enzyme';
2 | import renderer from 'react-test-renderer';
3 | import Braintree from './braintree-mocks';
4 | import Square from './square-mocks.js';
5 | import Stripe from './stripe-mocks.js';
6 | import TestingComponent from './testing-component-factory.jsx';
7 | import { resetIDCounter } from '../src/api';
8 |
9 | const Mocks = {
10 | Braintree,
11 | Square,
12 | Stripe,
13 | };
14 |
15 | [
16 | 'Stripe',
17 | 'Square',
18 | 'Braintree',
19 | ].forEach((vendor) => {
20 | const mountComponent = props => mount(TestingComponent.build({ vendor, ...props }));
21 |
22 | describe(`${vendor} Api`, () => {
23 | let mocks;
24 | const authorization = `${vendor}-123456`;
25 |
26 | beforeEach(() => {
27 | mocks = new Mocks[vendor]();
28 | });
29 |
30 | afterEach(() => {
31 | mocks.reset();
32 | resetIDCounter();
33 | });
34 |
35 | it('renders and matches snapshot', () => {
36 | expect(renderer.create(
37 | TestingComponent.build({ vendor, authorization }),
38 | )).toMatchSnapshot();
39 | });
40 |
41 | it('sets properties', () => {
42 | const styles = { base: { 'font-size': '18px' } };
43 | mountComponent({ authorization, styles });
44 | mocks.expectInitialized({ authorization, styles });
45 | });
46 |
47 | it('forwards events to fields', () => {
48 | const onFocus = jest.fn();
49 | const onBlur = jest.fn();
50 | const onValidityChange = jest.fn();
51 | const fieldTypes = ['cardNumber', 'expirationDate', 'cvv', 'postalCode'];
52 | const fields = {};
53 | fieldTypes.forEach((f) => { fields[f] = { onFocus, onBlur }; });
54 | mountComponent({
55 | authorization,
56 | onValidityChange,
57 | props: fields,
58 | });
59 |
60 | fieldTypes.forEach((f) => {
61 | mocks.emitFocusEvent(f);
62 | expect(onFocus).toHaveBeenCalledWith(
63 | expect.objectContaining({
64 | field: f, type: 'onFocus', isValid: false, isPotentiallyValid: true,
65 | }),
66 | );
67 |
68 | mocks.emitBlurEvent(f);
69 | expect(onBlur).toHaveBeenCalledWith(
70 | expect.objectContaining({
71 | field: f, type: 'onBlur', isValid: false, isPotentiallyValid: true,
72 | }),
73 | );
74 | onFocus.mockReset();
75 | onBlur.mockReset();
76 | });
77 |
78 | mocks.emitValid(true);
79 | expect(onValidityChange).toHaveBeenCalledWith(
80 | expect.objectContaining({ isValid: true }),
81 | );
82 | onValidityChange.mockReset();
83 | mocks.emitValid(false);
84 | expect(onValidityChange).toHaveBeenCalledWith(
85 | expect.objectContaining({ isValid: false }),
86 | );
87 | });
88 |
89 | it('can generate token', () => {
90 | const onReady = jest.fn();
91 | mountComponent({ authorization, onReady });
92 | expect(onReady).toHaveBeenCalledWith(expect.objectContaining({
93 | tokenize: expect.anything(),
94 | }));
95 | return onReady
96 | .mock.calls[0][0].tokenize()
97 | .then(token => mocks.expectValidToken(token));
98 | });
99 | });
100 | });
101 |
--------------------------------------------------------------------------------
/specs/square-mocks.js:
--------------------------------------------------------------------------------
1 | import loadjs from 'loadjs';
2 | import VendorMock from './vendor-mock';
3 | import { EVENTS_MAP } from '../src/square';
4 | import invert from './invert';
5 | import { camelCaseKeys } from '../src/util';
6 |
7 | jest.mock('loadjs');
8 |
9 | const INVERTED_EVENTS_MAP = invert(EVENTS_MAP);
10 |
11 | export default class SquareMocks extends VendorMock {
12 |
13 | install() {
14 | const mock = this;
15 | class SqMock {
16 |
17 | constructor(options) {
18 | this.options = options;
19 | this.options.callbacks.paymentFormLoaded();
20 | }
21 |
22 | build = jest.fn(() => { mock.mock = this; })
23 |
24 | requestCardNonce = jest.fn(() => {
25 | this.options.callbacks.cardNonceResponseReceived(null, 'square-12345', {});
26 | });
27 |
28 | }
29 | global.SqPaymentForm = SqMock;
30 | }
31 |
32 | expectInitialized({ authorization, styles = {} }) {
33 | expect(loadjs).toHaveBeenCalledWith([
34 | 'https://js.squareup.com/v2/paymentform',
35 | ], expect.anything());
36 | expect(this.mock.options.applicationId).toEqual(authorization);
37 | expect(this.mock.options.inputStyles[0]).toEqual(camelCaseKeys(styles.base));
38 | expect(this.mock.build).toHaveBeenCalled();
39 | }
40 |
41 | emitFocusEvent(field) {
42 | this.mock.options.callbacks.inputEventReceived({
43 | field,
44 | eventType: INVERTED_EVENTS_MAP.onFocus,
45 | previousState: { isCompletelyValid: false, isPotentiallyValid: true },
46 | currentState: { isCompletelyValid: false, isPotentiallyValid: true },
47 | });
48 | }
49 |
50 | emitBlurEvent(field) {
51 | this.mock.options.callbacks.inputEventReceived({
52 | field,
53 | eventType: INVERTED_EVENTS_MAP.onBlur,
54 | previousState: { isCompletelyValid: false, isPotentiallyValid: true },
55 | currentState: { isCompletelyValid: false, isPotentiallyValid: true },
56 | });
57 | }
58 |
59 | emitValid(val) {
60 | this.mock.options.callbacks.inputEventReceived({
61 | field: 'postalCode',
62 | eventType: INVERTED_EVENTS_MAP.onBlur,
63 | currentState: { isCompletelyValid: val, isPotentiallyValid: true },
64 | previousState: { isCompletelyValid: !val, isPotentiallyValid: true },
65 | });
66 | }
67 |
68 |
69 | expectValidToken(token) {
70 | expect(token).toMatchObject({ token: 'square-12345' });
71 | }
72 |
73 | reset() {
74 | global.SqPaymentForm = undefined;
75 | this.mock = undefined;
76 | }
77 |
78 | }
79 |
--------------------------------------------------------------------------------
/specs/stripe-mocks.js:
--------------------------------------------------------------------------------
1 | import loadjs from 'loadjs';
2 | import VendorMock from './vendor-mock';
3 | import { camelCaseKeys } from '../src/util';
4 | import { TYPES_MAP } from '../src/stripe';
5 |
6 | jest.mock('loadjs');
7 |
8 |
9 | export default class StripeMocks extends VendorMock {
10 |
11 | install() {
12 | const mock = this;
13 | mock.fields = Object.create(null);
14 | class ElementMock {
15 |
16 | constructor(options) { this.options = options; }
17 |
18 | listeners = {}
19 |
20 | mount = jest.fn()
21 |
22 | addEventListener = jest.fn((eventName, cb) => {
23 | if (!this.listeners[eventName]) {
24 | this.listeners[eventName] = [];
25 | }
26 | this.listeners[eventName].push(cb);
27 | if ('ready' === eventName) { cb(); }
28 | })
29 |
30 | }
31 |
32 | class ElmentsMock {
33 |
34 | create = jest.fn((type, options) => {
35 | mock.fields[type] = new ElementMock(options);
36 | return mock.fields[type];
37 | });
38 |
39 | }
40 |
41 | global.Stripe = jest.fn(() => ({
42 | createToken: jest.fn(() => Promise.resolve({
43 | id: 'stripe-12345', object: 'token', card: { id: 'card_BSiaK89MCCSEwX' },
44 | })),
45 | elements: jest.fn(() => {
46 | mock.elements = new ElmentsMock(this);
47 | return mock.elements;
48 | }),
49 | }));
50 | }
51 |
52 | expectInitialized({ authorization, styles = {} }) {
53 | expect(loadjs).toHaveBeenCalledWith([
54 | 'https://js.stripe.com/v3/',
55 | ], expect.anything());
56 | expect(global.Stripe).toHaveBeenCalledWith(authorization);
57 | const fields = Object.keys(this.fields);
58 | expect(fields).toEqual(
59 | ['cardNumber', 'cardExpiry', 'cardCvc', 'postalCode'],
60 | );
61 | fields.forEach((f) => {
62 | expect(this.fields[f].mount).toHaveBeenCalled();
63 | expect(this.fields[f].options.style.base).toEqual(
64 | { ...camelCaseKeys(styles.base), ':focus': {} },
65 | );
66 | });
67 | }
68 |
69 | emitFocusEvent(f) {
70 | const field = TYPES_MAP[f] || f;
71 | this.fields[field].listeners.focus.forEach(cb => cb({ type: 'focus' }));
72 | }
73 |
74 | emitBlurEvent(f) {
75 | const field = TYPES_MAP[f] || f;
76 | this.fields[field].listeners.blur.forEach(cb => cb({ type: 'blur' }));
77 | }
78 |
79 | emitValid(v) {
80 | Object.keys(this.fields).forEach((f) => {
81 | this.fields[f].listeners.change.forEach(cb => cb({ error: !v }));
82 | });
83 | }
84 |
85 | expectValidToken(token) {
86 | expect(token).toMatchObject({ id: 'stripe-12345' });
87 | }
88 |
89 | reset() {
90 | global.Stripe = undefined;
91 | this.mocks = undefined;
92 | }
93 |
94 | }
95 |
--------------------------------------------------------------------------------
/specs/testing-component-factory.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'; // eslint-disable-line no-unused-vars
2 | import PaymentFields from '../src/payment-fields.jsx';
3 |
4 | export default {
5 |
6 | build({
7 | vendor,
8 | props = {},
9 | styles = {},
10 | onReady = jest.fn(),
11 | onValidityChange = jest.fn(),
12 | authorization = 'sandbox_test_one_two_three',
13 | } = {}) {
14 | return (
15 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | },
30 |
31 | };
32 |
--------------------------------------------------------------------------------
/specs/vendor-mock.js:
--------------------------------------------------------------------------------
1 | import loadjs from 'loadjs';
2 |
3 | jest.mock('loadjs');
4 |
5 | export default class VendorMock {
6 |
7 | constructor() {
8 | const mock = this;
9 | loadjs.mockImplementation(jest.fn((urls, { success }) => {
10 | if (!mock.mock) { mock.install(mock); }
11 | success();
12 | }));
13 | }
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/src/api.js:
--------------------------------------------------------------------------------
1 | import loadjs from 'loadjs';
2 |
3 | let NEXT_FIELD_ID = 0;
4 |
5 | export function resetIDCounter() {
6 | NEXT_FIELD_ID = 0;
7 | }
8 |
9 | function nextFieldId() {
10 | NEXT_FIELD_ID += 1;
11 | return NEXT_FIELD_ID;
12 | }
13 |
14 | class Field {
15 |
16 | constructor(api, props) {
17 | this.api = api;
18 | this.isReady = false;
19 | this.events = Object.create(null);
20 | this.props = props;
21 | const { type, ...rest } = props;
22 | this.id = `field-wrapper-${nextFieldId()}`;
23 | this.type = type;
24 | for (const key in rest) { // eslint-disable-line
25 | if ('on' === key.substr(0, 2)) {
26 | this.events[key] = rest[key];
27 | delete rest[key];
28 | }
29 | }
30 | this.options = rest;
31 | }
32 |
33 | emit(event) {
34 | if (this.events[event.type]) {
35 | this.events[event.type](event);
36 | }
37 | }
38 |
39 | get selector() {
40 | return `#${this.id}`;
41 | }
42 |
43 | }
44 |
45 |
46 | export default class ClientApi {
47 |
48 | static Field = Field;
49 |
50 | fields = Object.create(null);
51 |
52 | fieldHandlers = Object.create(null);
53 |
54 | constructor({ isReady, urls, props }) {
55 | const { authorization: _, styles, ...callbacks } = props;
56 | this.styles = styles || {};
57 | this.wrapperHandlers = callbacks || {};
58 | this.tokenize = this.tokenize.bind(this);
59 | if (!isReady) {
60 | this.fetch(urls);
61 | }
62 | }
63 |
64 | isApiReady() {
65 | return false;
66 | }
67 |
68 | fetch(urls) {
69 | loadjs(urls, {
70 | success: () => {
71 | if (this.pendingAuthorization) {
72 | this.setAuthorization(this.pendingAuthorization);
73 | }
74 | },
75 | error: this.onError.bind(this),
76 | });
77 | }
78 |
79 | setAuthorization(authorization) {
80 | if (!this.isApiReady()) {
81 | this.pendingAuthorization = authorization;
82 | return;
83 | }
84 | if (!authorization && this.authorization) {
85 | this.teardown();
86 | } else if (authorization && authorization !== this.authorization) {
87 | if (this.authorization) { this.teardown(); }
88 | this.authorization = authorization;
89 | this.createInstance(authorization);
90 | }
91 | }
92 |
93 | onError(err) {
94 | if (!err) { return; }
95 | if (this.wrapperHandlers.onError) { this.wrapperHandlers.onError(err); }
96 | }
97 |
98 | checkInField(props) {
99 | const { FieldClass } = this;
100 | const field = new FieldClass(this, props);
101 | this.fields[field.type] = field;
102 | return field.id;
103 | }
104 |
105 | onFieldsReady() {
106 | if (this.wrapperHandlers.onReady) {
107 | this.wrapperHandlers.onReady({ tokenize: this.tokenize });
108 | }
109 | }
110 |
111 | onFieldValidity(event) {
112 | if (this.wrapperHandlers.onValidityChange) {
113 | this.wrapperHandlers.onValidityChange(event);
114 | }
115 | }
116 |
117 | onFieldEvent(event) {
118 | if (this.wrapperHandlers[event.type]) {
119 | this.wrapperHandlers[event.type](event);
120 | }
121 | }
122 |
123 | // the following fields are implemented in each client lib
124 | createInstance() {}
125 |
126 | tokenize() { }
127 |
128 | teardown() { }
129 |
130 |
131 | }
132 |
--------------------------------------------------------------------------------
/src/braintree.js:
--------------------------------------------------------------------------------
1 | import Api from './api';
2 |
3 | // sandbox_g42y39zw_348pk9cgf3bgyw2b
4 |
5 | export const EVENTS_MAP = {
6 | focus: 'onFocus',
7 | blur: 'onBlur',
8 | validityChange: 'onValidityChange',
9 | cardTypeChange: 'onCardTypeChange',
10 | };
11 |
12 | const EVENT_DECODERS = {
13 |
14 | onCardTypeChange(event) {
15 | const card = (1 === event.cards.length) ? event.cards[0] : {};
16 | return Object.assign(
17 | card,
18 | { type: 'onCardTypeChange', brand: card.type },
19 | );
20 | },
21 |
22 | };
23 |
24 | export const TYPES_MAP = {
25 | cardNumber: 'number',
26 | };
27 |
28 | export class BraintreeField extends Api.Field {
29 |
30 | constructor(api, props) {
31 | super(api, props);
32 | this.type = TYPES_MAP[this.type] || this.type;
33 | this.options.selector = `#${this.id}`;
34 | }
35 |
36 | }
37 |
38 | export default class BraintreeApi extends Api {
39 |
40 |
41 | FieldClass = BraintreeField;
42 |
43 | constructor(props) {
44 | super({
45 | props,
46 | isReady: !!global.braintree,
47 | urls: [
48 | 'https://js.braintreegateway.com/web/3.50.0/js/client.js',
49 | 'https://js.braintreegateway.com/web/3.50.0/js/hosted-fields.js',
50 | ],
51 | });
52 | }
53 |
54 | isApiReady() {
55 | return !!global.braintree;
56 | }
57 |
58 | createInstance() {
59 | global.braintree.client.create(
60 | { authorization: this.authorization },
61 | (err, clientInstance) => {
62 | if (err) {
63 | this.onError(err);
64 | } else {
65 | this.createFields(clientInstance);
66 | }
67 | },
68 | );
69 | }
70 |
71 | createFields(client) {
72 | const fields = {};
73 | Object.keys(this.fields).forEach((fieldName) => {
74 | fields[fieldName] = this.fields[fieldName].options;
75 | });
76 | global.braintree.hostedFields.create({
77 | client,
78 | fields,
79 | styles: {
80 | input: this.styles.base,
81 | ':focus': this.styles.focus,
82 | '.invalid': this.styles.invalid,
83 | '.valid': this.styles.valid,
84 | },
85 | }, (err, hostedFields) => {
86 | if (err) {
87 | this.onError(err);
88 | return;
89 | }
90 | this.hostedFields = hostedFields;
91 | [
92 | 'blur', 'focus', 'empty', 'notEmpty',
93 | 'cardTypeChange', 'validityChange',
94 | ].forEach((eventName) => {
95 | hostedFields.on(eventName, ev => this.onFieldEvent(eventName, ev));
96 | });
97 | this.onFieldsReady();
98 | });
99 | }
100 |
101 | onFieldEvent(eventName, event) {
102 | const type = EVENTS_MAP[eventName] || eventName;
103 | const attrs = EVENT_DECODERS[type] ? EVENT_DECODERS[type](event) : {};
104 | const field = this.fields[TYPES_MAP[event.emittedBy] || event.emittedBy];
105 | const sanitizedEvent = Object.assign({
106 | field: field.props.type,
107 | type,
108 | event,
109 | isPotentiallyValid: event.fields[event.emittedBy].isPotentiallyValid,
110 | isValid: event.fields[event.emittedBy].isValid,
111 | }, attrs);
112 | field.emit(sanitizedEvent);
113 |
114 | if ('validityChange' === eventName) {
115 | let isValid = true;
116 | const fields = Object.keys(event.fields);
117 | for (let fi = 0; fi < fields.length; fi++) {
118 | isValid = Boolean(event.fields[fields[fi]].isValid);
119 | if (!isValid) { break; }
120 | }
121 | this.onFieldValidity(Object.assign(sanitizedEvent, { isValid }));
122 | } else {
123 | super.onFieldEvent(sanitizedEvent);
124 | }
125 | }
126 |
127 | tokenize(options = {}) {
128 | return new Promise((resolve, reject) => {
129 | this.hostedFields.tokenize(options, (err, payload) => {
130 | if (err) {
131 | this.onError(err);
132 | reject(err);
133 | } else {
134 | resolve({ token: payload });
135 | }
136 | });
137 | });
138 | }
139 |
140 | teardown() {
141 | if (this.hostedFields) { this.hostedFields.teardown(); }
142 | }
143 |
144 | }
145 |
--------------------------------------------------------------------------------
/src/context.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const PaymentFieldsContext = React.createContext();
4 |
5 | export default PaymentFieldsContext;
6 |
--------------------------------------------------------------------------------
/src/field.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'; // eslint-disable-line no-unused-vars
2 | import PropTypes from 'prop-types';
3 | import PaymentFieldsContext from './context';
4 |
5 | export default class Field extends React.Component {
6 |
7 | static propTypes = {
8 | type: PropTypes.oneOf([
9 | 'cardNumber', 'expirationDate', 'cvv', 'postalCode',
10 | ]).isRequired,
11 | placeholder: PropTypes.string,
12 | className: PropTypes.string,
13 | onValidityChange: PropTypes.func,
14 | onFocus: PropTypes.func,
15 | onBlur: PropTypes.func,
16 | }
17 |
18 | static defaultProps = {
19 | placeholder: '',
20 | }
21 |
22 | static contextType = PaymentFieldsContext;
23 |
24 | constructor(props, context) {
25 | super(props, context);
26 | this.fieldId = context.checkInField(this.props);
27 | }
28 |
29 | focus() {
30 | this.context.focusField(this.props.type);
31 | }
32 |
33 | clear() {
34 | this.context.clearField(this.props.type);
35 | }
36 |
37 | get className() {
38 | const list = ['payment-field'];
39 | if (this.props.className) { list.push(this.props.className); }
40 | return list.join(' ');
41 | }
42 |
43 | render() {
44 | return
;
45 | }
46 |
47 | }
48 |
--------------------------------------------------------------------------------
/src/payment-fields.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Vendors from './vendors';
4 | import Field from './field.jsx';
5 | import PaymentFieldsContext from './context';
6 |
7 | export default class PaymentFields extends React.Component {
8 |
9 | static Field = Field;
10 |
11 | static propTypes = {
12 | vendor: PropTypes.oneOf(Object.keys(Vendors)).isRequired,
13 | children: PropTypes.node.isRequired,
14 | onReady: PropTypes.func,
15 | authorization: PropTypes.string,
16 | onValidityChange: PropTypes.func,
17 | onCardTypeChange: PropTypes.func,
18 | onError: PropTypes.func,
19 | passThroughStyles: PropTypes.any,
20 | styles: PropTypes.object,
21 | className: PropTypes.string,
22 | tagName: PropTypes.string,
23 | }
24 |
25 | static defaultProps = {
26 | tagName: 'div',
27 | styles: {},
28 | }
29 |
30 | constructor(props) {
31 | super(props);
32 | const Api = Vendors[props.vendor];
33 | this.api = new Api(props);
34 | }
35 |
36 | componentDidMount() {
37 | if (this.props.authorization) {
38 | this.api.setAuthorization(this.props.authorization);
39 | }
40 | }
41 |
42 | componentWillUnmount() {
43 | this.api.teardown();
44 | }
45 |
46 | componentDidUpdate(prevProps) {
47 | if (prevProps.authorization !== this.props.authorization) {
48 | this.api.setAuthorization(this.props.authorization);
49 | }
50 | }
51 |
52 | tokenize(options) {
53 | return this.api.tokenize(options);
54 | }
55 |
56 | render() {
57 | const { className: providedClass, tagName: Tag } = this.props;
58 | let className = 'payment-fields-wrapper';
59 | if (providedClass) { className += ` ${providedClass}`; }
60 | return (
61 |
62 |
63 | {this.props.children}
64 |
65 |
66 | );
67 | }
68 |
69 | }
70 |
--------------------------------------------------------------------------------
/src/square.js:
--------------------------------------------------------------------------------
1 | import Api from './api';
2 | import { camelCaseKeys } from './util';
3 |
4 | export const EVENTS_MAP = {
5 | focusClassAdded: 'onFocus',
6 | focusClassRemoved: 'onBlur',
7 | errorClassAdded: 'onValidityChange',
8 | errorClassRemoved: 'onValidityChange',
9 | cardBrandChanged: 'onCardTypeChange',
10 | postalCodeChanged: 'onChange',
11 | };
12 |
13 | const EVENT_DECODERS = {
14 |
15 | onCardTypeChange(event) {
16 | return { brand: event.cardBrand };
17 | },
18 |
19 | };
20 |
21 | // sandbox-sq0idp-i06hC8ZeXrqOujH_QfYt5Q
22 |
23 | export class SquareField extends Api.Field {
24 |
25 | constructor(api, props) {
26 | super(api, props);
27 | this.options.elementId = this.id;
28 | }
29 |
30 | emit(ev) {
31 | Object.assign(ev, {
32 | isValid: ev.event.currentState.isCompletelyValid,
33 | isPotentiallyValid: ev.event.currentState.isPotentiallyValid,
34 | });
35 | super.emit(ev);
36 | if (this.isValid !== ev.event.currentState.isCompletelyValid) {
37 | this.isValid = ev.event.currentState.isCompletelyValid;
38 | super.emit(Object.assign({}, ev, {
39 | type: 'onValidityChange',
40 | }));
41 | }
42 | }
43 |
44 | }
45 |
46 |
47 | export default class SquareApi extends Api {
48 |
49 | FieldClass = SquareField;
50 |
51 | constructor(props) {
52 | super({
53 | isReady: !!global.SqPaymentForm,
54 | urls: ['https://js.squareup.com/v2/paymentform'],
55 | props,
56 | });
57 | }
58 |
59 | isApiReady() {
60 | return !!global.SqPaymentForm;
61 | }
62 |
63 | createInstance() {
64 | const options = {
65 | inputClass: 'hosted-card-field',
66 | applicationId: this.authorization,
67 | captureUncaughtExceptions: true,
68 | inputStyles: [
69 | camelCaseKeys(this.styles.base),
70 | ],
71 | };
72 | for (const type in this.fields) { // eslint-disable-line
73 | const field = this.fields[type];
74 | options[field.type] = field.options;
75 | }
76 | options.callbacks = {
77 | cardNonceResponseReceived: this.onCardNonce.bind(this),
78 | inputEventReceived: this.onFieldEvent.bind(this),
79 | paymentFormLoaded: this.onFieldsReady.bind(this),
80 | };
81 | this.hostedFields = new global.SqPaymentForm(options);
82 | this.hostedFields.build();
83 | }
84 |
85 | onFieldEvent(event) {
86 | const type = EVENTS_MAP[event.eventType] || event.eventType;
87 | const attrs = EVENT_DECODERS[type] ? EVENT_DECODERS[type](event) : {};
88 |
89 | const sanitizedEvent = Object.assign({
90 | field: event.field,
91 | type,
92 | event,
93 | }, attrs);
94 |
95 | this.fields[event.field].emit(sanitizedEvent);
96 |
97 | super.onFieldEvent(sanitizedEvent);
98 |
99 | if (event.currentState.isCompletelyValid !== event.previousState.isCompletelyValid) {
100 | this.onFieldValidity(Object.assign(sanitizedEvent, {
101 | type: 'validityChange',
102 | isValid: event.currentState.isCompletelyValid,
103 | isPotentiallyValid: event.currentState.isPotentiallyValid,
104 | }));
105 | }
106 | }
107 |
108 | onCardNonce(errors, nonce, cardData) {
109 | const { pendingToken } = this;
110 | this.pendingToken = null;
111 | if (errors) {
112 | pendingToken.reject({ errors });
113 | return;
114 | }
115 | pendingToken.resolve({ token: nonce, cardData });
116 | }
117 |
118 | tokenize() {
119 | if (this.pendingToken) {
120 | return Promise.reject(new Error('tokenization in progress'));
121 | }
122 | return new Promise((resolve, reject) => {
123 | this.pendingToken = { resolve, reject };
124 | this.hostedFields.requestCardNonce();
125 | });
126 | }
127 |
128 | teardown() {
129 | if (this.hostedFields) { this.hostedFields.destroy(); }
130 | super.teardown();
131 | }
132 |
133 | }
134 |
--------------------------------------------------------------------------------
/src/stripe.js:
--------------------------------------------------------------------------------
1 | import Api from './api';
2 | import { camelCaseKeys } from './util.js';
3 |
4 | // pk_Kljj8QJjBXmRjXgP1OyfXSlfku3CX
5 |
6 | const EVENTS_MAP = {
7 | onChange: 'change',
8 | onBlur: 'blur',
9 | onFocus: 'focus',
10 |
11 | };
12 |
13 | export const TYPES_MAP = {
14 | expirationDate: 'cardExpiry',
15 | cvv: 'cardCvc',
16 | };
17 |
18 | const EVENT_DECODERS = {
19 | onValidityChange(event) {
20 | return { isValid: event.isValid };
21 | },
22 |
23 | onCardTypeChange(event) {
24 | return Object.assign(
25 | event.cards[0],
26 | { type: 'onCardTypeChange', brand: event.cards[0].type },
27 | );
28 | },
29 |
30 | };
31 |
32 |
33 | class StripeField extends Api.Field {
34 |
35 | constructor(api, props) {
36 | super(api, props);
37 | this.type = TYPES_MAP[this.type] || this.type;
38 | if (!this.options.style) {
39 | this.options.style = this.api.fieldStyles;
40 | }
41 | this.isValid = false;
42 | }
43 |
44 | mount(elements) {
45 | this.element = elements.create(this.type, this.options);
46 | this.element.mount(this.selector);
47 | this.element.addEventListener('change', (ev) => {
48 | if (ev.isValid !== this.isValid) {
49 | this.isValid = !ev.error;
50 | if (this.events.onValidityChange) {
51 | this.api.onFieldEvent('onValidityChange', this, ev);
52 | }
53 | this.api.onFieldValidity(this);
54 | }
55 | });
56 |
57 | if ('cardNumber' === this.type) {
58 | this.element.addEventListener('ready', () => {
59 | this.api.onFieldsReady();
60 | });
61 | this.element.addEventListener('change', ev => this.api.onCardChange(ev));
62 | }
63 |
64 | for (const evName in this.events) { // eslint-disable-line
65 | this.element.addEventListener(
66 | EVENTS_MAP[evName] || evName,
67 | ev => this.api.onFieldEvent(evName, this, ev),
68 | );
69 | }
70 | }
71 |
72 | unmount() {
73 | if (this.element) { this.element.unmount(); }
74 | }
75 |
76 | }
77 |
78 |
79 | export default class StripeApi extends Api {
80 |
81 | FieldClass = StripeField;
82 |
83 | cardType = '';
84 |
85 | constructor(props) {
86 | super({
87 | isReady: !!global.Stripe,
88 | urls: ['https://js.stripe.com/v3/'],
89 | props,
90 | });
91 | }
92 |
93 | get fieldStyles() {
94 | return {
95 | base: Object.assign(
96 | camelCaseKeys(this.styles.base),
97 | { ':focus': camelCaseKeys(this.styles.focus) },
98 | ),
99 | complete: camelCaseKeys(this.styles.valid),
100 | invalid: camelCaseKeys(this.styles.invalid),
101 | };
102 | }
103 |
104 | isApiReady() {
105 | return !!global.Stripe;
106 | }
107 |
108 | onFieldValidity() {
109 | for (const type in this.fields) { // eslint-disable-line
110 | if (!this.fields[type].isValid) {
111 | super.onFieldValidity({ isValid: false });
112 | return;
113 | }
114 | }
115 | super.onFieldValidity({ isValid: true });
116 | }
117 |
118 | onCardChange(ev) {
119 | if (this.wrapperHandlers.onCardTypeChange && ev.brand !== this.cardType) {
120 | this.wrapperHandlers.onCardTypeChange(ev);
121 | }
122 | }
123 |
124 | createInstance() {
125 | this.stripe = global.Stripe(this.authorization);
126 | this.elements = this.stripe.elements();
127 | for (const type in this.fields) { // eslint-disable-line
128 | this.fields[type].mount(this.elements);
129 | }
130 | }
131 |
132 | onFieldEvent(eventName, field, event) {
133 | const attrs = EVENT_DECODERS[eventName] ? EVENT_DECODERS[eventName](event) : {};
134 | // ev.isValid = this.isValid;
135 | // ev.isPotentiallyValid = !ev.error;
136 | const sanitizedEvent = Object.assign({
137 | field: field.props.type,
138 | type: eventName,
139 | isValid: !!event.isValid,
140 | isPotentiallyValid: !event.error,
141 | event,
142 | }, attrs);
143 | field.emit(sanitizedEvent);
144 |
145 | if ('onValidityChange' === eventName) {
146 | this.onFieldValidity();
147 | } else {
148 | super.onFieldEvent(sanitizedEvent);
149 | }
150 | }
151 |
152 | tokenize(cardData) {
153 | return new Promise((resolve, reject) => {
154 | this.stripe.createToken(this.fields.cardNumber.element, cardData).then((result) => {
155 | if (result.error) {
156 | reject(result.error);
157 | } else {
158 | resolve(result);
159 | }
160 | });
161 | });
162 | }
163 |
164 | teardown() {
165 | if (this.stripe) {
166 | for (const type in this.fields) { // eslint-disable-line
167 | this.fields[type].unmount();
168 | }
169 | }
170 | }
171 |
172 | }
173 |
--------------------------------------------------------------------------------
/src/util.js:
--------------------------------------------------------------------------------
1 | export function capitalize(string) {
2 | return string.charAt(0).toUpperCase() + string.slice(1);
3 | }
4 |
5 | export function camelCase(str) {
6 | return str.replace(/-([a-z])/gi, (s, c, i) => (0 === i ? c : c.toUpperCase()));
7 | }
8 |
9 | export function camelCaseKeys(src) {
10 | if (!src) return {};
11 | const dest = {};
12 | const keys = Object.keys(src);
13 | for (let i = 0; i < keys.length; i++) {
14 | const key = keys[i];
15 | dest[camelCase(key)] = src[key];
16 | }
17 | return dest;
18 | }
19 |
--------------------------------------------------------------------------------
/src/vendors.js:
--------------------------------------------------------------------------------
1 | import Braintree from './braintree';
2 | import Square from './square';
3 | import Stripe from './stripe';
4 |
5 | export default {
6 | Braintree, Square, Stripe,
7 | };
8 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const path = require('path');
3 |
4 | const config = {
5 | entry: {
6 | demo: path.join(__dirname, 'demo.jsx'),
7 | },
8 | output: {
9 | path: path.join(__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 | node: {
29 | fs: 'empty',
30 | },
31 | devServer: {
32 | hot: false,
33 | inline: true,
34 | port: 2222,
35 | historyApiFallback: true,
36 | stats: {
37 | colors: true,
38 | profile: true,
39 | hash: false,
40 | version: false,
41 | timings: false,
42 | assets: true,
43 | chunks: false,
44 | modules: false,
45 | reasons: true,
46 | children: false,
47 | source: true,
48 | errors: true,
49 | errorDetails: false,
50 | warnings: true,
51 | publicPath: false,
52 | },
53 | },
54 | };
55 |
56 | // console.log(config)
57 |
58 | module.exports = config;
59 |
--------------------------------------------------------------------------------