├── .circleci └── config.yml ├── .gitignore ├── .npmignore ├── README.md ├── babel.config.js ├── example ├── Example.tsx ├── SubscriptionExample.tsx ├── index.html ├── index.tsx ├── style.css └── webpack.config.js ├── jest.config.js ├── package-lock.json ├── package.json ├── paypalImage.png ├── src ├── ErrorBoundary.tsx ├── PayPalButton.tsx ├── PayPalButtonBase.tsx ├── index.ts ├── setupTests.ts ├── types.ts └── utils │ ├── __snapshots__ │ ├── composeUrl.test.ts.snap │ ├── usePaypalMethods.test.ts.snap │ └── usePaypalScript.test.ts.snap │ ├── composeUrl.test.ts │ ├── composeUrl.ts │ ├── constants.ts │ ├── index.ts │ ├── usePaypalMethods.test.ts │ ├── usePaypalMethods.ts │ ├── usePaypalScript.test.ts │ ├── usePaypalScript.ts │ └── usePaypalScriptOptions.ts ├── tsconfig.json ├── tslint.json └── webpack.config.js /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | _common: 3 | node-docker: &node-docker 4 | - image: circleci/node:10.13.0 5 | restore-cache: &restore-cache 6 | keys: 7 | - bootstrap-v1-{{ .Branch }}-{{ .Revision }} 8 | attach-workspace: &attach-workspace 9 | at: . 10 | 11 | jobs: 12 | bootstrap: 13 | docker: *node-docker 14 | steps: 15 | - restore_cache: *restore-cache 16 | - checkout 17 | - setup_remote_docker: 18 | docker_layer_caching: false 19 | - run: 20 | name: Install deps 21 | command: npm install 22 | - save_cache: 23 | key: bootstrap-v1-{{ .Branch }}-{{ .Revision }} 24 | paths: 25 | - ~/. 26 | 27 | test: 28 | docker: *node-docker 29 | steps: 30 | - restore_cache: *restore-cache 31 | - checkout 32 | - run: 33 | name: Install deps 34 | command: npm install 35 | - run: 36 | name: Run tests 37 | command: npm run test:once 38 | 39 | workflows: 40 | version: 2 41 | build-and-deploy: 42 | jobs: 43 | - bootstrap 44 | - test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | example/dist 3 | .env 4 | example/.env 5 | bin/ 6 | react-paypal-button-3.2.0.tgz 7 | 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | example 3 | .env 4 | jest.config.js 5 | paypalImage.png 6 | webpack.config.js 7 | tsconfig.json 8 | tsconfig.test.json 9 | tslint.json 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React-Paypal-Button 2 | 3 | This repository is DEPRECATED. Please see [PayPal's supported solution](https://www.npmjs.com/package/@paypal/react-paypal-js) 4 | 5 | 6 | 7 | 8 | 9 | [![npm version](https://badge.fury.io/js/react-paypal-button.svg)](https://badge.fury.io/js/react-paypal-button) 10 | [![CircleCI](https://circleci.com/gh/andrewangelle/react-paypal-button.svg?style=svg)](https://circleci.com/gh/andrewangelle/react-paypal-button) 11 | 12 | 13 | 14 | A button component to implement PayPal's Express Checkout in React 15 | 16 | 17 | ## Prerequisites 18 | 19 | * To use PayPal's Express Checkout you must have a PayPal Business account set up and verified. After this is done, you'll have access to your API credentials to use with this button. Once you have your account set up you will have 2 different sets of credentials for sandbox mode and prouduction mode. Both will have a clientID, this is what you will use to pass to `paypalOptions`. 20 | 21 | * Because the internals of this library use hooks, npm version `4.x.x` and above requires a peer dependency of `react-v16.8.x` `react-dom-v16.8.x`. 22 | 23 | ## Installation 24 | 25 | ```sh 26 | $ npm install react-paypal-button --save 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```javascript 32 | import { PayPalButton } from 'react-paypal-button' 33 | 34 | export default function App() { 35 | const paypalOptions = { 36 | clientId: '12345', 37 | intent: 'capture' 38 | } 39 | 40 | const buttonStyles = { 41 | layout: 'vertical', 42 | shape: 'rect', 43 | } 44 | return ( 45 | 50 | ) 51 | } 52 | ``` 53 | 54 | ### Types 55 | 56 | * All relevant types are bundled and exported with the npm package 57 | 58 | ```typescript 59 | 60 | type PayPalButtonProps = { 61 | paypalOptions: PaypalOptions; 62 | buttonStyles: ButtonStylingOptions; 63 | amount: number; 64 | subscriptionPlanId?: string; 65 | onApprove?: (data, authId) => void; 66 | onPaymentStart?: () => void; 67 | onPaymentSuccess?: (response: OnCaptureData) => void; 68 | onPaymentError?: (msg: string) => void; 69 | onPaymentCancel?: (data: OnCancelData) => void; 70 | onShippingChange?: (data: OnShippingChangeData) => 71 | Promise | 72 | string | 73 | number | 74 | void; 75 | } 76 | 77 | ``` 78 | 79 | * See [list and documentation on styling options](https://developer.paypal.com/docs/checkout/integration-features/customize-button/#color) that are to be passed to `buttonStyles` prop 80 | 81 | * See [list and documentation on values](https://developer.paypal.com/docs/checkout/reference/customize-sdk/#query-parameters) that are to be passed to `paypalOptions`prop 82 | 83 | * See examples folder for more examples 84 | 85 | ## Development 86 | 87 | Install dependencies: 88 | 89 | ``` 90 | $ npm install 91 | ``` 92 | 93 | Run the example app at [http://localhost:8008](http://localhost:8008): 94 | 95 | ``` 96 | $ npm start 97 | ``` 98 | 99 | Generate UMD output in the `bin` folder: 100 | 101 | ``` 102 | $ npm run build 103 | ``` 104 | 105 | Run tests in watch mode: 106 | 107 | ``` 108 | $ npm test 109 | ``` 110 | 111 | Perform a single run of tests: 112 | 113 | ``` 114 | $ npm run test:once 115 | ``` 116 | 117 | ## License 118 | 119 | MIT 120 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-env', 4 | '@babel/preset-react', 5 | '@babel/preset-typescript', 6 | ], 7 | plugins: [ 8 | '@babel/plugin-transform-runtime', 9 | '@babel/plugin-proposal-class-properties', 10 | '@babel/plugin-proposal-object-rest-spread' 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /example/Example.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PayPalButton, PaypalOptions, ButtonStylingOptions } from '../src'; 3 | 4 | const wrapperStyles: React.CSSProperties = { 5 | textAlign: 'center', 6 | padding: '5rem', 7 | width: '30%', 8 | margin: '5rem auto' 9 | } 10 | 11 | const paypalOptions: PaypalOptions = { 12 | clientId: process.env.PAYPAL_CLIENT_ID!, 13 | intent:'capture', 14 | currency:'USD', 15 | }; 16 | 17 | const buttonStyles: ButtonStylingOptions = { 18 | layout: 'vertical', 19 | shape: 'rect', 20 | label: 'checkout', 21 | tagline: false 22 | } 23 | 24 | export function Example() { 25 | return ( 26 |
27 | console.log('onApprove', data, authId)} 32 | onPaymentStart={() => console.log('onPaymentStart')} 33 | onPaymentSuccess={data => console.log('onPaymentSuccess', data)} 34 | onPaymentError={msg => console.log('payment error', msg)} 35 | onPaymentCancel={data => console.log(data)} 36 | onShippingChange={data => console.log('onShippingChange', data)} 37 | /> 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /example/SubscriptionExample.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PayPalButton, PaypalOptions, ButtonStylingOptions } from '../src'; 3 | 4 | const wrapperStyles: React.CSSProperties = { 5 | textAlign: 'center', 6 | padding: '5rem', 7 | width: '30%', 8 | margin: '5rem auto' 9 | } 10 | 11 | const paypalOptions: PaypalOptions = { 12 | clientId: process.env.PAYPAL_CLIENT_ID!, 13 | currency:'USD', 14 | vault: true // required 15 | }; 16 | 17 | const buttonStyles: ButtonStylingOptions = { 18 | label: 'installment', 19 | } 20 | 21 | // To use this button with subscriptions the paypal account that accepts payments 22 | // must already be setup to handle subscription plans and recurring payments 23 | // Checkout paypal developer docs for more info 24 | 25 | export function SubscriptionExample() { 26 | return ( 27 |
28 | console.log('onPaymentSuccess', data)} 34 | /> 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | paypal-button 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { Example } from './Example'; 4 | import './style.css'; 5 | 6 | render( 7 | , 8 | document.getElementById('root') 9 | ); 10 | -------------------------------------------------------------------------------- /example/style.css: -------------------------------------------------------------------------------- 1 | /* styles here */ 2 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | const libraryName = 'reactGlide'; 5 | const DotEnv = require('dotenv-webpack') 6 | 7 | const config = { 8 | entry: path.join(__dirname), 9 | output: { 10 | path: path.join(__dirname, 'dist'), 11 | publicPath: '/' 12 | }, 13 | optimization: { 14 | splitChunks: { 15 | cacheGroups: { 16 | styles: { 17 | name: 'styles', 18 | test: /\.css$/, 19 | chunks: 'all', 20 | enforce: true 21 | } 22 | } 23 | }, 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /^(?!.*test\.tsx|\.ts?$).*\.tsx|\.ts?$/, 29 | exclude: /node_modules/, 30 | use: ['babel-loader'] 31 | }, 32 | { 33 | test: /\.css/, 34 | exclude: /node_modules/, 35 | use: [ 36 | 'style-loader', 37 | 'css-loader', 38 | ] 39 | }, 40 | { 41 | test: /^(?!.*test\.tsx|\.ts?$).*\.tsx|\.ts?$/, 42 | exclude: /node_modules/, 43 | use: ["source-map-loader"], 44 | enforce: "pre" 45 | }, 46 | ] 47 | }, 48 | resolve: { 49 | extensions: ['.js', '.tsx', '.css', '.ts', '.jsx'], 50 | }, 51 | devServer: { 52 | contentBase: 'dist', 53 | port: 8008, 54 | open: true, 55 | host: 'localhost', 56 | hot: true 57 | }, 58 | plugins: [ 59 | new webpack.HotModuleReplacementPlugin(), 60 | new HtmlWebpackPlugin({ 61 | template: 'example/index.html', 62 | filename: 'index.html' 63 | }), 64 | new DotEnv({ 65 | path: '../.env' 66 | }) 67 | ] 68 | }; 69 | 70 | module.exports = config; 71 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | collectCoverageFrom: [ 4 | "src/**/*.{tsx}" 5 | ], 6 | transform: { 7 | '^.+\\.ts?$': 'babel-jest', 8 | '^.+\\.tsx?$': 'babel-jest', 9 | }, 10 | roots: [ 11 | "/src" 12 | ], 13 | globals: { 14 | "ts-jest": { 15 | "babelConfig": true 16 | }, 17 | }, 18 | transformIgnorePatterns: ['/node_modules/'], 19 | snapshotSerializers: ["enzyme-to-json/serializer"], 20 | moduleFileExtensions: [ 21 | "ts", 22 | "tsx", 23 | "js", 24 | "jsx", 25 | "json", 26 | "node" 27 | ], 28 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-paypal-button", 3 | "version": "4.1.1", 4 | "description": "a button component to implement PayPal's Express Checkout in React", 5 | "author": "andrewangelle", 6 | "email": "andrewangelle@gmail.com", 7 | "repository": "http://github.com/andrewangelle/react-paypal-button.git", 8 | "license": "MIT", 9 | "main": "bin/index.min.js", 10 | "types": "bin/types/src/index.d.ts", 11 | "scripts": { 12 | "build": "rm -rf bin && tsc --emitDeclarationOnly && webpack --config webpack.config.js", 13 | "start": "webpack-dev-server --config ./example/webpack.config.js --mode development --open --hot", 14 | "test": "jest --watchAll", 15 | "test:once": "jest", 16 | "coverage": "jest --coverage && cat ./coverage/lcov.info | coveralls", 17 | "preversion": "rm -rf bin && tsc --emitDeclarationOnly && webpack --config webpack.config.js", 18 | "postversion": "git push --follow-tags origin master" 19 | }, 20 | "files": [ 21 | "bin", 22 | "bin/types" 23 | ], 24 | "keywords": [ 25 | "react", 26 | "component" 27 | ], 28 | "peerDependencies": { 29 | "react": "^16.8.x", 30 | "react-dom": "^16.8.x" 31 | }, 32 | "resolutions": { 33 | "babel-core": "7.0.0-bridge.0" 34 | }, 35 | "devDependencies": { 36 | "@babel/cli": "^7.5.5", 37 | "@babel/core": "^7.5.5", 38 | "@babel/plugin-proposal-class-properties": "^7.5.5", 39 | "@babel/plugin-proposal-object-rest-spread": "^7.5.5", 40 | "@babel/plugin-syntax-dynamic-import": "^7.2.0", 41 | "@babel/plugin-transform-runtime": "^7.5.5", 42 | "@babel/polyfill": "^7.4.4", 43 | "@babel/preset-env": "^7.5.5", 44 | "@babel/preset-react": "^7.0.0", 45 | "@babel/preset-typescript": "^7.3.3", 46 | "@babel/runtime": "^7.5.5", 47 | "@testing-library/jest-dom": "^4.1.0", 48 | "@testing-library/react": "^9.1.3", 49 | "@testing-library/react-hooks": "^2.0.1", 50 | "@types/enzyme": "^3.10.3", 51 | "@types/enzyme-adapter-react-16": "^1.0.4", 52 | "@types/jest": "^23.3.14", 53 | "@types/node": "^11.13.20", 54 | "@types/react": "^16.9.2", 55 | "@types/react-dom": "^16.9.0", 56 | "@types/testing-library__react": "^9.1.1", 57 | "@types/webpack-env": "^1.14.0", 58 | "babel-core": "^7.0.0-bridge.0", 59 | "babel-jest": "^25.0.0", 60 | "babel-loader": "^8.0.6", 61 | "cross-env": "^5.2.0", 62 | "css-loader": "^2.1.0", 63 | "dotenv": "^8.1.0", 64 | "dotenv-webpack": "^1.7.0", 65 | "enzyme": "^3.10.0", 66 | "enzyme-adapter-react-16": "^1.14.0", 67 | "enzyme-to-json": "^3.4.0", 68 | "html-webpack-plugin": "^3.2.0", 69 | "jest": "^25.0.0", 70 | "mini-css-extract-plugin": "^0.5.0", 71 | "optimize-css-assets-webpack-plugin": "^5.0.3", 72 | "path": "^0.12.7", 73 | "react": "^16.9.0", 74 | "react-dom": "^16.9.0", 75 | "source-map-loader": "^0.2.4", 76 | "style-loader": "^0.23.1", 77 | "ts-jest": "^23.10.5", 78 | "ts-loader": "^5.4.5", 79 | "tslint": "^5.19.0", 80 | "tslint-react": "^3.6.0", 81 | "typescript": "^3.5.3", 82 | "uglifyjs-webpack-plugin": "^2.2.0", 83 | "webpack": "^4.39.3", 84 | "webpack-cli": "^3.3.7", 85 | "webpack-dev-server": "^3.8.0" 86 | }, 87 | "dependencies": {} 88 | } 89 | -------------------------------------------------------------------------------- /paypalImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewangelle/react-paypal-button/a56678a647ab77f42fbd6845833cf7f9be36c215/paypalImage.png -------------------------------------------------------------------------------- /src/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class ErrorBoundary extends React.Component<{}, {hasError: boolean}> { 4 | state={ 5 | hasError: false 6 | } 7 | componentDidCatch(){ 8 | this.setError() 9 | } 10 | setError = () => { 11 | this.setState({hasError: true}) 12 | } 13 | render() { 14 | if(this.state.hasError){ 15 | return null 16 | } else { 17 | return this.props.children 18 | } 19 | } 20 | } 21 | 22 | export default ErrorBoundary 23 | -------------------------------------------------------------------------------- /src/PayPalButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import PayPalButtonBase from './PayPalButtonBase'; 4 | import ErrorBoundary from './ErrorBoundary'; 5 | import { PayPalButtonProps } from './types'; 6 | 7 | 8 | function PayPalButton(props: PayPalButtonProps){ 9 | return ( 10 | 11 | 12 | 13 | ) 14 | } 15 | 16 | export default PayPalButton 17 | -------------------------------------------------------------------------------- /src/PayPalButtonBase.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | import { usePaypalScriptOptions, usePaypalScript, scriptLoadError } from './utils'; 4 | import { PayPalButtonProps } from './types'; 5 | 6 | function PayPalButtonBase(props: PayPalButtonProps) { 7 | const { loading, done } = usePaypalScript(props.paypalOptions); 8 | const options = usePaypalScriptOptions(props); 9 | 10 | useEffect(() => { 11 | const hasWindow = window !== undefined && window.paypal !== undefined; 12 | 13 | if(hasWindow) {// check to support SSR 14 | if(!loading && done){ 15 | try { 16 | window.paypal.Buttons(options).render('#paypal-button'); 17 | } catch (e){ 18 | console.error(scriptLoadError) 19 | } 20 | } 21 | 22 | } 23 | },[ loading, done ]) 24 | 25 | return
26 | } 27 | 28 | export default PayPalButtonBase 29 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as PayPalButton } from './PayPalButton'; 2 | export * from './types' 3 | 4 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import { configure, shallow, mount, render } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16' 3 | 4 | configure({ adapter: new Adapter() }); 5 | 6 | export { shallow, mount, render }; 7 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | 2 | declare global { 3 | interface Window { 4 | paypal: any 5 | } 6 | } 7 | 8 | export type OnCancelData = { 9 | billingID: string; 10 | cancelUrl: string; 11 | intent: string; 12 | paymentID: string; 13 | paymentToken: string; 14 | } 15 | 16 | export type OnShippingChangeData = { 17 | amount: { 18 | value: string, 19 | currency_code: string, 20 | breakdown: {} 21 | }, 22 | orderID: string; 23 | paymentID: string; 24 | paymentToken: string; 25 | shipping_address: { 26 | city: string; 27 | country_code: string; 28 | postal_code: string; 29 | state: string; 30 | } 31 | } 32 | 33 | export type OnApproveData = { 34 | orderID: string, 35 | payerID: string 36 | }; 37 | 38 | export type OnCaptureData = { 39 | create_time: string; 40 | id: string; 41 | intent: String; 42 | links: Array<{href: string; method: string; rel: string; title: string;}> 43 | payer: { 44 | address: {country_code: string} 45 | email_address: string; 46 | name: { 47 | given_name: string; // first name 48 | surname: string; // last name 49 | }, 50 | payer_id: string; 51 | }; 52 | purchase_units: Array; 53 | status: string; 54 | update_time: string; 55 | } 56 | 57 | 58 | type OnShippingChangeReturnType = 59 | Promise | 60 | number | 61 | string | 62 | void; 63 | 64 | export type PaypalOptions = { 65 | clientId: string; 66 | merchantId?: string; 67 | currency?: number | string; 68 | intent?: 'capture' | 'authorize'; 69 | commit?: boolean; 70 | vault?: boolean; 71 | components?: string; 72 | disableFunding?: Array<'card' | 'credit' | 'bancontact' | 'sepa' | 'eps' | 'giropay' | 'ideal' | 'mybank' | 'sofort'>; 73 | disableCard?: Array<'amex' | 'discover' | 'visa' | 'mastercard' | 'jcb' | 'elo' | 'hiper'>; 74 | integrationDate?: string; 75 | locale?: string; 76 | buyerCountry?: string; 77 | debug?: boolean|string; 78 | } 79 | 80 | export type ButtonStylingOptions = { 81 | layout?: 'vertical' | 'horizontal'; 82 | color?: 'blue' | 'gold' | 'silver' | 'white' | 'black'; 83 | shape?: 'rect' | 'pill'; 84 | label?: 'paypal' | 'checkout' | 'buynow' | 'pay' | 'installment'; 85 | tagline?: boolean; 86 | } 87 | 88 | export type PayPalButtonProps = { 89 | paypalOptions: PaypalOptions; 90 | buttonStyles?: ButtonStylingOptions, 91 | amount: number | string; 92 | description?: string; 93 | subscriptionPlanId?: string; 94 | onApprove?: (data: OnApproveData, authId: string) => void; 95 | onPaymentStart?: () => void; 96 | onPaymentSuccess?: (response: OnCaptureData) => void; 97 | onPaymentError?: (msg: string) => void; 98 | onPaymentCancel?: (data: OnCancelData) => void; 99 | onShippingChange?: (data: OnShippingChangeData) => OnShippingChangeReturnType; 100 | } 101 | 102 | -------------------------------------------------------------------------------- /src/utils/__snapshots__/composeUrl.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`composeUrl composes the paypal url from options 1`] = `"https://www.paypal.com/sdk/js?client-id=12345&intent=capture"`; 4 | 5 | exports[`composeUrl works with array values 1`] = `"https://www.paypal.com/sdk/js?client-id=12345&intent=capture&disable-card=amex,visa,discover&disable-funding=credit,bancontact"`; 6 | -------------------------------------------------------------------------------- /src/utils/__snapshots__/usePaypalMethods.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`usePaypalMethods returns an object with methods for paypal lib 1`] = ` 4 | Object { 5 | "createOrder": [Function], 6 | "createSubscription": null, 7 | "onApprove": [Function], 8 | "onCancel": [Function], 9 | "onError": [Function], 10 | "onShippingChange": [Function], 11 | "payment": [Function], 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /src/utils/__snapshots__/usePaypalScript.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`usePaypalMethods returns loading state 1`] = ` 4 | Object { 5 | "done": false, 6 | "error": false, 7 | "loading": true, 8 | } 9 | `; 10 | -------------------------------------------------------------------------------- /src/utils/composeUrl.test.ts: -------------------------------------------------------------------------------- 1 | import { composeUrl } from './composeUrl' 2 | import { PaypalOptions } from '../types'; 3 | 4 | describe('composeUrl', () => { 5 | it('composes the paypal url from options', () => { 6 | const options: PaypalOptions = { 7 | clientId: '12345', 8 | intent: 'capture' 9 | } 10 | const test = composeUrl(options); 11 | expect(test).toMatchSnapshot() 12 | }) 13 | 14 | it('works with array values', () => { 15 | const options: PaypalOptions = { 16 | clientId: '12345', 17 | intent: 'capture', 18 | disableCard: ['amex', 'visa', 'discover'], 19 | disableFunding: ['credit', 'bancontact'] 20 | } 21 | const test = composeUrl(options); 22 | expect(test).toMatchSnapshot() 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /src/utils/composeUrl.ts: -------------------------------------------------------------------------------- 1 | import { baseUrl } from './constants'; 2 | import { PaypalOptions } from '../types'; 3 | 4 | export const composeUrl = (options: PaypalOptions) => { 5 | const queryString = Object.keys(options) 6 | .reduce((prevParams, currentValue, index) => { 7 | const convertKeyFromCamelCaseToDash = currentValue 8 | .split(/(?=[A-Z])/) 9 | .join('-') 10 | .toLowerCase(); 11 | 12 | // for disableCard or disableFunding prop convert array to comma seperated string 13 | // else return the value 14 | const value = (currentValue === 'disableCard' || currentValue === 'disableFunding') 15 | ? (options[currentValue] as string[]).join(',') 16 | : options[currentValue]; 17 | 18 | // add an '&' symbol for all except the first key value pair 19 | const keyValuePair = `${index === 0 ? '' : '&'}${convertKeyFromCamelCaseToDash}=${value}` 20 | 21 | return `${prevParams}${keyValuePair}` 22 | },'?'); 23 | 24 | const url = `${baseUrl}${queryString}`; 25 | 26 | return url; 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const scriptLoadError = ` 2 | React-paypal-button loading script error: 3 | 4 | Make sure that that your clientID is correct and that you are passing valid values to the paypalOptions prop and the buttonStyles prop. 5 | 6 | For a list of valid properties for paypalOptions see https://developer.paypal.com/docs/checkout/reference/customize-sdk/#query-parameters 7 | For a list of valid properties for buttonStyles see https://developer.paypal.com/docs/checkout/integration-features/customize-button/#color 8 | `; 9 | 10 | export const authError = ` 11 | React-paypal-button authorization error: 12 | 13 | This is likely an issue with paypal's api and the way they are handling their session data. 14 | Try closing and reopening your browser 15 | `; 16 | 17 | export const captureError = ` 18 | React-paypal-button capture error: 19 | 20 | This is likely an issue with paypal's api and the way they are handling their session data. 21 | Try closing and reopening your browser 22 | ` 23 | 24 | export const baseUrl = 'https://www.paypal.com/sdk/js'; 25 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './usePaypalMethods'; 2 | export * from './usePaypalScript'; 3 | export * from './usePaypalScriptOptions'; 4 | export * from './composeUrl'; 5 | export * from './constants'; 6 | -------------------------------------------------------------------------------- /src/utils/usePaypalMethods.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import { cleanup } from '@testing-library/react'; 3 | import { usePaypalMethods } from './usePaypalMethods'; 4 | import { PayPalButtonProps } from '../types'; 5 | 6 | const baseProps: PayPalButtonProps = { 7 | amount: 1.00, 8 | paypalOptions: { 9 | clientId: '12345', 10 | intent: 'capture' 11 | }, 12 | onPaymentStart: jest.fn() 13 | } 14 | 15 | describe('usePaypalMethods', () => { 16 | afterEach(cleanup) 17 | it('returns an object with methods for paypal lib', () => { 18 | const { result } = renderHook(() => usePaypalMethods(baseProps)) 19 | expect(result.current).toMatchSnapshot() 20 | }); 21 | 22 | it('returns null for createSubscription if vault prop is false', () => { 23 | const { result } = renderHook(() => usePaypalMethods(baseProps)) 24 | expect(result.current.createSubscription).toBeNull() 25 | }) 26 | 27 | it('returns null for createOrder if vault prop is true', () => { 28 | const props = { 29 | ...baseProps, 30 | paypalOptions: { 31 | ...baseProps.paypalOptions, 32 | vault: true 33 | } 34 | } 35 | const { result } = renderHook(() => usePaypalMethods(props)) 36 | expect(result.current.createOrder).toBeNull() 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /src/utils/usePaypalMethods.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { 4 | OnShippingChangeData, 5 | OnCancelData, 6 | OnCaptureData, 7 | OnApproveData, 8 | PayPalButtonProps, 9 | } from '../types'; 10 | import { authError, captureError } from './constants'; 11 | 12 | export function usePaypalMethods (props: PayPalButtonProps){ 13 | 14 | const onError = useCallback((data) => { 15 | if(props.onPaymentError){ 16 | props.onPaymentError(data.message) 17 | } 18 | }, []); 19 | 20 | const createOrder = props.paypalOptions.vault ? null : 21 | useCallback((data: any, actions: any) => { 22 | if(props.onPaymentStart){ 23 | props.onPaymentStart() 24 | } 25 | 26 | return actions.order.create({ 27 | purchase_units: [{ 28 | amount: { 29 | value: props.amount 30 | }, 31 | description: props.description 32 | }] 33 | }) 34 | }, [ props.amount ]); 35 | 36 | const createSubscription = !props.paypalOptions.vault ? null : 37 | useCallback((data: any, actions: any) => { 38 | if(props.paypalOptions.vault && props.subscriptionPlanId){ 39 | return actions.subscription.create({ 40 | plan_id: props.subscriptionPlanId 41 | }) 42 | } 43 | }, []); 44 | 45 | const onApprove = useCallback(( 46 | data: OnApproveData | OnCaptureData, 47 | actions: any 48 | ) => { 49 | if(props.paypalOptions.vault && props.subscriptionPlanId){ 50 | if(props.onPaymentSuccess){ 51 | props.onPaymentSuccess(data as any) 52 | } 53 | } 54 | 55 | if(props.paypalOptions.intent === 'capture') { 56 | return actions.order.capture() 57 | .then((details: OnCaptureData) => { 58 | if (props.onPaymentSuccess) { 59 | props.onPaymentSuccess(details) 60 | } 61 | }) 62 | .catch(e => { 63 | if(props.onPaymentError){ 64 | props.onPaymentError(e) 65 | } 66 | console.error(captureError, ` 67 | Original error message: ${e.message} 68 | ` ) 69 | }) 70 | } 71 | 72 | if(props.paypalOptions.intent === 'authorize'){ 73 | actions.order.authorize() 74 | .then(auth => { 75 | const id = auth.purchase_units[0].payments.authorizations[0].id; 76 | if(props.onApprove !== undefined){ 77 | props.onApprove(data as OnApproveData, id); 78 | } 79 | }) 80 | .catch(e => { 81 | if(props.onPaymentError){ 82 | props.onPaymentError(e) 83 | } 84 | console.error(authError, ` 85 | Original error message: ${e.message} 86 | `) 87 | }) 88 | } 89 | 90 | }, []) 91 | 92 | 93 | 94 | const payment = useCallback((data: any, actions: any) => { 95 | return actions.payment.create({ 96 | transactions: [ 97 | { 98 | amount: { 99 | total: props.amount, 100 | currency: props.paypalOptions.currency, 101 | }, 102 | description: props.description 103 | } 104 | ] 105 | }) 106 | }, []); 107 | 108 | const onShippingChange = useCallback((data: OnShippingChangeData, actions) => { 109 | if(props.onShippingChange){ 110 | Promise.resolve(props.onShippingChange(data)) 111 | .then((rate) => { 112 | 113 | // early exit if user doesn't return a value 114 | if(!rate){ 115 | return actions.resolve() 116 | } 117 | 118 | // otherwise tell paypal to update the total 119 | const baseOrderAmount = `${props.amount}` 120 | const shippingAmount = `${rate}`; 121 | const value = (parseFloat(baseOrderAmount) + parseFloat(shippingAmount)).toFixed(2); 122 | const currency_code = props.paypalOptions.currency 123 | 124 | return actions.order.patch([ 125 | { 126 | op: 'replace', 127 | path: '/purchase_units/@reference_id==\'default\'/amount', 128 | value: { 129 | currency_code, 130 | value, 131 | breakdown: { 132 | item_total: { 133 | currency_code, 134 | value: baseOrderAmount 135 | }, 136 | shipping: { 137 | currency_code, 138 | value: shippingAmount 139 | } 140 | } 141 | } 142 | } 143 | ]); 144 | }); 145 | } else { 146 | return actions.resolve() 147 | } 148 | }, []); 149 | 150 | const onCancel = useCallback((data: OnCancelData) => { 151 | if(props.onPaymentCancel){ 152 | props.onPaymentCancel(data) 153 | } 154 | }, []); 155 | 156 | return { 157 | createOrder, 158 | createSubscription, 159 | onApprove, 160 | onCancel, 161 | onError, 162 | onShippingChange, 163 | payment 164 | } 165 | 166 | } 167 | -------------------------------------------------------------------------------- /src/utils/usePaypalScript.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | import { usePaypalScript } from './usePaypalScript'; 3 | import { PaypalOptions } from '../types'; 4 | 5 | const props: PaypalOptions = { 6 | clientId: '12345', 7 | intent: 'capture' 8 | } 9 | 10 | describe('usePaypalMethods', () => { 11 | it('returns loading state', () => { 12 | const { result }= renderHook(() => usePaypalScript(props)) 13 | expect(result.current).toMatchSnapshot() 14 | }) 15 | }) -------------------------------------------------------------------------------- /src/utils/usePaypalScript.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | import { PaypalOptions } from '../types'; 4 | import { scriptLoadError, composeUrl } from '.'; 5 | 6 | type Props = { 7 | loading: boolean; 8 | done: boolean; 9 | error:boolean; 10 | } 11 | 12 | export function usePaypalScript(options: PaypalOptions): Props { 13 | let scriptCache: string[] = []; 14 | 15 | const url = composeUrl(options); 16 | 17 | const [loading, setLoading] = useState(true); 18 | const [error, setError] = useState(false); 19 | const [done, setDone] = useState(false); 20 | 21 | 22 | useEffect(() => { 23 | // early exit if cache is in script 24 | if(scriptCache.includes(url)){ 25 | setLoading(false) 26 | setDone(true), 27 | setError(false) 28 | } else { 29 | scriptCache.push(url) 30 | } 31 | 32 | let script = document.createElement('script') 33 | script.src = url; 34 | script.async = true; 35 | 36 | const onScriptLoad = () => { 37 | setLoading(false) 38 | setDone(true), 39 | setError(false) 40 | } 41 | 42 | const onScriptError = () => { 43 | // if we error out, retry by removing the url from the cache 44 | const urlIndex = scriptCache.indexOf(url); 45 | 46 | if(urlIndex !== -1){ 47 | scriptCache.splice(urlIndex, 1); 48 | script.remove() 49 | } 50 | 51 | console.error(scriptLoadError) 52 | 53 | setLoading(false) 54 | setDone(true), 55 | setError(true) 56 | } 57 | 58 | script.addEventListener('load', onScriptLoad); 59 | script.addEventListener('error', onScriptError); 60 | 61 | document.body.appendChild(script); 62 | 63 | return () => { 64 | script.removeEventListener('load', onScriptLoad); 65 | script.removeEventListener('error', onScriptError); 66 | } 67 | 68 | // rerun if url changes 69 | }, [url]); 70 | 71 | return { loading, error, done } 72 | } 73 | -------------------------------------------------------------------------------- /src/utils/usePaypalScriptOptions.ts: -------------------------------------------------------------------------------- 1 | import { PayPalButtonProps } from '../types'; 2 | import { usePaypalMethods } from '.'; 3 | 4 | export function usePaypalScriptOptions(props: PayPalButtonProps) { 5 | const { buttonStyles: style, amount, description } = props; 6 | const { 7 | createOrder, 8 | createSubscription, 9 | onApprove, 10 | onCancel, 11 | onError, 12 | onShippingChange, 13 | payment 14 | } = usePaypalMethods(props); 15 | 16 | return { 17 | style, 18 | amount, 19 | description, 20 | createOrder, 21 | createSubscription, 22 | onApprove, 23 | onCancel, 24 | onError, 25 | onShippingChange, 26 | payment, 27 | } 28 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "rootDir": ".", 5 | "outDir": "bin/types", 6 | "module": "es6", 7 | "target": "es2015", 8 | "lib": [ 9 | "es6", 10 | "dom", 11 | "esnext.asynciterable" 12 | ], 13 | "sourceMap": true, 14 | "allowJs": false, 15 | "jsx": "react", 16 | "moduleResolution": "node", 17 | "allowSyntheticDefaultImports": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "noImplicitReturns": true, 20 | "noImplicitThis": true, 21 | "noImplicitAny": false, 22 | "strictNullChecks": true, 23 | "suppressImplicitAnyIndexErrors": true, 24 | "noUnusedLocals": true, 25 | "skipLibCheck": true, 26 | }, 27 | "exclude": [ 28 | "bin", 29 | "example" 30 | ], 31 | "include": [ 32 | "src" 33 | ] 34 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-react" 4 | ], 5 | "exclude": [ 6 | "unit", 7 | "dist", 8 | "lib" 9 | ], 10 | "defaultSeverity": "warning", 11 | "rules": { 12 | "align": [ 13 | true, 14 | "parameters", 15 | "statements" 16 | ], 17 | "ban": false, 18 | "class-name": true, 19 | "comment-format": [ 20 | true, 21 | "check-space" 22 | ], 23 | "curly": true, 24 | "eofline": false, 25 | "forin": true, 26 | "indent": [ 27 | true, 28 | "spaces" 29 | ], 30 | "interface-name": [ 31 | true, 32 | "never-prefix" 33 | ], 34 | "jsdoc-format": true, 35 | "jsx-no-lambda": false, 36 | "jsx-no-multiline-js": false, 37 | "jsx-boolean-value": false, 38 | "label-position": true, 39 | "max-line-length": [ 40 | true, 41 | 120 42 | ], 43 | "member-ordering": [ 44 | true, 45 | "public-before-private", 46 | "static-before-instance", 47 | "variables-before-functions" 48 | ], 49 | "no-any": false, 50 | "no-arg": true, 51 | "no-bitwise": true, 52 | "no-console": [ 53 | false, 54 | "error", 55 | "debug", 56 | "info", 57 | "time", 58 | "timeEnd", 59 | "trace" 60 | ], 61 | "no-consecutive-blank-lines": false, 62 | "no-construct": true, 63 | "no-debugger": false, 64 | "no-duplicate-variable": true, 65 | "no-empty": true, 66 | "no-eval": true, 67 | "no-shadowed-variable": false, 68 | "no-string-literal": false, 69 | "no-switch-case-fall-through": true, 70 | "no-trailing-whitespace": false, 71 | "no-unused-expression": true, 72 | "no-use-before-declare": true, 73 | "one-line": [ 74 | true, 75 | "check-catch", 76 | "check-else", 77 | "check-open-brace" 78 | ], 79 | "quotemark": [ 80 | true, 81 | "single", 82 | "jsx-double" 83 | ], 84 | "radix": true, 85 | "semicolon": [ 86 | false 87 | ], 88 | "switch-default": true, 89 | "trailing-comma": [ 90 | false 91 | ], 92 | "triple-equals": [ 93 | true, 94 | "allow-null-check" 95 | ], 96 | "typedef": [ 97 | false, 98 | "parameter", 99 | "property-declaration" 100 | ], 101 | "typedef-whitespace": [ 102 | false, 103 | { 104 | "call-signature": "nospace", 105 | "index-signature": "nospace", 106 | "parameter": "nospace", 107 | "property-declaration": "nospace", 108 | "variable-declaration": "nospace" 109 | } 110 | ], 111 | "whitespace": [ 112 | false, 113 | "check-branch", 114 | "check-decl", 115 | "check-module", 116 | "check-operator", 117 | "check-separator", 118 | "check-type", 119 | "check-typecast" 120 | ] 121 | } 122 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const config = { 4 | entry: path.join(__dirname, 'src/index.ts'), 5 | externals: { 6 | react: 'react', 7 | }, 8 | mode: 'none', 9 | module: { 10 | rules: [ 11 | { 12 | test: /^(?!.*test\.tsx|\.ts?$).*\.tsx|\.ts?$/, 13 | exclude: /node_modules/, 14 | use: ['babel-loader'] 15 | }, 16 | { 17 | test: /^(?!.*test\.tsx|\.ts?$).*\.tsx|\.ts?$/, 18 | exclude: /node_modules/, 19 | use: ["source-map-loader"], 20 | enforce: "pre" 21 | }, 22 | ] 23 | }, 24 | output: { 25 | path: `${__dirname}/bin`, 26 | filename: 'index.min.js', 27 | library: 'react-paypal-button', 28 | libraryTarget: 'umd', 29 | umdNamedDefine: true, 30 | globalObject: "typeof self !== 'undefined' ? self : this", 31 | }, 32 | resolve: { 33 | extensions: ['.ts', '.tsx', '.js', '.jsx', '.d.ts'], 34 | modules: ['node_modules'] 35 | }, 36 | target: 'node' 37 | }; 38 | 39 | module.exports = config --------------------------------------------------------------------------------