├── .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 | [![Build Status](https://travis-ci.org/nathanstitt/payment-fields.svg?branch=master)](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 | 131 |
132 |
133 | ); 134 | } 135 | 136 | render() { 137 | const { type } = this.state; 138 | 139 | return ( 140 |
141 |
142 | {Object.keys(Vendors).map(k => ( 143 | ))} 149 |
150 |

Payments Field Demo for: {type}

151 | 162 | 163 | {this.renderFields()} 164 | 165 | 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 |
7 |
11 |
15 |
19 |
23 |
24 | `; 25 | 26 | exports[`Square Api renders and matches snapshot 1`] = ` 27 |
30 |
34 |
38 |
42 |
46 |
47 | `; 48 | 49 | exports[`Stripe Api renders and matches snapshot 1`] = ` 50 |
53 |
57 |
61 |
65 |
69 |
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 | --------------------------------------------------------------------------------