├── .babelrc ├── .env.defaults ├── .env.development ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .idea └── workspace.xml ├── .nvmrc ├── .stylelintrc.json ├── .vscode ├── extensions.json └── settings.json ├── .yarn └── releases │ └── yarn-1.21.1.js ├── LICENSE ├── README.md ├── csp.js ├── extension-banner.png ├── package.json ├── source ├── assets │ ├── brands.png │ ├── icons │ │ ├── b-logo-blue.svg │ │ ├── bp-logo-blue.svg │ │ ├── close.svg │ │ ├── decrement-icon.svg │ │ ├── dots.svg │ │ ├── exit-icon.svg │ │ ├── expand-icon.svg │ │ ├── favicon-128.png │ │ ├── favicon-16.png │ │ ├── favicon-32.png │ │ ├── favicon-48.png │ │ ├── favicon-inactive-128.png │ │ ├── favicon-inactive-16.png │ │ ├── favicon-inactive-32.png │ │ ├── favicon-inactive-48.png │ │ ├── go-back-icon.svg │ │ ├── increment-icon.svg │ │ ├── info-icon-blue.svg │ │ ├── info-icon-white.svg │ │ ├── link-icon.svg │ │ ├── minimize-icon.svg │ │ ├── right-arrow.svg │ │ ├── search-clear-icon.svg │ │ ├── search-icon.svg │ │ ├── settings-icon--active.svg │ │ ├── settings-icon.svg │ │ ├── shop-icon--active.svg │ │ ├── shop-icon.svg │ │ ├── spinner-thick.svg │ │ ├── spinner-warn.svg │ │ ├── spinner.svg │ │ ├── wallet-icon--active.svg │ │ └── wallet-icon.svg │ ├── pay-with-bitpay.svg │ ├── sign-in-with-bitpay.svg │ ├── slot-dark.svg │ └── slot.svg ├── background │ └── index.ts ├── content-script │ ├── drag.ts │ └── index.ts ├── manifest │ ├── v2.js │ └── v3.js ├── options │ ├── index.tsx │ ├── options.tsx │ └── styles.scss ├── popup │ ├── components │ │ ├── action-button │ │ │ ├── action-button.scss │ │ │ └── action-button.tsx │ │ ├── card-denoms │ │ │ └── card-denoms.tsx │ │ ├── card-header │ │ │ ├── card-header.scss │ │ │ └── card-header.tsx │ │ ├── card-menu │ │ │ ├── card-menu.scss │ │ │ └── card-menu.tsx │ │ ├── code-box │ │ │ ├── code-box.scss │ │ │ └── code-box.tsx │ │ ├── discount-text │ │ │ └── discount-text.tsx │ │ ├── gravatar │ │ │ ├── gravatar.scss │ │ │ ├── gravatar.spec.tsx │ │ │ └── gravatar.tsx │ │ ├── ios-switch │ │ │ └── ios-switch.tsx │ │ ├── line-items │ │ │ ├── line-items.scss │ │ │ └── line-items.tsx │ │ ├── merchant-cell │ │ │ ├── merchant-cell.scss │ │ │ └── merchant-cell.tsx │ │ ├── merchant-cta │ │ │ ├── merchant-cta.scss │ │ │ └── merchant-cta.tsx │ │ ├── navbar │ │ │ ├── back-button │ │ │ │ ├── back-button.scss │ │ │ │ └── back-button.tsx │ │ │ ├── bp-logo │ │ │ │ ├── bp-logo.scss │ │ │ │ └── bp-logo.tsx │ │ │ ├── navbar.scss │ │ │ ├── navbar.tsx │ │ │ └── toggle │ │ │ │ ├── toggle.scss │ │ │ │ └── toggle.tsx │ │ ├── pay-with-bitpay │ │ │ ├── pay-with-bitpay.scss │ │ │ └── pay-with-bitpay.tsx │ │ ├── search-bar │ │ │ ├── search-bar.scss │ │ │ ├── search-bar.spec.tsx │ │ │ └── search-bar.tsx │ │ ├── snack │ │ │ ├── snack.scss │ │ │ └── snack.tsx │ │ ├── super-toast │ │ │ ├── super-toast.scss │ │ │ ├── super-toast.spec.tsx │ │ │ └── super-toast.tsx │ │ ├── svg │ │ │ ├── pay-with-bitpay-image.tsx │ │ │ └── sign-in-with-bitpay-image.tsx │ │ ├── tabs │ │ │ ├── tabs.scss │ │ │ └── tabs.tsx │ │ └── wallet-cards │ │ │ ├── __tests__ │ │ │ ├── wallet-card.spec.tsx │ │ │ └── wallet-cards.spec.tsx │ │ │ ├── wallet-card.scss │ │ │ ├── wallet-card.tsx │ │ │ └── wallet-cards.tsx │ ├── index.tsx │ ├── pages │ │ ├── amount │ │ │ ├── amount.scss │ │ │ ├── amount.spec.tsx │ │ │ └── amount.tsx │ │ ├── brand │ │ │ ├── brand.scss │ │ │ └── brand.tsx │ │ ├── card │ │ │ ├── balance │ │ │ │ ├── balance.scss │ │ │ │ └── balance.tsx │ │ │ ├── card.scss │ │ │ └── card.tsx │ │ ├── cards │ │ │ ├── cards.scss │ │ │ └── cards.tsx │ │ ├── category │ │ │ ├── category.scss │ │ │ └── category.tsx │ │ ├── country │ │ │ ├── country.scss │ │ │ └── country.tsx │ │ ├── payment │ │ │ ├── payment.scss │ │ │ └── payment.tsx │ │ ├── phone │ │ │ └── phone.tsx │ │ ├── settings │ │ │ ├── account │ │ │ │ ├── account.scss │ │ │ │ └── account.tsx │ │ │ ├── archive │ │ │ │ ├── archive.scss │ │ │ │ └── archive.tsx │ │ │ ├── email │ │ │ │ └── email.tsx │ │ │ ├── legal │ │ │ │ └── legal.tsx │ │ │ ├── settings.scss │ │ │ └── settings.tsx │ │ ├── shop │ │ │ ├── shop.scss │ │ │ └── shop.tsx │ │ └── wallet │ │ │ ├── wallet.scss │ │ │ └── wallet.tsx │ ├── popup.tsx │ └── styles.scss ├── services │ ├── analytics.ts │ ├── animations.ts │ ├── bitpay-id.ts │ ├── browser.ts │ ├── copy-util.ts │ ├── currency.ts │ ├── directory.ts │ ├── frame.ts │ ├── gift-card-storage.ts │ ├── gift-card.ts │ ├── gift-card.types.ts │ ├── merchant.ts │ ├── phone.ts │ ├── storage.ts │ └── utils.ts ├── setupTests.ts ├── styles │ ├── _animations.scss │ ├── _fonts.scss │ ├── _reset.scss │ └── _variables.scss ├── testData.ts └── typings.d.ts ├── tsconfig.json ├── views ├── options.html └── popup.html ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | // Latest stable ECMAScript features 5 | "@babel/preset-env", 6 | { 7 | "useBuiltIns": false, 8 | // Do not transform modules to CJS 9 | "modules": false, 10 | "targets": { 11 | "chrome": "49", 12 | "firefox": "52", 13 | "opera": "36", 14 | "edge": "79" 15 | } 16 | } 17 | ], 18 | "@babel/typescript", 19 | "@babel/react" 20 | ], 21 | "plugins": [ 22 | ["@babel/plugin-proposal-class-properties"], 23 | ["@babel/plugin-transform-destructuring", { 24 | "useBuiltIns": true 25 | }], 26 | ["@babel/plugin-proposal-object-rest-spread", { 27 | "useBuiltIns": true 28 | }], 29 | [ 30 | // Polyfills the runtime needed for async/await and generators 31 | "@babel/plugin-transform-runtime", 32 | { 33 | "helpers": false, 34 | "regenerator": true 35 | } 36 | ] 37 | ] 38 | } -------------------------------------------------------------------------------- /.env.defaults: -------------------------------------------------------------------------------- 1 | API_ORIGIN=https://bitpay.com 2 | GA_UA=UA-24163874-26 3 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | API_ORIGIN=https://bitpay.com 2 | GA_UA=UA-24163874-24 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | extension/ 4 | .yarn/ 5 | .pnp.js -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@abhijithvijayan/eslint-config/typescript", 4 | "@abhijithvijayan/eslint-config/node", 5 | "@abhijithvijayan/eslint-config/react" 6 | ], 7 | "parserOptions": { 8 | "project": [ 9 | "./tsconfig.json" 10 | ], 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "arrow-parens": ["error", "as-needed"], 15 | "comma-dangle": ["error", "never"], 16 | "no-console": "off", 17 | "no-extend-native": "off", 18 | "react/jsx-props-no-spreading": "off", 19 | "jsx-a11y/label-has-associated-control": "off", 20 | "class-methods-use-this": "off", 21 | "max-classes-per-file": "off", 22 | "no-trailing-spaces": ["error", { "skipBlankLines": true }], 23 | "node/no-missing-import": "off", 24 | "node/no-unpublished-import": "off", 25 | "node/no-unsupported-features/es-syntax": ["error", { 26 | "ignores": ["modules"] 27 | }], 28 | "object-curly-spacing": [ "error", "always" ], 29 | "prettier/prettier": "off" 30 | }, 31 | "env": { 32 | "webextensions": true 33 | }, 34 | "settings": { 35 | "node": { 36 | "tryExtensions": [".tsx"] // append tsx to the list as well 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ignore haters 2 | haters/ 3 | .DS_Store 4 | src/.DS_Store 5 | 6 | 7 | ### Node ### 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # TypeScript v1 declaration files 52 | typings/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # next.js build output 80 | .next 81 | 82 | # nuxt.js build output 83 | .nuxt 84 | 85 | # react / gatsby 86 | public/ 87 | 88 | # vuepress build output 89 | .vuepress/dist 90 | 91 | # Serverless directories 92 | .serverless/ 93 | 94 | # FuseBox cache 95 | .fusebox/ 96 | 97 | # DynamoDB Local files 98 | .dynamodb/ 99 | 100 | ### Sass ### 101 | .sass-cache/ 102 | *.css.map 103 | *.sass.map 104 | *.scss.map 105 | 106 | ## Build directory 107 | extension/ 108 | dist/ 109 | .awcache 110 | 111 | # yarn 2 112 | # https://github.com/yarnpkg/berry/issues/454#issuecomment-530312089 113 | .yarn/* 114 | !.yarn/releases 115 | !.yarn/plugins 116 | .pnp.* 117 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.18.0 -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-prettier/recommended"] 3 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "eamodio.gitlens", 6 | "streetsidesoftware.code-spell-checker", 7 | "stylelint.vscode-stylelint", 8 | ] 9 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit", 4 | "source.fixAll.stylelint": "explicit" 5 | }, 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2024 BitPay, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

5 | Discover new ways to use crypto 6 |

7 |

8 | Be alerted whenever a website you visit offers crypto as a payment option. 9 |
10 | Pay directly at checkout, or purchase and manage store credit through the app. 11 |

12 |

13 |

14 | 15 |

16 | 17 |
18 | 19 | ## Browser Support 20 | 21 | | [![Chrome](https://raw.github.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png)](https://chrome.google.com/webstore/detail/pay-with-bitpay/jkjgekcefbkpogohigkgooodolhdgcda) | [![Firefox](https://raw.github.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png)](https://addons.mozilla.org/en-US/firefox/addon/pay-with-bitpay/) | [![Brave](https://raw.github.com/alrra/browser-logos/master/src/brave/brave_48x48.png)](https://chrome.google.com/webstore/detail/pay-with-bitpay/jkjgekcefbkpogohigkgooodolhdgcda) | [![Opera](https://raw.github.com/alrra/browser-logos/master/src/opera/opera_48x48.png)](https://addons.opera.com/en/extensions/details/pay-with-bitpay/) | 22 | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 23 | | 49 & later ✔ | 52 & later ✔ | 36 & later ✔ | 79 & later ✔ 24 | 25 | ## 🚀 Quick Start 26 | 27 | Ensure you have 28 | - [Node.js](https://nodejs.org) 10 or later installed 29 | - [Yarn](https://yarnpkg.com) v1 or v2 installed 30 | 31 | Then run the following: 32 | - `yarn install` to install dependencies. 33 | - `yarn run dev:chrome` to start the development server for chrome extension 34 | - `yarn run dev:firefox` to start the development server for firefox addon 35 | - `yarn run dev:opera` to start the development server for opera extension 36 | - `yarn run build:chrome` to build chrome extension 37 | - `yarn run build:firefox` to build firefox addon 38 | - `yarn run build:opera` to build opera extension 39 | - `yarn run build` builds and packs extensions all at once to extension/ directory 40 | 41 | ### Development 42 | 43 | - `yarn install` to install dependencies. 44 | - To watch file changes in development (please note that Hot Module Replacement is currently only supported for Manifest v2 builds): 45 | 46 | - Chrome 47 | - `yarn run dev:chrome` 48 | - Firefox 49 | - `yarn run dev:firefox` 50 | - Opera 51 | - `yarn run dev:opera` 52 | 53 | - **Load extension in browser** 54 | 55 | - ### Chrome 56 | 57 | - Go to the browser address bar and type `chrome://extensions` 58 | - Check the `Developer Mode` button to enable it. 59 | - Click on the `Load Unpacked Extension…` button. 60 | - Select your extension’s extracted directory. 61 | 62 | - ### Firefox 63 | 64 | - Load the Add-on via `about:debugging` as temporary Add-on. 65 | - Choose the `manifest.json` file in the extracted directory 66 | 67 | - ### Opera 68 | 69 | - Load the extension via `opera:extensions` 70 | - Check the `Developer Mode` and load as unpacked from extension’s extracted directory. 71 | 72 | 73 | ### Enabling testnet payments 74 | Change your `.env.development` file to the following: 75 | 76 | ```bash 77 | API_ORIGIN=https://test.bitpay.com 78 | ``` 79 | 80 | ### Generating browser specific manifest.json 81 | See the original [README](https://github.com/abhijithvijayan/wext-manifest) of wext-manifest package for more details 82 | 83 | ### Production 84 | 85 | - `yarn run build` builds the extension for all the browsers to `extension/BROWSER` directory respectively. 86 | 87 | ## Show your support 88 | 89 | Give a ⭐️ if this project helped you! 90 | 91 | ## License 92 | 93 | Code released under the [MIT License](LICENSE). 94 | -------------------------------------------------------------------------------- /csp.js: -------------------------------------------------------------------------------- 1 | const apiOrigin = process.env.API_ORIGIN; 2 | const isProd = process.env.NODE_ENV === 'production'; 3 | const isFirefox = process.env.TARGET_BROWSER === 'firefox'; 4 | 5 | const cspObject = { 6 | 'default-src': ["'self'", apiOrigin], 7 | 'base-uri': ["'self'"], 8 | 'connect-src': [ 9 | apiOrigin, 10 | ...(isFirefox ? [] : ['https://www.google-analytics.com']), 11 | ...(isProd ? [] : ['ws:']) 12 | ], 13 | 'img-src': ['https://gravatar.com', 'https://*.wp.com', apiOrigin], 14 | 'font-src': ['https://fonts.gstatic.com'], 15 | 'object-src': ["'self'"], 16 | 'script-src': ["'self'"], 17 | 'style-src': ["'self'", 'https://fonts.googleapis.com/', "'unsafe-inline'"] 18 | }; 19 | 20 | const buildPolicy = policyObj => 21 | Object.keys(policyObj) 22 | .map(key => `${key} ${policyObj[key].join(' ')}`) 23 | .join('; '); 24 | 25 | const cspString = buildPolicy(cspObject); 26 | 27 | module.exports = { 28 | cspObject, 29 | cspString 30 | }; 31 | -------------------------------------------------------------------------------- /extension-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitpay/bitpay-browser-extension/cb67b29ec2e722a78dbe24d038cfcd9632080ab4/extension-banner.png -------------------------------------------------------------------------------- /source/assets/brands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitpay/bitpay-browser-extension/cb67b29ec2e722a78dbe24d038cfcd9632080ab4/source/assets/brands.png -------------------------------------------------------------------------------- /source/assets/icons/b-logo-blue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /source/assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Path 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /source/assets/icons/decrement-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /source/assets/icons/dots.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 3 dots 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /source/assets/icons/exit-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /source/assets/icons/expand-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /source/assets/icons/favicon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitpay/bitpay-browser-extension/cb67b29ec2e722a78dbe24d038cfcd9632080ab4/source/assets/icons/favicon-128.png -------------------------------------------------------------------------------- /source/assets/icons/favicon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitpay/bitpay-browser-extension/cb67b29ec2e722a78dbe24d038cfcd9632080ab4/source/assets/icons/favicon-16.png -------------------------------------------------------------------------------- /source/assets/icons/favicon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitpay/bitpay-browser-extension/cb67b29ec2e722a78dbe24d038cfcd9632080ab4/source/assets/icons/favicon-32.png -------------------------------------------------------------------------------- /source/assets/icons/favicon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitpay/bitpay-browser-extension/cb67b29ec2e722a78dbe24d038cfcd9632080ab4/source/assets/icons/favicon-48.png -------------------------------------------------------------------------------- /source/assets/icons/favicon-inactive-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitpay/bitpay-browser-extension/cb67b29ec2e722a78dbe24d038cfcd9632080ab4/source/assets/icons/favicon-inactive-128.png -------------------------------------------------------------------------------- /source/assets/icons/favicon-inactive-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitpay/bitpay-browser-extension/cb67b29ec2e722a78dbe24d038cfcd9632080ab4/source/assets/icons/favicon-inactive-16.png -------------------------------------------------------------------------------- /source/assets/icons/favicon-inactive-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitpay/bitpay-browser-extension/cb67b29ec2e722a78dbe24d038cfcd9632080ab4/source/assets/icons/favicon-inactive-32.png -------------------------------------------------------------------------------- /source/assets/icons/favicon-inactive-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitpay/bitpay-browser-extension/cb67b29ec2e722a78dbe24d038cfcd9632080ab4/source/assets/icons/favicon-inactive-48.png -------------------------------------------------------------------------------- /source/assets/icons/go-back-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /source/assets/icons/increment-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /source/assets/icons/info-icon-blue.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /source/assets/icons/info-icon-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /source/assets/icons/link-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /source/assets/icons/minimize-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /source/assets/icons/right-arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Path 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /source/assets/icons/search-clear-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /source/assets/icons/search-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /source/assets/icons/settings-icon--active.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /source/assets/icons/settings-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /source/assets/icons/shop-icon--active.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /source/assets/icons/shop-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /source/assets/icons/spinner-thick.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /source/assets/icons/spinner-warn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Path 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /source/assets/icons/spinner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Path 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /source/assets/icons/wallet-icon--active.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /source/assets/icons/wallet-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /source/assets/slot-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | grey slot 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /source/assets/slot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /source/content-script/drag.ts: -------------------------------------------------------------------------------- 1 | import { browser } from 'webextension-polyfill-ts'; 2 | import { FrameDimensions } from '../services/frame'; 3 | 4 | export type NavbarMode = 'default' | 'pay'; 5 | 6 | export interface DragMethods { 7 | onNavbarModeChange: (mode: NavbarMode) => void; 8 | } 9 | 10 | export function dragElementFunc(iframe: HTMLIFrameElement | undefined, dragEle: HTMLElement): DragMethods { 11 | let pos1 = 0; 12 | let pos2 = 0; 13 | let pos3 = 0; 14 | let pos4 = 0; 15 | const windowInnerHeight = window.innerHeight; 16 | const windowInnerWidth = window.innerWidth; 17 | const padding = 10; 18 | let navbarMode: NavbarMode = 'default'; 19 | let rect: ClientRect; 20 | const viewport = { 21 | bottom: 0, 22 | left: 0, 23 | right: 0, 24 | top: 0 25 | }; 26 | 27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 28 | function elementDrag(e: any): void { 29 | // eslint-disable-next-line no-param-reassign 30 | e = e || window.event; 31 | e.preventDefault(); 32 | // calculate the new cursor position: 33 | pos1 = pos3 - e.clientX; 34 | pos2 = pos4 - e.clientY; 35 | pos3 = e.clientX; 36 | pos4 = e.clientY; 37 | 38 | const newLeft = dragEle.offsetLeft - pos1; 39 | const newTop = dragEle.offsetTop - pos2; 40 | const leftBound = newLeft < viewport.left; 41 | const topBound = newTop < viewport.top; 42 | const rightBound = newLeft + rect.width > viewport.right; 43 | const bottomBound = newTop + rect.height > viewport.bottom; 44 | 45 | if (leftBound || topBound || rightBound || bottomBound) { 46 | if (bottomBound || topBound) { 47 | const left = leftBound || rightBound ? dragEle.style.left : newLeft; 48 | const top = bottomBound ? windowInnerHeight - rect.height - padding : 10; 49 | browser.runtime.sendMessage({ 50 | name: 'RESET_FRAME_POSITION', 51 | top, 52 | left 53 | }); 54 | dragEle.style.top = `${top}px`; 55 | dragEle.style.left = `${left}px`; 56 | } 57 | 58 | if (rightBound || leftBound) { 59 | const top = topBound || bottomBound ? dragEle.style.top : newTop; 60 | const left = rightBound ? dragEle.style.left : 10; 61 | browser.runtime.sendMessage({ 62 | name: 'RESET_FRAME_POSITION', 63 | top, 64 | left 65 | }); 66 | dragEle.style.top = `${top}px`; 67 | dragEle.style.left = `${left}px`; 68 | } 69 | } else { 70 | // set the element's new position: 71 | browser.runtime.sendMessage({ name: 'RESET_FRAME_POSITION', top: newTop, left: newLeft }); 72 | dragEle.style.top = `${newTop}px`; 73 | dragEle.style.left = `${newLeft}px`; 74 | } 75 | } 76 | 77 | function resizeAndRepositionDragElement(): void { 78 | const leftOffset = navbarMode === 'pay' ? '120px' : '75px'; 79 | const width = navbarMode === 'pay' ? '110px' : '145px'; 80 | if (dragEle.style.left) { 81 | dragEle.style.left = `calc(${(iframe as HTMLIFrameElement).style.left} + ${leftOffset})`; 82 | } else { 83 | dragEle.style.right = '75px'; 84 | } 85 | dragEle.style.width = width; 86 | } 87 | 88 | function closeDragElement(): void { 89 | dragEle.style.height = `${FrameDimensions.collapsedHeight}px`; 90 | dragEle.style.width = '115px'; 91 | dragEle.style.cursor = 'grab'; 92 | 93 | resizeAndRepositionDragElement(); 94 | 95 | if (iframe) { 96 | iframe.style.transform = 'translate3d(0px, 0px, 0px)'; 97 | iframe.style.boxShadow = '0 0 12px 4px rgba(0,0,0,0.1)'; 98 | } 99 | 100 | // stop moving when mouse button is released: 101 | document.onmouseup = null; 102 | document.onmousemove = null; 103 | } 104 | 105 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 106 | function dragMouseDown(e: any): void { 107 | if (iframe) { 108 | dragEle.style.height = iframe.style.height; 109 | dragEle.style.width = iframe.style.width; 110 | dragEle.style.right = iframe.style.right; 111 | dragEle.style.left = iframe.style.left; 112 | dragEle.style.cursor = 'grabbing'; 113 | iframe.style.transform = 'translate3d(0px, -2px, 0px) scale(1.01)'; 114 | iframe.style.boxShadow = '0 2px 18px 8px rgba(0,0,0,0.08)'; 115 | } 116 | // eslint-disable-next-line no-param-reassign 117 | e = e || window.event; 118 | e.preventDefault(); 119 | // get the mouse cursor position at startup: 120 | pos3 = e.clientX; 121 | pos4 = e.clientY; 122 | rect = dragEle.getBoundingClientRect(); 123 | viewport.bottom = windowInnerHeight - padding; 124 | viewport.left = padding; 125 | viewport.right = windowInnerWidth - padding; 126 | viewport.top = padding; 127 | document.onmouseup = closeDragElement; 128 | // call a function whenever the cursor moves: 129 | document.onmousemove = elementDrag; 130 | } 131 | dragEle.onmousedown = dragMouseDown; 132 | resizeAndRepositionDragElement(); 133 | 134 | return { 135 | onNavbarModeChange: (mode): void => { 136 | navbarMode = mode; 137 | resizeAndRepositionDragElement(); 138 | } 139 | }; 140 | } 141 | -------------------------------------------------------------------------------- /source/manifest/v2.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const pkg = require('../../package.json'); 3 | const csp = require('../../csp.js'); 4 | 5 | const manifestInput = { 6 | manifest_version: 2, 7 | name: 'Pay with BitPay', 8 | version: pkg.version, 9 | 10 | icons: { 11 | 16: 'assets/icons/favicon-16.png', 12 | 32: 'assets/icons/favicon-32.png', 13 | 48: 'assets/icons/favicon-48.png', 14 | 128: 'assets/icons/favicon-128.png' 15 | }, 16 | 17 | description: 'Spend crypto instantly', 18 | homepage_url: 'https://github.com/bitpay/bitpay-browser-extension', 19 | short_name: 'Pay with BitPay', 20 | 21 | permissions: ['activeTab', 'storage', 'http://*/*', 'https://*/*'], 22 | content_security_policy: csp.cspString, 23 | 24 | '__chrome|firefox__author': 'bitpay', 25 | __opera__developer: { 26 | name: 'bitpay' 27 | }, 28 | 29 | __firefox__applications: { 30 | gecko: { 31 | id: '{854FB1AD-CC3B-4856-B6A0-7786F8CA9D17}' 32 | } 33 | }, 34 | 35 | __chrome__minimum_chrome_version: '49', 36 | __opera__minimum_opera_version: '36', 37 | 38 | browser_action: { 39 | default_icon: { 40 | 16: 'assets/icons/favicon-16.png', 41 | 32: 'assets/icons/favicon-32.png', 42 | 48: 'assets/icons/favicon-48.png', 43 | 128: 'assets/icons/favicon-128.png' 44 | }, 45 | default_title: 'Pay with BitPay', 46 | '__chrome|opera__chrome_style': false, 47 | __firefox__browser_style: false 48 | }, 49 | 50 | // '__chrome|opera__options_page': 'options.html', 51 | 52 | // options_ui: { 53 | // page: 'options.html', 54 | // open_in_tab: true, 55 | // __chrome__chrome_style: false 56 | // }, 57 | 58 | background: { 59 | scripts: ['js/background.bundle.js'], 60 | '__chrome|opera__persistent': false 61 | }, 62 | 63 | content_scripts: [ 64 | { 65 | matches: ['http://*/*', 'https://*/*'], 66 | js: ['js/contentScript.bundle.js'] 67 | } 68 | ], 69 | 70 | web_accessible_resources: ['popup.html'] 71 | }; 72 | 73 | module.exports = manifestInput; 74 | -------------------------------------------------------------------------------- /source/manifest/v3.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const pkg = require('../../package.json'); 3 | const csp = require('../../csp.js'); 4 | 5 | const manifestInput = { 6 | manifest_version: 3, 7 | name: 'Pay with BitPay', 8 | version: pkg.version, 9 | 10 | icons: { 11 | 16: 'assets/icons/favicon-16.png', 12 | 32: 'assets/icons/favicon-32.png', 13 | 48: 'assets/icons/favicon-48.png', 14 | 128: 'assets/icons/favicon-128.png' 15 | }, 16 | 17 | description: 'Spend crypto instantly', 18 | homepage_url: 'https://github.com/bitpay/bitpay-browser-extension', 19 | short_name: 'BitPay', 20 | 21 | permissions: ['activeTab', 'storage', 'scripting'], 22 | host_permissions: ['http://*/*', 'https://*/*'], 23 | 24 | content_security_policy: { 25 | extension_pages: csp.cspString 26 | }, 27 | 28 | '__chrome|firefox__author': 'BitPay', 29 | __opera__developer: { 30 | name: 'BitPay' 31 | }, 32 | 33 | __firefox__applications: { 34 | gecko: { 35 | id: '{854FB1AD-CC3B-4856-B6A0-7786F8CA9D17}' 36 | } 37 | }, 38 | 39 | __chrome__minimum_chrome_version: '88', 40 | __opera__minimum_opera_version: '36', 41 | 42 | action: { 43 | default_icon: { 44 | 16: 'assets/icons/favicon-16.png', 45 | 32: 'assets/icons/favicon-32.png', 46 | 48: 'assets/icons/favicon-48.png', 47 | 128: 'assets/icons/favicon-128.png' 48 | }, 49 | default_title: 'Pay with BitPay', 50 | '__chrome|opera__chrome_style': false, 51 | __firefox__browser_style: false 52 | }, 53 | 54 | background: { 55 | service_worker: 'js/background.bundle.js', 56 | type: 'module' 57 | }, 58 | 59 | content_scripts: [ 60 | { 61 | matches: ['http://*/*', 'https://*/*'], 62 | js: ['js/contentScript.bundle.js'] 63 | } 64 | ], 65 | 66 | web_accessible_resources: [ 67 | { 68 | resources: ['popup.html'], 69 | matches: ['*://*/*'] 70 | } 71 | ] 72 | }; 73 | 74 | module.exports = manifestInput; 75 | -------------------------------------------------------------------------------- /source/options/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import Options from './options'; 5 | 6 | ReactDOM.render(, document.getElementById('options-root')); 7 | -------------------------------------------------------------------------------- /source/options/options.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './styles.scss'; 4 | 5 | const Options: React.FC = () => { 6 | return
Hello World
; 7 | }; 8 | 9 | export default Options; 10 | -------------------------------------------------------------------------------- /source/options/styles.scss: -------------------------------------------------------------------------------- 1 | @import "../styles/fonts"; 2 | @import "../styles/reset"; 3 | @import "../styles/variables"; 4 | 5 | body { 6 | color: black; 7 | background-color: white; 8 | } 9 | -------------------------------------------------------------------------------- /source/popup/components/action-button/action-button.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .action-button { 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | width: 100%; 8 | padding: 12px; 9 | color: white; 10 | font-size: 16px; 11 | font-weight: $bold; 12 | background-color: $blue; 13 | border-radius: 8px; 14 | cursor: pointer; 15 | user-select: none; 16 | 17 | &--warn { 18 | color: $warning; 19 | background-color: rgba($color: $warning, $alpha: 0.1); 20 | font-weight: $medium; 21 | padding: 18px; 22 | } 23 | 24 | &--danger { 25 | color: $caution; 26 | background-color: rgba($color: $caution, $alpha: 0.1); 27 | font-weight: $medium; 28 | padding: 18px; 29 | } 30 | 31 | &__footer { 32 | margin: 10px 4px 0; 33 | &--fixed { 34 | position: fixed; 35 | bottom: 0; 36 | width: 100%; 37 | background-color: white; 38 | padding: 14px 14px 18px; 39 | display: flex; 40 | flex-direction: column; 41 | justify-content: center; 42 | box-shadow: 0 -2px 8px 0 rgba(black, 0.04); 43 | text-align: center; 44 | 45 | a { 46 | width: 100%; 47 | } 48 | } 49 | a { 50 | width: 100%; 51 | } 52 | } 53 | 54 | &__spinner { 55 | animation: spin 1s linear infinite; 56 | margin-right: 12px; 57 | margin-top: -2px; 58 | } 59 | 60 | &--light { 61 | background: $lightBlue; 62 | color: $blue; 63 | font-size: 16px; 64 | font-weight: $medium; 65 | padding: 10px 0; 66 | } 67 | 68 | &--pending { 69 | background: $lightBlue; 70 | color: $blue; 71 | font-size: 16px; 72 | font-weight: $medium; 73 | padding: 10px 0; 74 | height: 60px; 75 | width: 270px; 76 | margin-top: 0; 77 | } 78 | } 79 | 80 | .secondary-button { 81 | color: $blue; 82 | font-weight: $bold; 83 | font-size: 16px; 84 | padding-top: 14px; 85 | } 86 | 87 | button { 88 | &[disabled] { 89 | opacity: 0.6 !important; 90 | pointer-events: none; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /source/popup/components/action-button/action-button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './action-button.scss'; 3 | 4 | import { motion } from 'framer-motion'; 5 | 6 | function setFlavor(flavor?: string): string { 7 | switch (flavor) { 8 | case 'warn': 9 | return ' action-button--warn'; 10 | case 'danger': 11 | return ' action-button--danger'; 12 | case 'light': 13 | return ' action-button--light'; 14 | default: 15 | return ''; 16 | } 17 | } 18 | 19 | const ActionButton: React.FC<{ 20 | onClick?: () => void; 21 | flavor?: string; 22 | disabled?: boolean; 23 | type?: 'button' | 'submit' | 'reset'; 24 | children?: unknown; 25 | }> = ({ onClick, children, flavor, disabled, type = 'button' }) => ( 26 | 33 | {children} 34 | 35 | ); 36 | 37 | export default ActionButton; 38 | -------------------------------------------------------------------------------- /source/popup/components/card-denoms/card-denoms.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { spreadAmounts } from '../../../services/merchant'; 3 | import { CardConfig } from '../../../services/gift-card.types'; 4 | import { currencySymbols } from '../../../services/currency'; 5 | 6 | const CardDenoms: React.FC<{ cardConfig: CardConfig }> = ({ cardConfig }) => ( 7 | <> 8 | {cardConfig.minAmount && cardConfig.maxAmount && ( 9 | <> 10 | {currencySymbols[cardConfig.currency] ? ( 11 | <> 12 | {currencySymbols[cardConfig.currency]} 13 | {cardConfig.minAmount} - {currencySymbols[cardConfig.currency]} 14 | {cardConfig.maxAmount} 15 | 16 | ) : ( 17 | <> 18 | {cardConfig.minAmount} {cardConfig.currency} - {cardConfig.maxAmount} {cardConfig.currency} 19 | 20 | )} 21 | 22 | )} 23 | {cardConfig.supportedAmounts && <>{spreadAmounts(cardConfig.supportedAmounts, cardConfig.currency)}} 24 | 25 | ); 26 | 27 | export default CardDenoms; 28 | -------------------------------------------------------------------------------- /source/popup/components/card-header/card-header.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .card-header { 4 | margin-bottom: 20px; 5 | &__title { 6 | font-size: 15px; 7 | color: $slateDark; 8 | font-weight: $medium; 9 | margin-bottom: 3px; 10 | margin-top: 3px; 11 | text-align: center; 12 | } 13 | 14 | &__balance { 15 | color: $black; 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | font-size: 32px; 20 | font-weight: $exbold; 21 | 22 | > img { 23 | height: 25px; 24 | width: 25px; 25 | border-radius: 50%; 26 | margin-right: 10px; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /source/popup/components/card-header/card-header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { motion } from 'framer-motion'; 3 | import { counterPunch } from '../../../services/animations'; 4 | import { CardConfig, UnsoldGiftCard, GiftCard } from '../../../services/gift-card.types'; 5 | import { formatCurrency } from '../../../services/currency'; 6 | import './card-header.scss'; 7 | 8 | const CardHeader: React.FC<{ amount?: number; card: Partial & UnsoldGiftCard; cardConfig: CardConfig }> = ({ 9 | amount, 10 | card, 11 | cardConfig 12 | }) => ( 13 | 14 |
{cardConfig.displayName}
15 |
16 | {`${cardConfig.displayName} 17 | {formatCurrency(typeof amount === 'undefined' ? card.amount : amount, card.currency, { hideSymbol: true })} 18 |
19 |
20 | ); 21 | 22 | export default CardHeader; 23 | -------------------------------------------------------------------------------- /source/popup/components/card-menu/card-menu.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .card-menu { 4 | div { 5 | background: $oceanBlue; 6 | border-radius: 6px; 7 | margin-left: 5px; 8 | margin-top: -8px; 9 | } 10 | 11 | &__icon { 12 | padding: 15px; 13 | padding-top: 6px; 14 | position: absolute; 15 | right: 0; 16 | top: 0; 17 | z-index: 1; 18 | } 19 | 20 | &__item { 21 | @at-root li#{&} { 22 | color: white; 23 | font-size: 12px; 24 | font-weight: $medium; 25 | justify-content: flex-start; 26 | padding-right: 14px; 27 | padding-left: 14px; 28 | padding-top: 0; 29 | padding-bottom: 0; 30 | min-height: 27px; 31 | 32 | @for $i from 1 through 4 { 33 | &:nth-child(#{$i}) { 34 | color: rgba( 35 | $color: ( 36 | white 37 | ), 38 | $alpha: 39 | 1 - 40 | ( 41 | $i - 1 42 | ) * 43 | 0.1 44 | ); 45 | } 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /source/popup/components/card-menu/card-menu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './card-menu.scss'; 3 | 4 | import Menu from '@material-ui/core/Menu'; 5 | import MenuItem from '@material-ui/core/MenuItem'; 6 | import { usePopupState, bindTrigger, bindMenu } from 'material-ui-popup-state/hooks'; 7 | 8 | const CardMenu: React.FC<{ items: string[]; onClick: (arg0: string) => void }> = ({ items, onClick }) => { 9 | const popupState = usePopupState({ variant: 'popover', popupId: 'cardActions' }); 10 | const itemClick = (item: string) => (): void => { 11 | onClick(item); 12 | popupState.close(); 13 | }; 14 | return ( 15 | <> 16 | 19 | 26 | {items.map((option: string, index: number) => ( 27 | 28 | {option} 29 | 30 | ))} 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default CardMenu; 37 | -------------------------------------------------------------------------------- /source/popup/components/code-box/code-box.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .code-box { 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | justify-content: center; 8 | background: #f4f6fa; 9 | border-radius: 10px; 10 | padding: 20px 0; 11 | width: 100%; 12 | cursor: pointer; 13 | 14 | &--wrapper { 15 | margin: 0 10px; 16 | } 17 | 18 | &__label { 19 | font-size: 12px; 20 | color: rgba($color: $slateDark, $alpha: 0.75); 21 | position: absolute; 22 | top: 0; 23 | 24 | &--action { 25 | color: $blue; 26 | font-weight: $medium; 27 | } 28 | &--wrapper { 29 | display: flex; 30 | justify-content: center; 31 | position: relative; 32 | margin: 5px auto 0; 33 | height: 18px; 34 | width: 195px; 35 | } 36 | } 37 | 38 | &__value { 39 | font-size: 20px; 40 | font-weight: $bold; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /source/popup/components/code-box/code-box.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useEffect, useRef } from 'react'; 2 | import { useTracking } from 'react-tracking'; 3 | import { motion } from 'framer-motion'; 4 | import copyUtil from '../../../services/copy-util'; 5 | import { wait } from '../../../services/utils'; 6 | import './code-box.scss'; 7 | 8 | const animateLabels = { 9 | base: { 10 | opacity: 1, 11 | y: 0 12 | }, 13 | delta: { 14 | opacity: 0, 15 | y: 8 16 | } 17 | }; 18 | 19 | const CodeBox: React.FC<{ code: string; label: string }> = ({ code, label }) => { 20 | const tracking = useTracking(); 21 | const mountedRef = useRef(true); 22 | const [hovering, setHovering] = useState(false); 23 | const [copied, setCopied] = useState(false); 24 | const startCopying = useCallback(async () => { 25 | copyUtil(code); 26 | if (copied) return; 27 | setCopied(true); 28 | tracking.trackEvent({ action: 'copiedValue', label, gaAction: `copiedValue:${label}` }); 29 | await wait(1500); 30 | if (mountedRef.current) setCopied(false); 31 | }, [copied, code, label, tracking]); 32 | const changeHovering = useCallback( 33 | (val: boolean) => (): void => { 34 | setHovering(val); 35 | }, 36 | [] 37 | ); 38 | useEffect( 39 | () => (): void => { 40 | mountedRef.current = false; 41 | }, 42 | [] 43 | ); 44 | return ( 45 |
46 | 55 | 56 | {code} 57 | 58 |
59 | 64 | {label} 65 | 66 | 72 | Copy to Clipboard 73 | 74 | 80 | Copied to Clipboard! 81 | 82 |
83 |
84 |
85 | ); 86 | }; 87 | 88 | export default CodeBox; 89 | -------------------------------------------------------------------------------- /source/popup/components/discount-text/discount-text.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Merchant, formatDiscount, getCouponColor } from '../../../services/merchant'; 3 | import { DirectoryDiscount } from '../../../services/directory'; 4 | import { getVisibleCoupon } from '../../../services/gift-card'; 5 | 6 | const DiscountText: React.FC<{ merchant: Merchant }> = ({ merchant }) => { 7 | const cardConfig = merchant.giftCards[0]; 8 | const discount = merchant.discount || getVisibleCoupon(cardConfig); 9 | const discountCurrency = merchant.discount ? merchant.discount.currency : cardConfig && cardConfig.currency; 10 | const color = getCouponColor(merchant); 11 | const text = { color, fontWeight: 700 }; 12 | return ( 13 |
14 | {formatDiscount(discount as DirectoryDiscount, discountCurrency)} 15 |
16 | ); 17 | }; 18 | 19 | export default DiscountText; 20 | -------------------------------------------------------------------------------- /source/popup/components/gravatar/gravatar.scss: -------------------------------------------------------------------------------- 1 | .gravatar { 2 | border-radius: 50%; 3 | } 4 | -------------------------------------------------------------------------------- /source/popup/components/gravatar/gravatar.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Gravatar from './gravatar'; 4 | 5 | describe('Gravatar', () => { 6 | it('should create component', () => { 7 | const wrapper = shallow(); 8 | expect(wrapper.exists()).toBe(true); 9 | }); 10 | 11 | it('should create the Gravatar url', () => { 12 | const testInputs = [ 13 | { 14 | props: { email: 'sio_bibblebibblebibble@gmail.com' }, 15 | expected: { 16 | src: `https://gravatar.com/avatar/435dc65470defec47a5117da43fccfb6.jpg?s=120&d=${process.env.API_ORIGIN}/img/wallet-logos/bitpay-wallet.png`, 17 | size: 30 18 | } 19 | }, 20 | { 21 | props: { email: 'anakin_skywalker@gamil.com', size: 35 }, 22 | expected: { 23 | src: `https://gravatar.com/avatar/760358ccdb2c7f111e52eccbbfa9b5ef.jpg?s=140&d=${process.env.API_ORIGIN}/img/wallet-logos/bitpay-wallet.png`, 24 | size: 35 25 | } 26 | } 27 | ]; 28 | 29 | testInputs.forEach(input => { 30 | const wrapper = shallow(); 31 | expect(wrapper.find('img').prop('src')).toEqual(input.expected.src); 32 | expect(wrapper.find('img').prop('width')).toEqual(input.expected.size); 33 | expect(wrapper.find('img').prop('height')).toEqual(input.expected.size); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /source/popup/components/gravatar/gravatar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Md5 } from 'ts-md5'; 3 | import './gravatar.scss'; 4 | 5 | const Gravatar: React.FC<{ email: string; size?: string | number }> = ({ email, size = 30 }) => { 6 | const emailHash = Md5.hashStr(email || '') as string; 7 | const defaultImg = `${process.env.API_ORIGIN}/img/wallet-logos/bitpay-wallet.png`; 8 | const url = `https://gravatar.com/avatar/${emailHash}.jpg?s=${+size * 4}&d=${defaultImg}`; 9 | 10 | return profile icon; 11 | }; 12 | 13 | export default Gravatar; 14 | -------------------------------------------------------------------------------- /source/popup/components/ios-switch/ios-switch.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Switch from '@material-ui/core/Switch'; 3 | import { withStyles } from '@material-ui/core/styles'; 4 | 5 | export const IOSSwitch = withStyles(theme => ({ 6 | root: { 7 | width: 42, 8 | height: 26, 9 | padding: 0, 10 | margin: theme.spacing(1) 11 | }, 12 | switchBase: { 13 | padding: 1, 14 | '&$checked': { 15 | transform: 'translateX(16px)', 16 | color: theme.palette.common.white, 17 | '& + $track': { 18 | backgroundColor: '#52d869', 19 | opacity: 1, 20 | border: 'none' 21 | } 22 | }, 23 | '&$focusVisible $thumb': { 24 | color: '#52d869', 25 | border: '6px solid #fff' 26 | } 27 | }, 28 | thumb: { 29 | width: 24, 30 | height: 24 31 | }, 32 | track: { 33 | borderRadius: 26 / 2, 34 | border: `1px solid ${theme.palette.grey[400]}`, 35 | backgroundColor: theme.palette.grey[50], 36 | opacity: 1, 37 | transition: theme.transitions.create(['background-color', 'border']) 38 | }, 39 | checked: {}, 40 | focusVisible: {} 41 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 42 | }))((props: any) => { 43 | const { classes, ...others } = props; 44 | return ( 45 | 57 | ); 58 | }); 59 | -------------------------------------------------------------------------------- /source/popup/components/line-items/line-items.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .line-items { 4 | padding: 0 15px; 5 | &__item { 6 | display: flex; 7 | align-items: center; 8 | margin: 16px 0; 9 | &__label { 10 | flex-grow: 1; 11 | font-size: 14px; 12 | color: rgba($color: #73808c, $alpha: 0.8); 13 | 14 | &--bold { 15 | color: black; 16 | font-size: 16px; 17 | font-weight: $medium; 18 | transform: translateY(-4px); 19 | } 20 | } 21 | &__value { 22 | flex-shrink: 0; 23 | font-size: 14px; 24 | color: $slateDark; 25 | 26 | &--bold { 27 | color: black; 28 | font-size: 24px; 29 | font-weight: $bold; 30 | transform: translateY(-2px); 31 | } 32 | 33 | &.crypto-amount { 34 | color: $blue; 35 | font-weight: $medium; 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /source/popup/components/line-items/line-items.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTracking } from 'react-tracking'; 3 | import { format } from 'date-fns'; 4 | import { motion } from 'framer-motion'; 5 | import { upperCut } from '../../../services/animations'; 6 | import { launchNewTab } from '../../../services/browser'; 7 | import { formatDiscount } from '../../../services/merchant'; 8 | import { CardConfig, GiftCard, UnsoldGiftCard } from '../../../services/gift-card.types'; 9 | import { formatCurrency } from '../../../services/currency'; 10 | import { getTotalDiscount, getDiscountAmount, getActivationFee, hasVisibleBoost } from '../../../services/gift-card'; 11 | import './line-items.scss'; 12 | 13 | const LineItems: React.FC<{ cardConfig: CardConfig; card: Partial & UnsoldGiftCard }> = ({ 14 | cardConfig, 15 | card 16 | }) => { 17 | const tracking = useTracking(); 18 | const activationFee = getActivationFee(card.amount, cardConfig); 19 | const totalDiscount = card.totalDiscount || getTotalDiscount(card.amount, card.coupons || card.discounts || cardConfig.coupons || cardConfig.discounts); 20 | const boosts = card.coupons && card.coupons.filter(coupon => coupon.displayType === 'boost'); 21 | const boost = boosts && boosts[0]; 22 | const discounts = card.discounts ? card.discounts : (card.coupons && card.coupons.filter(coupon => coupon.displayType === 'discount')); 23 | const discount = discounts && discounts[0]; 24 | const openInvoice = (url: string) => (): void => { 25 | launchNewTab(`${url}&view=popup`); 26 | tracking.trackEvent({ action: 'clickedAmountPaid' }); 27 | }; 28 | return ( 29 | 30 | {card.date && ( 31 |
32 |
Purchased
33 |
{format(new Date(card.date), 'MMM dd yyyy')}
34 |
35 | )} 36 | {hasVisibleBoost(cardConfig) && ( 37 |
38 |
39 | Entered Amount 40 |
41 |
42 | {formatCurrency(card.amount - totalDiscount, card.currency, { hideSymbol: true })} 43 |
44 |
45 | )} 46 | {boost && ( 47 |
48 |
49 | {boost.code ? `${formatDiscount(boost, cardConfig.currency, true)} ` : ''}Boost 50 |
51 |
52 | +  53 | {formatCurrency(getDiscountAmount(card.amount, boost), card.currency, { 54 | hideSymbol: true 55 | })} 56 |
57 |
58 | )} 59 |
60 |
Credit Amount
61 |
62 | {formatCurrency(card.amount, card.currency, { hideSymbol: true })} 63 |
64 |
65 | {activationFee > 0 && ( 66 |
67 |
Activation Fee
68 |
69 | {formatCurrency(activationFee, card.currency, { hideSymbol: true })} 70 |
71 |
72 | )} 73 | {discount && ( 74 |
75 |
76 | {discount.code ? `${formatDiscount(discount, cardConfig.currency, true)} ` : ''}Discount 77 |
78 |
79 | -  80 | {formatCurrency(getDiscountAmount(card.amount, discount), card.currency, { 81 | hideSymbol: true 82 | })} 83 |
84 |
85 | )} 86 | {(totalDiscount > 0 || activationFee > 0) && ( 87 |
88 |
89 | Total Cost 90 |
91 |
92 | {formatCurrency(card.amount + activationFee - totalDiscount, card.currency, { hideSymbol: !!card.date })} 93 |
94 |
95 | )} 96 | {card.invoice && ( 97 |
98 |
Amount Paid
99 | 106 |
107 | )} 108 |
109 | ); 110 | }; 111 | 112 | export default LineItems; 113 | -------------------------------------------------------------------------------- /source/popup/components/merchant-cell/merchant-cell.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .merchant-cell { 4 | display: flex; 5 | flex-direction: row; 6 | align-items: center; 7 | padding: 12px 14px; 8 | background: white; 9 | border-radius: 8px; 10 | transition: all 300ms ease-in-out; 11 | cursor: pointer; 12 | user-select: none; 13 | 14 | &:hover { 15 | background: darken(#f8fbfd, 1%); 16 | } 17 | 18 | &__avatar { 19 | border-radius: 50vh; 20 | width: 39px; 21 | height: 39px; 22 | margin-right: 14px; 23 | flex-shrink: 0; 24 | } 25 | 26 | &__block { 27 | display: flex; 28 | flex-direction: column; 29 | } 30 | 31 | &__title { 32 | font-weight: $bold; 33 | font-size: 15px; 34 | color: $black; 35 | max-width: 200px; 36 | white-space: nowrap; 37 | overflow: hidden; 38 | text-overflow: ellipsis; 39 | } 40 | 41 | &__caption { 42 | font-family: $regular; 43 | font-size: 12px; 44 | color: rgba($slateDark, 0.75); 45 | max-width: 200px; 46 | white-space: nowrap; 47 | overflow: hidden; 48 | text-overflow: ellipsis; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /source/popup/components/merchant-cell/merchant-cell.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './merchant-cell.scss'; 3 | import { Merchant } from '../../../services/merchant'; 4 | import CardDenoms from '../card-denoms/card-denoms'; 5 | import DiscountText from '../discount-text/discount-text'; 6 | import { getVisibleCoupon } from '../../../services/gift-card'; 7 | 8 | const MerchantCell: React.FC<{ merchant: Merchant }> = ({ merchant }) => { 9 | const cardConfig = merchant.giftCards[0]; 10 | const discount = merchant.discount || getVisibleCoupon(cardConfig); 11 | return ( 12 |
13 | {`${merchant.displayName} 14 |
15 |
{merchant.displayName}
16 | {discount ? ( 17 |
18 | 19 |
20 | ) : ( 21 |
22 | {merchant.hasDirectIntegration ? <>{merchant.caption} : } 23 |
24 | )} 25 |
26 |
27 | ); 28 | }; 29 | 30 | export default MerchantCell; 31 | -------------------------------------------------------------------------------- /source/popup/components/merchant-cta/merchant-cta.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .merchant-cta { 4 | box-shadow: 0 2px 5px 1px rgba(48, 49, 51, 0.1); 5 | border-radius: 8px; 6 | margin: 0 12px; 7 | padding: 14px; 8 | 9 | $max-caption-width: 192px; 10 | overflow: hidden; 11 | flex-shrink: 0; 12 | 13 | &__content { 14 | display: flex; 15 | align-items: center; 16 | margin-bottom: 13px; 17 | 18 | > img { 19 | border-radius: 50%; 20 | height: 39px; 21 | margin-right: 17px; 22 | width: 39px; 23 | } 24 | 25 | &__merchant { 26 | font-size: 16px; 27 | color: $slateDark; 28 | font-weight: $bold; 29 | } 30 | 31 | &__caption { 32 | font-size: 13px; 33 | color: rgba($slateDark, 0.75); 34 | font-weight: $regular; 35 | } 36 | 37 | &__promo { 38 | font-size: 13px; 39 | color: $blue; 40 | font-weight: $medium; 41 | } 42 | } 43 | > a { 44 | display: block; 45 | text-align: center; 46 | background: $blue; 47 | width: 100%; 48 | color: white; 49 | font-size: 13px; 50 | font-weight: $bold; 51 | background: $blue; 52 | border-radius: 6px; 53 | padding: 8px 0; 54 | 55 | &.zero { 56 | background: $lightBlue; 57 | color: $blue; 58 | font-size: 16px; 59 | font-weight: $medium; 60 | margin-top: 15px; 61 | padding: 10px 0; 62 | } 63 | } 64 | 65 | &__zero { 66 | &__hero { 67 | margin: -14px; 68 | margin-bottom: 0; 69 | max-width: 100%; 70 | max-width: calc(100% + 28px); 71 | } 72 | &__title { 73 | font-size: 18px; 74 | color: $black; 75 | font-weight: $bold; 76 | margin: 6px 0; 77 | } 78 | &__description { 79 | font-size: 13px; 80 | color: $slateDark; 81 | margin: 6px 0; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /source/popup/components/merchant-cta/merchant-cta.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import './merchant-cta.scss'; 3 | import { Link } from 'react-router-dom'; 4 | import { useTracking } from 'react-tracking'; 5 | import { Merchant, getDiscount, getPromoEventParams } from '../../../services/merchant'; 6 | import CardDenoms from '../card-denoms/card-denoms'; 7 | import SuperToast from '../super-toast/super-toast'; 8 | import DiscountText from '../discount-text/discount-text'; 9 | import { getVisibleCoupon } from '../../../services/gift-card'; 10 | 11 | const MerchantCta: React.FC<{ merchant?: Merchant; slimCTA: boolean }> = ({ merchant, slimCTA }) => { 12 | const tracking = useTracking(); 13 | const ctaPath = merchant && (merchant.hasDirectIntegration ? `/brand/${merchant.name}` : `/amount/${merchant.name}`); 14 | const hasDiscount = !!(merchant && getDiscount(merchant)); 15 | const hasGiftCardDiscount = !!(merchant && getVisibleCoupon(merchant.giftCards[0])); 16 | useEffect(() => { 17 | if (!merchant || !hasGiftCardDiscount) return; 18 | tracking.trackEvent({ 19 | action: 'presentedWithGiftCardPromo', 20 | ...getPromoEventParams(merchant), 21 | gaAction: `presentedWithGiftCardPromo:${merchant.name}` 22 | }); 23 | }, [tracking, hasGiftCardDiscount, merchant]); 24 | return ( 25 | <> 26 | {merchant ? ( 27 |
28 |
29 | {`${merchant.displayName} 30 |
31 |
{merchant.displayName}
32 | {hasDiscount ? ( 33 |
34 | 35 |
36 | ) : ( 37 | <> 38 | {merchant.hasDirectIntegration ? ( 39 |
{merchant.caption}
40 | ) : ( 41 |
42 | 43 |
44 | )} 45 | 46 | )} 47 |
48 |
49 | 52 | tracking.trackEvent({ 53 | action: 'clickedMerchantWalletCta', 54 | merchant: merchant.name, 55 | gaAction: `clickedMerchantWalletCta:${merchant.name}` 56 | }) 57 | } 58 | > 59 | {merchant.hasDirectIntegration ? <>Learn More : <>Buy Now} 60 | 61 |
62 | ) : ( 63 | <> 64 | {slimCTA ? ( 65 | <> 66 | tracking.trackEvent({ action: 'clickedGeneralWalletCta' })} 69 | > 70 | 75 | 76 | 77 | ) : ( 78 |
79 | brands 80 |
Spend Crypto Instantly
81 |
82 | Purchase store credit with BTC, BCH, ETH and more at 100+ major retailers. 83 |
84 | 85 | View All Brands 86 | 87 |
88 | )} 89 | 90 | )} 91 | 92 | ); 93 | }; 94 | 95 | export default MerchantCta; 96 | -------------------------------------------------------------------------------- /source/popup/components/navbar/back-button/back-button.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../styles/variables"; 2 | 3 | .back-button { 4 | position: absolute; 5 | left: 10px; 6 | display: flex; 7 | align-items: center; 8 | justify-content: space-between; 9 | height: 24px; 10 | width: 55px; 11 | padding: 0 9px; 12 | font-size: 11px; 13 | font-weight: $medium; 14 | color: $slate; 15 | background-color: rgba($fog, 0); 16 | border-radius: 50vh; 17 | transition: all 250ms ease-in-out; 18 | user-select: none; 19 | &:hover { 20 | background-color: rgba($fog, 1); 21 | } 22 | &--text { 23 | transform: translate(-0.5px, 0.5px); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /source/popup/components/navbar/back-button/back-button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './back-button.scss'; 3 | import { motion, AnimatePresence } from 'framer-motion'; 4 | import { FrameDimensions } from '../../../../services/frame'; 5 | 6 | const BackButton: React.FC<{ show: boolean; onClick: () => void }> = ({ show, onClick }) => ( 7 | 8 | {show && ( 9 | 18 | go back 19 | Back 20 | 21 | )} 22 | 23 | ); 24 | 25 | export default BackButton; 26 | -------------------------------------------------------------------------------- /source/popup/components/navbar/bp-logo/bp-logo.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../styles/variables"; 2 | 3 | .bp-logo { 4 | position: absolute; 5 | left: 16px; 6 | 7 | &__helper { 8 | font-weight: $regular; 9 | font-size: 11px; 10 | pointer-events: none; 11 | user-select: none; 12 | background: linear-gradient( 13 | to right, 14 | #97aaff 0%, 15 | #97aaff 30%, 16 | lighten(#97aaff, 40%) 40%, 17 | #97aaff 50%, 18 | #97aaff 100% 19 | ); 20 | background-size: 82px; 21 | background-clip: text; 22 | color: transparent; 23 | animation: rollingGradient 8.2s linear infinite; 24 | } 25 | } 26 | 27 | @keyframes rollingGradient { 28 | 0% { 29 | background-position: -82px 0; 30 | } 31 | 100% { 32 | background-position: 82px 0; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /source/popup/components/navbar/navbar.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .fixed { 4 | position: sticky; 5 | top: 0; 6 | z-index: 1; 7 | width: 100%; 8 | background-color: white; 9 | transition: all 300ms ease; 10 | &--dark { 11 | background-color: $midnightBlue; 12 | } 13 | } 14 | 15 | .header-bar { 16 | position: relative; 17 | display: flex; 18 | flex-direction: row; 19 | justify-content: space-between; 20 | align-items: center; 21 | width: 100%; 22 | min-height: 48px; 23 | height: 48px; 24 | padding: 0 12px; 25 | border-bottom: 1px solid rgba(black, 0.05); 26 | 27 | .pay-click-handler { 28 | cursor: pointer; 29 | position: absolute; 30 | left: 0; 31 | height: 100%; 32 | width: 50%; 33 | z-index: 2; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /source/popup/components/navbar/navbar.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/click-events-have-key-events */ 2 | /* eslint-disable jsx-a11y/no-static-element-interactions */ 3 | import React, { useState, useEffect } from 'react'; 4 | import { withRouter, RouteComponentProps } from 'react-router-dom'; 5 | import { useTracking } from 'react-tracking'; 6 | import { browser } from 'webextension-polyfill-ts'; 7 | import { fromEvent } from 'rxjs'; 8 | import { debounceTime } from 'rxjs/operators'; 9 | import { resizeFrame, FrameDimensions } from '../../../services/frame'; 10 | import { trackComponent } from '../../../services/analytics'; 11 | import './navbar.scss'; 12 | 13 | import BitpayLogo from './bp-logo/bp-logo'; 14 | import BackButton from './back-button/back-button'; 15 | import Toggle from './toggle/toggle'; 16 | 17 | const Navbar: React.FC = ({ 18 | history, 19 | location, 20 | initiallyCollapsed 21 | }) => { 22 | const tracking = useTracking(); 23 | const [preCollapseHeight, setPreCollapseHeight] = useState(0); 24 | const [collapsed, setCollapsed] = useState(false); 25 | const goBack = (): void => { 26 | if (collapsed) { 27 | setCollapsed(false); 28 | } 29 | history.goBack(); 30 | tracking.trackEvent({ action: 'clickedBackButton' }); 31 | }; 32 | const collapse = (): void => { 33 | setPreCollapseHeight(document.body.offsetHeight); 34 | setCollapsed(true); 35 | resizeFrame(FrameDimensions.collapsedHeight); 36 | tracking.trackEvent({ action: 'collapsedWidget' }); 37 | }; 38 | const expand = (): void => { 39 | setCollapsed(false); 40 | resizeFrame(preCollapseHeight); 41 | tracking.trackEvent({ action: 'expandedWidget' }); 42 | }; 43 | const close = (): void => { 44 | tracking.trackEvent({ action: 'closedWidget' }); 45 | browser.runtime.sendMessage({ name: 'POPUP_CLOSED' }); 46 | }; 47 | const routesWithBackButton = [ 48 | '/brand', 49 | '/card', 50 | '/amount', 51 | '/payment', 52 | '/phone', 53 | '/settings/', 54 | '/category', 55 | '/country' 56 | ]; 57 | const showBackButton = routesWithBackButton.some(route => location.pathname.startsWith(route)); 58 | const routesWithPayMode = ['/amount', '/payment']; 59 | const inPaymentFlow = routesWithPayMode.some(route => location.pathname.startsWith(route)); 60 | const payMode = collapsed && inPaymentFlow; 61 | const handleLogoClick = (): void => { 62 | if (payMode) expand(); 63 | }; 64 | browser.runtime.sendMessage({ name: 'NAVBAR_MODE_CHANGED', mode: payMode ? 'pay' : 'default' }); 65 | useEffect(() => { 66 | fromEvent(window, 'message') 67 | .pipe(debounceTime(1000)) 68 | .subscribe(() => tracking.trackEvent({ action: 'draggedWidget' })); 69 | }, [tracking]); 70 | useEffect(() => { 71 | if (!initiallyCollapsed) return; 72 | collapse(); 73 | setPreCollapseHeight(FrameDimensions.amountPageHeight); 74 | // eslint-disable-next-line react-hooks/exhaustive-deps 75 | }, []); 76 | return ( 77 |
78 | {payMode &&
} 79 | 80 | 81 | 82 |
83 | ); 84 | }; 85 | 86 | export default withRouter(trackComponent(Navbar)); 87 | -------------------------------------------------------------------------------- /source/popup/components/navbar/toggle/toggle.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../styles/variables"; 2 | 3 | .header-bar { 4 | &__controls { 5 | position: absolute; 6 | right: 13px; 7 | transform: translateY(3px); 8 | user-select: none; 9 | 10 | &__toggle { 11 | position: absolute; 12 | right: 0; 13 | &--wrapper { 14 | position: relative; 15 | right: 7px; 16 | display: flex; 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /source/popup/components/navbar/toggle/toggle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './toggle.scss'; 3 | import { motion } from 'framer-motion'; 4 | 5 | const animateToggle = { 6 | expand: { 7 | rotate: 135, 8 | rotateX: 0, 9 | opacity: 1 10 | }, 11 | minimize: { 12 | rotate: 45, 13 | rotateX: -10, 14 | opacity: 0 15 | }, 16 | reset: { 17 | rotate: 0 18 | }, 19 | rotate: { 20 | rotate: 180 21 | } 22 | }; 23 | 24 | const Toggle: React.FC<{ 25 | close: () => void; 26 | expand: () => void; 27 | collapse: () => void; 28 | collapsed: boolean; 29 | payMode?: boolean; 30 | }> = ({ close, expand, collapse, collapsed = false, payMode = false }) => ( 31 |
32 | 38 | 39 | 40 | 41 | 42 | 48 | 49 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 70 | 71 | 72 | 73 |
74 | ); 75 | 76 | export default Toggle; 77 | -------------------------------------------------------------------------------- /source/popup/components/pay-with-bitpay/pay-with-bitpay.scss: -------------------------------------------------------------------------------- 1 | .pay-with-bitpay { 2 | display: flex; 3 | justify-content: center; 4 | img { 5 | display: block; 6 | cursor: pointer; 7 | } 8 | 9 | &__pay-button { 10 | height: 60px; 11 | } 12 | .action-button__spinner { 13 | animation: unset; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /source/popup/components/search-bar/search-bar.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .search-bar { 4 | display: flex; 5 | align-items: center; 6 | background: $fog; 7 | border-radius: 8px; 8 | height: 34px; 9 | 10 | &--wrapper { 11 | position: sticky; 12 | top: 0; 13 | z-index: 1; 14 | padding: 14px 14px 8px; 15 | background-color: white; 16 | } 17 | 18 | &__box { 19 | display: flex; 20 | align-items: center; 21 | justify-content: space-between; 22 | width: 100%; 23 | 24 | &__input { 25 | margin-left: 12px; 26 | width: 80%; 27 | background-color: $fog; 28 | border: none; 29 | color: $slateDark; 30 | font-size: 12px; 31 | font-weight: $medium; 32 | 33 | &:focus { 34 | outline: none !important; 35 | } 36 | 37 | &::placeholder { 38 | color: $slate; 39 | font-weight: $regular; 40 | user-select: none; 41 | } 42 | } 43 | 44 | &__icon { 45 | margin-right: 11px; 46 | user-select: none; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /source/popup/components/search-bar/search-bar.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, ShallowWrapper } from 'enzyme'; 3 | import SearchBar from './search-bar'; 4 | 5 | describe('Search Bar', () => { 6 | const setWrapperProps = (wrapper: ShallowWrapper): void => { 7 | wrapper.setProps({ 8 | output: (val: string) => wrapper.setProps({ value: val }), 9 | tracking: { 10 | trackEvent: (): void => undefined 11 | } 12 | }); 13 | }; 14 | it('should create the component', () => { 15 | const wrapper = shallow(); 16 | expect(wrapper.exists()).toBeTruthy(); 17 | }); 18 | 19 | it('should change icons based on input value', () => { 20 | const wrapper = shallow(); 21 | setWrapperProps(wrapper); 22 | expect(wrapper.find('#searchClearIcon').exists()).toBeFalsy(); 23 | expect(wrapper.find('#searchIcon').exists()).toBeTruthy(); 24 | wrapper.find('input').simulate('change', { currentTarget: { value: 'amazon' } }); 25 | expect(wrapper.find('#searchClearIcon').exists()).toBeTruthy(); 26 | expect(wrapper.find('#searchIcon').exists()).toBeFalsy(); 27 | wrapper.find('button').simulate('click'); 28 | expect(wrapper.find('#searchClearIcon').exists()).toBeFalsy(); 29 | expect(wrapper.find('#searchIcon').exists()).toBeTruthy(); 30 | }); 31 | 32 | it('should clear input value on clicking clearSearchButton ', () => { 33 | const wrapper = shallow(); 34 | setWrapperProps(wrapper); 35 | wrapper.find('input').simulate('change', { currentTarget: { value: 'amazon' } }); 36 | expect(wrapper.find('input').prop('value')).toBe('amazon'); 37 | wrapper.find('button').simulate('click'); 38 | expect(wrapper.find('input').prop('value')).toBe(''); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /source/popup/components/search-bar/search-bar.tsx: -------------------------------------------------------------------------------- 1 | import './search-bar.scss'; 2 | import React, { useEffect, useState } from 'react'; 3 | import { motion, transform } from 'framer-motion'; 4 | import { Subject } from 'rxjs'; 5 | import { debounceTime } from 'rxjs/operators'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | const SearchBar: React.FC = ({ output, value, tracking, placeholder, autoFocus }) => { 9 | const [analyticsSubject] = useState(new Subject()); 10 | useEffect(() => { 11 | analyticsSubject.pipe(debounceTime(1000)).subscribe(query => { 12 | tracking.trackEvent({ action: 'searched', query, gaAction: `searched:${query}` }); 13 | }); 14 | }, [analyticsSubject, tracking]); 15 | const onChange = (e: React.FormEvent): void => { 16 | output(e.currentTarget.value); 17 | analyticsSubject.next(e.currentTarget.value); 18 | }; 19 | const clearSearchBox = (): void => { 20 | output(''); 21 | tracking.trackEvent({ action: 'clearedSearchBox' }); 22 | }; 23 | const [scrollY, setScrollY] = useState(window.scrollY); 24 | const boxShadow = { boxShadow: `0 1px 5px 0 rgba(0, 0, 0, ${transform(scrollY, [0, 20], [0, 0.05])})` }; 25 | useEffect(() => { 26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | const handleScroll = (e: any): void => setScrollY(e?.currentTarget?.scrollTop); 28 | const parent = document.getElementById('search-bar')?.parentElement; 29 | if (parent) parent.addEventListener('scroll', handleScroll, { passive: true }); 30 | return (): void => parent?.removeEventListener('scroll', handleScroll); 31 | }, []); 32 | return ( 33 | 34 |
35 |
36 | 45 | {value ? ( 46 | 54 | ) : ( 55 | search 56 | )} 57 |
58 |
59 |
60 | ); 61 | }; 62 | 63 | export default SearchBar; 64 | -------------------------------------------------------------------------------- /source/popup/components/snack/snack.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .snack { 4 | .MuiAlert-action { 5 | align-items: start; 6 | } 7 | div.MuiAlert-icon { 8 | color: $caution; 9 | } 10 | div.MuiAlert-root { 11 | width: 270px; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /source/popup/components/snack/snack.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Snackbar, SnackbarOrigin } from '@material-ui/core'; 3 | import { Alert, AlertTitle } from '@material-ui/lab'; 4 | import IconButton from '@material-ui/core/IconButton'; 5 | import './snack.scss'; 6 | 7 | const position: React.CSSProperties = { top: '15px', left: '15px', right: '15px' }; 8 | const anchorOrigin: SnackbarOrigin = { vertical: 'top', horizontal: 'center' }; 9 | const menu: React.CSSProperties = { 10 | textAlign: 'left', 11 | background: '#081125', 12 | borderRadius: '9px', 13 | color: 'rgba(255, 255, 255, .75)', 14 | fontSize: '12px' 15 | }; 16 | const icon: React.CSSProperties = { color: 'rgba(255, 255, 255, 0.5)' }; 17 | const title: React.CSSProperties = { color: 'white', fontSize: '15px' }; 18 | 19 | const Snack: React.FC<{ message: string; onClose: () => void }> = ({ message, onClose }) => ( 20 |
21 | 22 | 27 | close 28 | 29 | } 30 | > 31 | Please Try Again! 32 | {message} 33 | 34 | 35 |
36 | ); 37 | 38 | export default Snack; 39 | -------------------------------------------------------------------------------- /source/popup/components/super-toast/super-toast.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .super-toast { 4 | display: flex; 5 | position: relative; 6 | margin: 4px 14px; 7 | background-color: $oceanBlue; 8 | box-shadow: 0 0 14px 4px rgba(black, 0.05); 9 | border-radius: 9px; 10 | cursor: pointer; 11 | user-select: none; 12 | 13 | &__content { 14 | display: flex; 15 | padding: 13px 13px 15px; 16 | &__icon { 17 | margin-top: 3px; 18 | margin-right: 11px; 19 | } 20 | &__block { 21 | display: flex; 22 | flex-direction: column; 23 | align-items: flex-start; 24 | justify-content: flex-start; 25 | 26 | &__title { 27 | font-weight: $medium; 28 | font-size: 15px; 29 | color: white; 30 | } 31 | &__caption { 32 | font-weight: $regular; 33 | font-size: 12px; 34 | color: rgba(white, 0.75); 35 | } 36 | } 37 | } 38 | 39 | &--gradient { 40 | background-image: linear-gradient(135deg, #d43f8d 0%, #0250c5 100%); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /source/popup/components/super-toast/super-toast.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import SuperToast from './super-toast'; 4 | 5 | describe('Super Toast Component', () => { 6 | const TestInput = { 7 | title: 'Really Old', 8 | caption: 'A long time ago in a galaxy far, far away...' 9 | }; 10 | it('should create the component', () => { 11 | const wrapper = shallow(); 12 | expect(wrapper.exists()).toBe(true); 13 | }); 14 | 15 | it('should display gradient toast for shopMode', () => { 16 | const wrapper = shallow(); 17 | expect(wrapper.hasClass('super-toast--gradient')).toBeTruthy(); 18 | expect(wrapper.find('#white').exists()).toBeTruthy(); 19 | expect(wrapper.find('#blue').exists()).toBeFalsy(); 20 | expect(wrapper.find('.super-toast__content__block__title').text()).toEqual(TestInput.title); 21 | expect(wrapper.find('.super-toast__content__block__caption').text()).toEqual(TestInput.caption); 22 | }); 23 | 24 | it('should not display gradient for shopMode: false', () => { 25 | const wrapper = shallow(); 26 | expect(wrapper.hasClass('super-toast--gradient')).toBeFalsy(); 27 | expect(wrapper.find('#white').exists()).toBeFalsy(); 28 | expect(wrapper.find('#blue').exists()).toBeTruthy(); 29 | expect(wrapper.find('.super-toast__content__block__title').text()).toEqual(TestInput.title); 30 | expect(wrapper.find('.super-toast__content__block__caption').text()).toEqual(TestInput.caption); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /source/popup/components/super-toast/super-toast.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './super-toast.scss'; 3 | 4 | const SuperToast: React.FC<{ title: string; caption: string; shopMode: boolean }> = ({ title, caption, shopMode }) => ( 5 |
6 |
7 |
8 | {shopMode ? ( 9 | info 10 | ) : ( 11 | info 12 | )} 13 |
14 |
15 |
{title}
16 |
{caption}
17 |
18 |
19 |
20 | ); 21 | 22 | export default SuperToast; 23 | -------------------------------------------------------------------------------- /source/popup/components/tabs/tabs.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .tab-bar { 4 | height: 48px; 5 | flex-shrink: 0; 6 | display: flex; 7 | width: 100%; 8 | background-color: white; 9 | border-top: 1px solid rgba(black, 0.05); 10 | padding: 0 20px; 11 | user-select: none; 12 | 13 | > a { 14 | display: flex; 15 | flex-grow: 1; 16 | justify-content: center; 17 | } 18 | 19 | .active { 20 | display: none; 21 | } 22 | 23 | .is-active { 24 | font-weight: $bold; 25 | .inactive { 26 | display: none; 27 | } 28 | .active { 29 | display: block; 30 | } 31 | } 32 | 33 | img { 34 | cursor: pointer; 35 | width: 16px; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /source/popup/components/tabs/tabs.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './tabs.scss'; 3 | import { NavLink, withRouter, RouteComponentProps } from 'react-router-dom'; 4 | 5 | const Tabs: React.FC = ({ location: { pathname } }) => { 6 | const routesVisible = ['/wallet', '/shop', '/settings']; 7 | const shouldShow = routesVisible.includes(pathname); 8 | 9 | return shouldShow ? ( 10 |
11 | 12 | wallet 13 | wallet 14 | 15 | 16 | shop 17 | shop 18 | 19 | 20 | settings 21 | settings 22 | 23 |
24 | ) : null; 25 | }; 26 | 27 | export default withRouter(Tabs); 28 | -------------------------------------------------------------------------------- /source/popup/components/wallet-cards/__tests__/wallet-card.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import WalletCard from '../wallet-card'; 4 | import { CardConfigData, GiftCardsData } from '../../../../testData'; 5 | 6 | const GetBalanceReturnValue = 5; 7 | 8 | jest.mock('../../../../services/gift-card', () => ({ 9 | getLatestBalance: (): number => GetBalanceReturnValue 10 | })); 11 | 12 | describe('Wallet Card', () => { 13 | it('should create the component', () => { 14 | const wrapper = shallow(); 15 | expect(wrapper.exists()).toBeTruthy(); 16 | }); 17 | 18 | it('should return pocket card for type=pocket', () => { 19 | const expectedCardLogoValues = { 20 | src: CardConfigData.logo, 21 | alt: `${CardConfigData.displayName} logo` 22 | }; 23 | const wrapper = shallow(); 24 | const cardLogoImg = wrapper.find('#pocketCardLogo'); 25 | expect(cardLogoImg.prop('src')).toBe(expectedCardLogoValues.src); 26 | expect(cardLogoImg.prop('alt')).toBe(expectedCardLogoValues.alt); 27 | expect(wrapper.find('.wallet-card__card__balance').text()).toBe(`$${GetBalanceReturnValue}`); 28 | }); 29 | 30 | it('should return brand box card for type=brand-box', () => { 31 | const expectedCardLogoValues = { 32 | src: CardConfigData.logo, 33 | alt: `${CardConfigData.displayName} logo` 34 | }; 35 | const wrapper = shallow(); 36 | expect(wrapper.find('#pocketCardLogo').exists()).toBeFalsy(); 37 | 38 | const cardLogoImg = wrapper.find('#brandBoxCardLogo'); 39 | expect(cardLogoImg.prop('src')).toBe(expectedCardLogoValues.src); 40 | expect(cardLogoImg.prop('alt')).toBe(expectedCardLogoValues.alt); 41 | expect(wrapper.find('.wallet-card--brand-box__balance').text()).toBe(`$${GetBalanceReturnValue}`); 42 | expect(wrapper.find('.wallet-card--card-box__text').exists()).toBeFalsy(); 43 | }); 44 | 45 | it('should return CardBox for type=card-box', () => { 46 | const wrapper = shallow(); 47 | expect(wrapper.find('#pocketCardLogo').exists()).toBeFalsy(); 48 | expect(wrapper.find('#brandBoxCardLogo').exists()).toBeFalsy(); 49 | expect(wrapper.find('.wallet-card--card-box__text__label').text()).toBe('Store Credit'); 50 | expect(wrapper.find('.wallet-card--card-box__text__note').text()).toBe('Apr 23 2020'); 51 | expect(wrapper.find('.wallet-card--card-box__balance').text()).toBe(`$${GetBalanceReturnValue}`); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /source/popup/components/wallet-cards/__tests__/wallet-cards.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { Link } from 'react-router-dom'; 4 | import WalletCards from '../wallet-cards'; 5 | import { CardConfigData, GiftCardsData } from '../../../../testData'; 6 | 7 | const GetBalanceReturnValue = 5; 8 | jest.mock('../../../../services/gift-card', () => ({ 9 | getLatestBalance: (): number => GetBalanceReturnValue 10 | })); 11 | 12 | describe('Wallet Cards', () => { 13 | it('should create the component for non empty list', () => { 14 | const wrapper = shallow(); 15 | expect(wrapper.exists()).toBeTruthy(); 16 | }); 17 | 18 | it('should return a card/invoiceId link', () => { 19 | const wrapper = shallow(); 20 | const expected = { 21 | pathname: `/card/${GiftCardsData[0].invoiceId}`, 22 | state: { 23 | cardConfig: CardConfigData, 24 | card: GiftCardsData[0] 25 | } 26 | }; 27 | const linkProps = wrapper.find(Link).props().to; 28 | expect(linkProps).toEqual(expected); 29 | }); 30 | 31 | it('should return card/brand link', () => { 32 | const giftCardsList = JSON.parse(JSON.stringify(GiftCardsData)); 33 | giftCardsList.push(JSON.parse(JSON.stringify(GiftCardsData))[0]); 34 | const expected = { 35 | pathname: `/cards/${giftCardsList[0].name}`, 36 | state: { 37 | cardConfig: CardConfigData, 38 | cards: giftCardsList 39 | } 40 | }; 41 | const wrapper = shallow(); 42 | const linkProps = wrapper.find(Link).props().to; 43 | expect(linkProps).toEqual(expected); 44 | }); 45 | 46 | it('should return multiple links', () => { 47 | const giftCardsList = JSON.parse(JSON.stringify(GiftCardsData)); 48 | giftCardsList.push(JSON.parse(JSON.stringify(GiftCardsData))[0]); 49 | giftCardsList[1].name = 'Nike.com'; 50 | giftCardsList.push(JSON.parse(JSON.stringify(GiftCardsData))[0]); 51 | const cardConfigList = JSON.parse(JSON.stringify([CardConfigData])); 52 | cardConfigList.push(JSON.parse(JSON.stringify([CardConfigData]))[0]); 53 | cardConfigList[1].name = 'Nike.com'; 54 | const expected = [ 55 | { 56 | pathname: `/cards/${giftCardsList[0].name}`, 57 | state: { 58 | cardConfig: cardConfigList[0], 59 | cards: [giftCardsList[0], giftCardsList[2]] 60 | } 61 | }, 62 | { 63 | pathname: `/card/${giftCardsList[1].invoiceId}`, 64 | state: { 65 | cardConfig: cardConfigList[1], 66 | card: giftCardsList[1] 67 | } 68 | } 69 | ]; 70 | 71 | const wrapper = shallow(); 72 | expect( 73 | wrapper 74 | .find(Link) 75 | .first() 76 | .props().to 77 | ).toEqual(expected[0]); 78 | expect( 79 | wrapper 80 | .find(Link) 81 | .last() 82 | .props().to 83 | ).toEqual(expected[1]); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /source/popup/components/wallet-cards/wallet-card.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .wallet-cards { 4 | $header-color: rgba(0, 0, 0, 0.2); 5 | padding-bottom: 20px; 6 | user-select: none; 7 | &__header { 8 | text-transform: uppercase; 9 | font-size: 12px; 10 | font-weight: $medium; 11 | color: $header-color; 12 | margin: 26px 0; 13 | margin-left: 13px; 14 | 15 | display: flex; 16 | width: 100%; 17 | justify-content: center; 18 | align-items: center; 19 | text-align: center; 20 | margin-right: 20px; 21 | 22 | &:after { 23 | content: ""; 24 | border-top: 2px solid rgba(0, 0, 0, 0.05); 25 | margin: 0 20px 0 0; 26 | flex: 1 0 20px; 27 | margin: 0 0 0 15px; 28 | } 29 | } 30 | } 31 | 32 | .wallet-card { 33 | margin-bottom: 2px; 34 | &__card { 35 | display: flex; 36 | align-items: center; 37 | width: 100%; 38 | background: #221e20; 39 | margin: 0 auto; 40 | overflow: hidden; 41 | width: calc(100% - 26px); 42 | border-top-left-radius: 11px; 43 | border-top-right-radius: 11px; 44 | padding-right: 16px; 45 | height: 45px; 46 | 47 | .light & { 48 | position: relative; 49 | box-shadow: 0 1px 12px rgba(0, 0, 0, 0.07); 50 | } 51 | 52 | > img { 53 | max-height: 47px; 54 | margin-top: -2px; 55 | margin-left: 3px; 56 | } 57 | 58 | &__balance { 59 | flex-grow: 1; 60 | text-align: right; 61 | color: white; 62 | font-size: 20px; 63 | font-weight: $bold; 64 | 65 | .light & { 66 | color: black; 67 | } 68 | } 69 | } 70 | &__slot { 71 | position: relative; 72 | margin-left: -3px; 73 | margin-top: -20px; 74 | height: 30px; 75 | overflow: hidden; 76 | 77 | img { 78 | width: 100%; 79 | } 80 | } 81 | 82 | &--brand-box, 83 | &--card-box { 84 | display: flex; 85 | align-items: center; 86 | border-radius: 8px; 87 | 88 | &__balance { 89 | flex-grow: 1; 90 | text-align: right; 91 | font-weight: $bold; 92 | } 93 | } 94 | 95 | &--brand-box { 96 | box-shadow: 0 2px 5px 1px rgba(48, 49, 51, 0.15); 97 | height: 67px; 98 | padding-right: 25px; 99 | padding-left: 10px; 100 | margin-bottom: 24px; 101 | 102 | .light & { 103 | position: relative; 104 | box-shadow: 0 1px 12px rgba(0, 0, 0, 0.08); 105 | } 106 | > img { 107 | max-height: 50px; 108 | } 109 | 110 | &__balance { 111 | color: white; 112 | font-size: 20px; 113 | 114 | .light & { 115 | color: black; 116 | } 117 | } 118 | } 119 | 120 | &--card-box { 121 | background: #f4f6fa; 122 | height: 66px; 123 | padding: 0 18px; 124 | margin-bottom: 15px; 125 | 126 | &__text { 127 | text-align: left; 128 | &__label { 129 | font-size: 13px; 130 | color: $slateDark; 131 | font-weight: $medium; 132 | } 133 | &__note { 134 | opacity: 0.5; 135 | font-size: 12px; 136 | color: $slateDark; 137 | } 138 | } 139 | &__balance { 140 | font-size: 26px; 141 | font-weight: $bold; 142 | color: $black; 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /source/popup/components/wallet-cards/wallet-card.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import { format } from 'date-fns'; 4 | import './wallet-card.scss'; 5 | import { GiftCard, CardConfig } from '../../../services/gift-card.types'; 6 | import { formatCurrency } from '../../../services/currency'; 7 | import { getLatestBalance } from '../../../services/gift-card'; 8 | 9 | const WalletCard: React.FC<{ 10 | cards: GiftCard[]; 11 | cardConfig: CardConfig; 12 | type: 'pocket' | 'brand-box' | 'card-box'; 13 | }> = ({ cards, cardConfig, type = 'pocket' }) => { 14 | const totalBalance = cards.reduce((sum, card) => sum + getLatestBalance(card), 0); 15 | const isGradient = cardConfig.logoBackgroundColor.indexOf('gradient') > -1; 16 | const cardBackgroundStyle = { 17 | ...(!isGradient && { background: cardConfig.logoBackgroundColor }), 18 | ...(isGradient && { backgroundImage: cardConfig.logoBackgroundColor }) 19 | }; 20 | return ( 21 |
26 | {type === 'pocket' ? ( 27 |
28 |
29 | 30 |
31 | {formatCurrency(totalBalance, cardConfig.currency, { customPrecision: 'minimal' })} 32 |
33 |
34 |
35 | slot 36 |
37 |
38 | ) : ( 39 |
40 | {type === 'brand-box' ? ( 41 | 42 | ) : ( 43 |
44 |
Store Credit
45 |
{format(new Date(cards[0].date), 'MMM dd yyyy')}
46 |
47 | )} 48 |
49 | {formatCurrency(totalBalance, cardConfig.currency, { customPrecision: 'minimal' })} 50 |
51 |
52 | )} 53 |
54 | ); 55 | }; 56 | 57 | export default WalletCard; 58 | -------------------------------------------------------------------------------- /source/popup/components/wallet-cards/wallet-cards.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { CardConfig, GiftCard } from '../../../services/gift-card.types'; 4 | import { groupBy } from '../../../services/utils'; 5 | import WalletCard from './wallet-card'; 6 | 7 | const WalletCards: React.FC<{ activeCards: GiftCard[]; supportedCards: CardConfig[] }> = ({ 8 | activeCards, 9 | supportedCards 10 | }) => { 11 | const cardsByBrand = groupBy(activeCards, 'name') as { [brand: string]: GiftCard[] }; 12 | 13 | return ( 14 |
15 |
Credits
16 | {Object.keys(cardsByBrand).map(brand => { 17 | const cards = cardsByBrand[brand]; 18 | const cardConfig = supportedCards.find(config => config.name === brand) as CardConfig; 19 | const pathname = cards.length > 1 ? `/cards/${brand}` : `/card/${cards[0].invoiceId}`; 20 | return ( 21 | 1 ? { cards } : { card: cards[0] }) 27 | } 28 | }} 29 | key={brand} 30 | > 31 | 32 | 33 | ); 34 | })} 35 |
36 | ); 37 | }; 38 | 39 | export default WalletCards; 40 | -------------------------------------------------------------------------------- /source/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import Popup from './popup'; 5 | 6 | ReactDOM.render(, document.getElementById('popup-root')); 7 | -------------------------------------------------------------------------------- /source/popup/pages/amount/amount.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .amount-page { 4 | display: flex; 5 | padding: 10px; 6 | flex-direction: column; 7 | text-align: center; 8 | padding-bottom: 15px; 9 | 10 | &__title { 11 | margin-bottom: -22px; 12 | } 13 | 14 | &__merchant-name { 15 | font-size: 15px; 16 | color: $slateDark; 17 | font-weight: $medium; 18 | margin-top: 3px; 19 | } 20 | 21 | &__promo { 22 | font-size: 12px; 23 | color: $blue; 24 | text-align: center; 25 | font-weight: $regular; 26 | margin-top: 3px; 27 | } 28 | 29 | &__cta { 30 | .action-button__footer { 31 | margin-top: 0; 32 | } 33 | .boost-amount { 34 | color: $slateDark; 35 | display: flex; 36 | justify-content: center; 37 | font-size: 14px; 38 | margin-top: -20px; 39 | padding: 10px; 40 | padding-top: 0; 41 | opacity: 0; 42 | transform: translateY(10px); 43 | transition: all 300ms ease; 44 | 45 | &--visible { 46 | opacity: 1; 47 | transform: translateY(0); 48 | } 49 | } 50 | } 51 | 52 | &__amount-box { 53 | width: 100%; 54 | 55 | &__amount { 56 | font-size: 44px; 57 | color: black; 58 | text-align: center; 59 | font-weight: $bold; 60 | display: flex; 61 | align-items: center; 62 | justify-content: center; 63 | 64 | &__value { 65 | flex-grow: 1; 66 | } 67 | 68 | > button { 69 | height: 30px; 70 | width: 30px; 71 | margin: 0 15px; 72 | } 73 | } 74 | 75 | &__currency { 76 | font-size: 16px; 77 | color: rgba(0, 0, 0, 0.25); 78 | font-weight: $medium; 79 | } 80 | 81 | &__denoms { 82 | opacity: 0.75; 83 | font-size: 12px; 84 | color: $slateDark; 85 | margin: 0 auto; 86 | max-width: 200px; 87 | margin-top: 3px; 88 | } 89 | 90 | &__wrapper { 91 | flex-grow: 1; 92 | display: flex; 93 | align-items: center; 94 | justify-self: center; 95 | text-align: center; 96 | } 97 | } 98 | 99 | &__input { 100 | position: absolute; 101 | top: 0; 102 | bottom: 0; 103 | opacity: 0; 104 | width: 1px; 105 | height: 1px; 106 | pointer-events: none; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /source/popup/pages/brand/brand.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .brand-page { 4 | display: flex; 5 | flex-direction: column; 6 | 7 | &__header { 8 | display: flex; 9 | flex-direction: row; 10 | justify-content: flex-start; 11 | align-items: flex-start; 12 | padding: 16px 18px; 13 | 14 | &__icon { 15 | min-width: 72px; 16 | max-width: 72px; 17 | height: 72px; 18 | border-radius: 50vh; 19 | margin-right: 20px; 20 | user-select: none; 21 | 22 | &--hover { 23 | position: absolute; 24 | top: 0; 25 | left: 0; 26 | min-width: 72px; 27 | max-width: 72px; 28 | height: 72px; 29 | border-radius: 50vh; 30 | background-color: rgba(#182026, 0.75); 31 | cursor: pointer; 32 | display: flex; 33 | align-items: center; 34 | justify-content: center; 35 | opacity: 0; 36 | transition: opacity 250ms ease-in-out; 37 | } 38 | &--wrapper { 39 | position: relative; 40 | &:hover { 41 | .brand-page__header__icon--hover { 42 | opacity: 1; 43 | } 44 | } 45 | } 46 | } 47 | 48 | &__block { 49 | display: flex; 50 | align-items: flex-start; 51 | flex-direction: column; 52 | margin-top: 6px; 53 | 54 | &__title { 55 | font-weight: $bold; 56 | font-size: 18px; 57 | color: $black; 58 | } 59 | 60 | &__caption { 61 | font-weight: $regular; 62 | font-size: 13px; 63 | color: rgba($slateDark, 0.75); 64 | padding-right: 4px; 65 | } 66 | 67 | &__discount { 68 | background: white; 69 | border-radius: 50vh; 70 | padding: 2px 12px; 71 | font-weight: $medium; 72 | font-size: 12px; 73 | display: flex; 74 | justify-content: center; 75 | align-items: center; 76 | margin-top: 12px; 77 | transform: translateX(-4px); 78 | color: $blue; 79 | border: 2px solid $blue; 80 | } 81 | } 82 | } 83 | 84 | &__body { 85 | padding: 0 10px 16px; 86 | &__divider { 87 | background-color: rgba(black, 0.05); 88 | height: 1px; 89 | margin: 18px 8px; 90 | } 91 | &__content { 92 | margin: auto 12px; 93 | &__title { 94 | font-weight: $bold; 95 | font-size: 15px; 96 | color: $slateDark; 97 | } 98 | &__text { 99 | position: relative; 100 | font-weight: $regular; 101 | font-size: 12px; 102 | color: rgba(#565d6d, 0.8); 103 | line-height: 17px; 104 | margin-top: 4px; 105 | max-height: 50px; 106 | overflow: hidden; 107 | word-break: break-all; 108 | 109 | &--action { 110 | position: absolute; 111 | bottom: -1px; 112 | right: 0; 113 | font-weight: $regular; 114 | font-size: 12px; 115 | color: $blue; 116 | line-height: 17px; 117 | cursor: pointer; 118 | background: linear-gradient(90deg, rgba(white, 0), white, white); 119 | width: 50px; 120 | text-align: right; 121 | } 122 | 123 | &--expand { 124 | max-height: unset; 125 | overflow: visible; 126 | word-break: break-word; 127 | } 128 | } 129 | } 130 | .shop-page__section-header { 131 | padding-top: 2px; 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /source/popup/pages/card/balance/balance.scss: -------------------------------------------------------------------------------- 1 | .balance { 2 | padding-left: 0; 3 | padding-right: 0; 4 | 5 | &__button { 6 | padding: 0 15px; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /source/popup/pages/card/balance/balance.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/no-autofocus */ 2 | import React, { useRef, useState, useEffect } from 'react'; 3 | import { useTracking } from 'react-tracking'; 4 | import { RouteComponentProps } from 'react-router-dom'; 5 | import { AnimatePresence } from 'framer-motion'; 6 | import { resizeToFitPage } from '../../../../services/frame'; 7 | import { GiftCard, CardConfig } from '../../../../services/gift-card.types'; 8 | import CardHeader from '../../../components/card-header/card-header'; 9 | import './balance.scss'; 10 | import { getPrecision } from '../../../../services/currency'; 11 | import { getLatestBalanceEntry } from '../../../../services/gift-card'; 12 | import { wait } from '../../../../services/utils'; 13 | import CardMenu from '../../../components/card-menu/card-menu'; 14 | import ActionButton from '../../../components/action-button/action-button'; 15 | import { trackComponent } from '../../../../services/analytics'; 16 | 17 | const Balance: React.FC void; 19 | }> = ({ location, history, updateGiftCard }) => { 20 | const tracking = useTracking(); 21 | const ref = useRef(null); 22 | useEffect(() => { 23 | resizeToFitPage(ref, 80); 24 | }, [ref]); 25 | const { card, cardConfig, updateType = 'Amount Spent' } = location.state as { 26 | card: GiftCard; 27 | cardConfig: CardConfig; 28 | updateType: 'Amount Spent' | 'Remaining Balance'; 29 | }; 30 | const latestBalance = getLatestBalanceEntry(card).amount; 31 | const [formValid, setFormValid] = useState(true); 32 | const inputRef = useRef(null); 33 | // eslint-disable-next-line no-unused-expressions 34 | inputRef.current?.focus(); 35 | const step = getPrecision(cardConfig.currency) === 2 ? '0.01' : '1'; 36 | const min = updateType === 'Remaining Balance' ? 0 : step; 37 | const onEmailChange = (): void => { 38 | setFormValid(inputRef.current?.validity.valid || false); 39 | }; 40 | const saveValue = async (event: React.FormEvent): Promise => { 41 | event.preventDefault(); 42 | const value = parseFloat(inputRef.current?.value as string); 43 | const amount = updateType === 'Amount Spent' ? latestBalance - value : value; 44 | const updatedCard = { 45 | ...card, 46 | balanceHistory: [...(card.balanceHistory || []), { date: new Date().toISOString(), amount }] 47 | }; 48 | await updateGiftCard(updatedCard); 49 | tracking.trackEvent({ action: 'changedBalance', type: updateType, gaAction: `changedBalance:${updateType}` }); 50 | history.goBack(); 51 | }; 52 | const handleMenuClick = async (option: string): Promise => { 53 | await wait(100); 54 | const type = option.replace('Enter ', ''); 55 | tracking.trackEvent({ action: 'changedBalanceUpdateType', type }); 56 | history.goBack(); 57 | history.push({ 58 | pathname: `/card/${card.invoiceId}/balance`, 59 | state: { card, cardConfig, updateType: type } 60 | }); 61 | }; 62 | return ( 63 |
64 |
65 | 69 | 70 | 71 | 72 |
73 |
74 |
75 |
{updateType}
76 |
77 | 87 |
88 | {updateType === 'Amount Spent' ? ( 89 |
We'll automatically calculate what you have remaining
90 | ) : null} 91 |
92 |
93 |
94 | 95 | Save 96 | 97 |
98 |
99 |
100 |
101 | ); 102 | }; 103 | 104 | export default trackComponent(Balance, { page: 'balance' }); 105 | -------------------------------------------------------------------------------- /source/popup/pages/card/card.scss: -------------------------------------------------------------------------------- 1 | .card-details { 2 | padding: 10px 15px; 3 | position: relative; 4 | 5 | .action-button--warn { 6 | margin-bottom: 15px; 7 | } 8 | 9 | &__content { 10 | display: flex; 11 | flex-direction: column; 12 | 13 | &__code-box { 14 | > :first-child { 15 | margin-top: 10px; 16 | } 17 | > :not(:first-child) { 18 | margin-top: 14px; 19 | } 20 | > :last-child { 21 | margin-bottom: 10px; 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /source/popup/pages/cards/cards.scss: -------------------------------------------------------------------------------- 1 | .cards-page { 2 | padding: 15px; 3 | padding-bottom: 80px; 4 | @-moz-document url-prefix() { 5 | margin-bottom: 80px; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /source/popup/pages/cards/cards.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { motion } from 'framer-motion'; 4 | import { useTracking } from 'react-tracking'; 5 | import { GiftCard, CardConfig } from '../../../services/gift-card.types'; 6 | import { sortByDescendingDate } from '../../../services/gift-card'; 7 | import { resizeFrame } from '../../../services/frame'; 8 | import WalletCard from '../../components/wallet-cards/wallet-card'; 9 | import ActionButton from '../../components/action-button/action-button'; 10 | import { Merchant } from '../../../services/merchant'; 11 | import { trackComponent } from '../../../services/analytics'; 12 | import './cards.scss'; 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 15 | const Cards: React.FC = ({ location, purchasedGiftCards, merchants }) => { 16 | const tracking = useTracking(); 17 | const { cardConfig } = location.state as { cards: GiftCard[]; cardConfig: CardConfig }; 18 | const merchant = merchants.find((m: Merchant) => cardConfig.name === m.name); 19 | const cards = (purchasedGiftCards as GiftCard[]) 20 | .filter(card => card.name === cardConfig.name && !card.archived && card.status !== 'UNREDEEMED') 21 | .sort(sortByDescendingDate); 22 | resizeFrame(405); 23 | return ( 24 | <> 25 |
26 | 27 | {cards.map((card, index) => ( 28 | 29 | 39 | 40 | 41 | 42 | ))} 43 |
44 | {merchant && ( 45 |
46 | tracking.trackEvent({ action: 'clickedTopUp' })} 49 | > 50 | Top Up 51 | 52 |
53 | )} 54 | 55 | ); 56 | }; 57 | 58 | export default trackComponent(Cards, { page: 'cards' }); 59 | -------------------------------------------------------------------------------- /source/popup/pages/category/category.scss: -------------------------------------------------------------------------------- 1 | .category-page { 2 | position: relative; 3 | 4 | .zero-state { 5 | margin-top: 25vh; 6 | } 7 | .loading-spinner__wrapper { 8 | margin-top: 30vh; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /source/popup/pages/country/country.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | div.country { 4 | background-color: white; 5 | .settings-group { 6 | border-bottom: 0; 7 | } 8 | .settings-group__item__value { 9 | color: $blue; 10 | font-weight: 500; 11 | margin-right: 0; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /source/popup/pages/country/country.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction, useState } from 'react'; 2 | import { RouteComponentProps } from 'react-router-dom'; 3 | import { useTracking } from 'react-tracking'; 4 | import { trackComponent } from '../../../services/analytics'; 5 | import { PhoneCountryInfo } from '../../../services/gift-card.types'; 6 | import { getPhoneCountryCodes } from '../../../services/phone'; 7 | import SearchBar from '../../components/search-bar/search-bar'; 8 | import './country.scss'; 9 | 10 | const Country: React.FC>; 12 | }> = ({ setPhoneCountryInfo, history, location }) => { 13 | const { allowedPhoneCountries } = location.state as { allowedPhoneCountries: string[] }; 14 | const tracking = useTracking(); 15 | const phoneCountryCodes = getPhoneCountryCodes(); 16 | const [searchVal, setSearchVal] = useState('' as string); 17 | const selectPhoneCountryInfo = (newPhoneCountryInfo: PhoneCountryInfo): void => { 18 | setPhoneCountryInfo(newPhoneCountryInfo); 19 | history.goBack(); 20 | }; 21 | return ( 22 |
23 | 24 |
25 | {phoneCountryCodes 26 | .filter(phoneCountryCode => phoneCountryCode.name.toLowerCase().includes(searchVal.toLowerCase())) 27 | .filter(phoneCountryCode => 28 | allowedPhoneCountries ? allowedPhoneCountries.includes(phoneCountryCode.countryCode) : true 29 | ) 30 | .map((phoneCountryCode, index) => ( 31 | 50 | ))} 51 |
52 |
53 | ); 54 | }; 55 | 56 | export default trackComponent(Country, { page: 'country' }); 57 | -------------------------------------------------------------------------------- /source/popup/pages/payment/payment.scss: -------------------------------------------------------------------------------- 1 | .payment { 2 | padding: 10px 0; 3 | overflow-y: hidden !important; 4 | 5 | .line-items { 6 | padding: 0 25px; 7 | } 8 | 9 | .settings-group { 10 | margin-bottom: 20px; 11 | 12 | &__label { 13 | color: #73808c; 14 | } 15 | } 16 | 17 | .pay-with-bitpay { 18 | margin-top: 20px; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /source/popup/pages/payment/payment.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/no-autofocus */ 2 | import React, { useRef, useEffect, useState, Dispatch, SetStateAction } from 'react'; 3 | import { RouteComponentProps } from 'react-router-dom'; 4 | import { AnimatePresence } from 'framer-motion'; 5 | import PayWithBitpay from '../../components/pay-with-bitpay/pay-with-bitpay'; 6 | import { GiftCardInvoiceParams, CardConfig, UnsoldGiftCard, GiftCard } from '../../../services/gift-card.types'; 7 | import LineItems from '../../components/line-items/line-items'; 8 | import CardHeader from '../../components/card-header/card-header'; 9 | import { resizeToFitPage } from '../../../services/frame'; 10 | import { BitpayUser } from '../../../services/bitpay-id'; 11 | import { Merchant } from '../../../services/merchant'; 12 | import { trackComponent } from '../../../services/analytics'; 13 | import './payment.scss'; 14 | 15 | const Payment: React.FC>; 18 | purchasedGiftCards: GiftCard[]; 19 | setPurchasedGiftCards: Dispatch>; 20 | supportedMerchant?: Merchant; 21 | initiallyCollapsed: boolean; 22 | }> = ({ 23 | location, 24 | history, 25 | user, 26 | setEmail, 27 | purchasedGiftCards, 28 | setPurchasedGiftCards, 29 | supportedMerchant, 30 | initiallyCollapsed 31 | }) => { 32 | const ref = useRef(null); 33 | const emailRef = useRef(null); 34 | 35 | const { amount, invoiceParams, cardConfig, isFirstPage } = location.state as { 36 | amount: number; 37 | invoiceParams: GiftCardInvoiceParams; 38 | cardConfig: CardConfig; 39 | isFirstPage: boolean; 40 | }; 41 | 42 | const [email, setReceiptEmail] = useState(invoiceParams.email || ''); 43 | const card: UnsoldGiftCard = { 44 | amount, 45 | currency: invoiceParams.currency, 46 | name: cardConfig.name, 47 | coupons: cardConfig.coupons 48 | }; 49 | const shouldShowLineItems = !!( 50 | (cardConfig.coupons && cardConfig.coupons.length) || 51 | (cardConfig.activationFees && cardConfig.activationFees.length) 52 | ); 53 | const onEmailChange = (event: React.ChangeEvent): void => { 54 | emailRef.current?.validity.valid ? setReceiptEmail(event.target.value) : setReceiptEmail(''); 55 | }; 56 | useEffect(() => { 57 | if (initiallyCollapsed && isFirstPage) return; 58 | resizeToFitPage(ref, 71, 100); 59 | }, [ref, initiallyCollapsed, isFirstPage]); 60 | return ( 61 |
62 |
63 | 64 | 65 | {shouldShowLineItems && } 66 | 67 | {!invoiceParams.email && !user && ( 68 |
69 |
Email
70 |
71 | 72 |
73 |
Email used for purchase receipts and communication
74 |
75 | )} 76 | 86 |
87 |
88 | ); 89 | }; 90 | 91 | export default trackComponent(Payment, { page: 'payment' }); 92 | -------------------------------------------------------------------------------- /source/popup/pages/settings/account/account.scss: -------------------------------------------------------------------------------- 1 | @import "../../../../styles/variables"; 2 | 3 | .account { 4 | background: white; 5 | 6 | .settings-group { 7 | margin-bottom: 0; 8 | 9 | &--no-border { 10 | border: none; 11 | } 12 | 13 | &__item { 14 | &--dark { 15 | height: 70px; 16 | } 17 | &__avatar { 18 | margin-right: 14px; 19 | height: 34px; 20 | width: 34px; 21 | } 22 | &__label { 23 | .name, 24 | .email { 25 | color: $black; 26 | } 27 | .email { 28 | font-weight: $medium; 29 | } 30 | } 31 | &__note { 32 | color: $slateDark; 33 | font-size: 13px; 34 | font-weight: $regular; 35 | } 36 | } 37 | } 38 | 39 | &__linked { 40 | position: relative; 41 | } 42 | 43 | &__zero-state { 44 | padding: 15px; 45 | text-align: center; 46 | 47 | &__sign-in { 48 | display: block; 49 | height: 60px; 50 | } 51 | 52 | .action-button__spinner { 53 | animation: unset; 54 | } 55 | } 56 | &__title { 57 | font-size: 20px; 58 | color: $black; 59 | font-weight: $bold; 60 | margin-top: 10px; 61 | margin-bottom: 10px; 62 | } 63 | &__body { 64 | font-size: 14px; 65 | color: #565d6d; 66 | margin-bottom: 30px; 67 | padding: 0 15px; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /source/popup/pages/settings/archive/archive.scss: -------------------------------------------------------------------------------- 1 | .archive { 2 | padding-bottom: 0; 3 | .settings-group__item { 4 | padding-left: 17px; 5 | height: 63px; 6 | &:before { 7 | top: calc(50% + 1px); 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /source/popup/pages/settings/archive/archive.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { format } from 'date-fns'; 4 | import { resizeFrame } from '../../../../services/frame'; 5 | import { GiftCard, CardConfig } from '../../../../services/gift-card.types'; 6 | import { formatCurrency } from '../../../../services/currency'; 7 | import { sortByDescendingDate } from '../../../../services/gift-card'; 8 | import { wait } from '../../../../services/utils'; 9 | import { trackComponent } from '../../../../services/analytics'; 10 | import './archive.scss'; 11 | 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | const Archive: React.FC<{ purchasedGiftCards: GiftCard[]; supportedGiftCards: CardConfig[]; location: any }> = ({ 14 | purchasedGiftCards, 15 | supportedGiftCards, 16 | location 17 | }) => { 18 | const archivedGiftCards = purchasedGiftCards.filter(card => card.archived).sort(sortByDescendingDate); 19 | const ref = useRef(null); 20 | useEffect(() => { 21 | const setScrollPosition = async (): Promise => { 22 | if (location.state) { 23 | await wait(0); 24 | if (ref.current) ref.current.scrollTop = location.state.scrollTop || 0; 25 | } 26 | }; 27 | resizeFrame(450); 28 | setScrollPosition(); 29 | }, [ref, location.state]); 30 | const handleClick = (): void => { 31 | location.state = { scrollTop: ref.current?.scrollTop as number }; 32 | }; 33 | 34 | return ( 35 |
36 | {archivedGiftCards.length ? ( 37 |
38 |
Archive
39 | {archivedGiftCards.map((card, index) => { 40 | const cardConfig = supportedGiftCards.find(config => config.name === card.name); 41 | return ( 42 | 49 | {`${cardConfig?.displayName} 54 |
55 |
{cardConfig?.displayName}
56 |
57 | {format(new Date(card.date), 'MMM dd yyyy')} 58 |
59 |
60 |
61 | {formatCurrency(card.amount, card.currency, { hideSymbol: false })} 62 |
63 | 64 | ); 65 | })} 66 |
67 | ) : ( 68 |
69 |
No Archived Gift Cards
70 |
You haven't archived any gift cards yet
71 |
72 | )} 73 |
74 | ); 75 | }; 76 | 77 | export default trackComponent(Archive, { page: 'archive' }); 78 | -------------------------------------------------------------------------------- /source/popup/pages/settings/email/email.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/no-autofocus */ 2 | import React, { useRef, useState } from 'react'; 3 | import { useTracking } from 'react-tracking'; 4 | import { resizeFrame } from '../../../../services/frame'; 5 | import { set } from '../../../../services/storage'; 6 | import ActionButton from '../../../components/action-button/action-button'; 7 | import { trackComponent } from '../../../../services/analytics'; 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | const Email: React.FC<{ email: string; setEmail: (email: string) => void; history?: any }> = ({ 11 | email, 12 | setEmail, 13 | history 14 | }) => { 15 | const tracking = useTracking(); 16 | const [formValid, setFormValid] = useState(true); 17 | const emailRef = useRef(null); 18 | resizeFrame(293); 19 | const onEmailChange = (): void => { 20 | setFormValid(emailRef.current?.validity.valid || false); 21 | }; 22 | const saveEmail = async (event: React.FormEvent): Promise => { 23 | event.preventDefault(); 24 | const newEmail = emailRef.current?.value as string; 25 | await set('email', newEmail); 26 | setEmail(newEmail); 27 | history.goBack(); 28 | tracking.trackEvent({ action: 'changedEmail' }); 29 | }; 30 | return ( 31 |
32 |
33 |
34 |
35 |
Email
36 |
37 | 46 |
47 |
Email used for purchase receipts and communication
48 |
49 |
50 |
51 | 52 | Save 53 | 54 |
55 |
56 |
57 | ); 58 | }; 59 | 60 | export default trackComponent(Email, { page: 'email' }); 61 | -------------------------------------------------------------------------------- /source/popup/pages/settings/legal/legal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTracking } from 'react-tracking'; 3 | import { launchNewTab } from '../../../../services/browser'; 4 | import { trackComponent } from '../../../../services/analytics'; 5 | 6 | const Legal: React.FC = () => { 7 | const tracking = useTracking(); 8 | const launchTerms = (): void => { 9 | launchNewTab('https://bitpay.com/about/terms'); 10 | tracking.trackEvent({ action: 'clickedTermsOfUse' }); 11 | }; 12 | const launchPrivacyPolicy = (): void => { 13 | launchNewTab('https://bitpay.com/about/privacy'); 14 | tracking.trackEvent({ action: 'clickedPrivacyPolicy' }); 15 | }; 16 | return ( 17 |
18 |
19 |
Legal
20 | 23 | 26 |
27 |
28 | ); 29 | }; 30 | 31 | export default trackComponent(Legal, { page: 'legal' }); 32 | -------------------------------------------------------------------------------- /source/popup/pages/settings/settings.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .settings { 4 | background: $feather; 5 | 6 | .zero-state { 7 | background: white; 8 | height: 100%; 9 | 10 | &__subtitle { 11 | padding: 0 20px; 12 | text-align: center; 13 | } 14 | } 15 | } 16 | 17 | @mixin placeholder { 18 | &::-webkit-input-placeholder { 19 | @content; 20 | } 21 | &:-moz-placeholder { 22 | @content; 23 | } 24 | &::-moz-placeholder { 25 | @content; 26 | } 27 | &:-ms-input-placeholder { 28 | @content; 29 | } 30 | } 31 | 32 | .settings-group { 33 | $border-color: rgba(0, 0, 0, 0.05); 34 | $input-border: 1px solid $border-color; 35 | $left-padding: 16px; 36 | $cell-height: 52px; 37 | 38 | border-bottom: $input-border; 39 | 40 | &:last-child { 41 | margin-bottom: 18px; 42 | } 43 | 44 | &__label { 45 | font-size: 12px; 46 | color: rgba( 47 | $color: ( 48 | #384366 49 | ), 50 | $alpha: 0.5 51 | ); 52 | text-transform: uppercase; 53 | font-weight: $medium; 54 | border-bottom: $input-border; 55 | padding-top: 12px; 56 | padding-bottom: 12px; 57 | padding-left: $left-padding; 58 | } 59 | 60 | &__item { 61 | font-weight: $medium; 62 | color: rgba( 63 | $color: ( 64 | $slateDark 65 | ), 66 | $alpha: 0.65 67 | ); 68 | display: flex; 69 | align-items: center; 70 | font-size: 14px; 71 | background: white; 72 | position: relative; 73 | padding: 0 $left-padding + 2; 74 | width: 100%; 75 | text-align: left; 76 | height: $cell-height; 77 | 78 | &--long { 79 | height: 79px; 80 | 81 | > div > div.settings-group__item__value { 82 | margin-top: 6px; 83 | } 84 | } 85 | 86 | &--consent { 87 | margin-top: 15px; 88 | &:after { 89 | display: none; 90 | } 91 | 92 | > div { 93 | font-size: 11px; 94 | font-weight: 400; 95 | } 96 | } 97 | 98 | &:not(:last-child) { 99 | &:after { 100 | content: ""; 101 | height: 1px; 102 | width: calc(100% - #{$left-padding}); 103 | background: $border-color; 104 | bottom: 0; 105 | right: 0; 106 | position: absolute; 107 | } 108 | } 109 | 110 | &--link, 111 | &--dark { 112 | > img { 113 | height: 16px; 114 | margin-right: 10px; 115 | } 116 | } 117 | 118 | &--link { 119 | color: $blue; 120 | } 121 | 122 | &--dark { 123 | color: $black; 124 | font-weight: $medium; 125 | font-size: 14px; 126 | } 127 | 128 | &__avatar { 129 | border-radius: 50%; 130 | height: 24px; 131 | width: 24px; 132 | margin-right: 15px; 133 | } 134 | 135 | &__label { 136 | font-weight: $medium; 137 | 138 | > div:first-child { 139 | font-weight: $bold; 140 | color: $slateDark; 141 | } 142 | 143 | &__note { 144 | font-size: 14px; 145 | font-weight: $medium; 146 | color: $slateDark; 147 | opacity: 0.75; 148 | } 149 | 150 | &__subtext { 151 | font-weight: $regular; 152 | color: $slateDark; 153 | opacity: 0.5; 154 | } 155 | } 156 | 157 | &__value { 158 | color: rgba( 159 | $color: ( 160 | $slateDark 161 | ), 162 | $alpha: 0.5 163 | ); 164 | flex-grow: 1; 165 | font-weight: $regular; 166 | text-align: right; 167 | margin-right: -5px; 168 | 169 | > span { 170 | margin-right: 0; 171 | } 172 | 173 | button &, 174 | a & { 175 | margin-right: 12px; 176 | } 177 | } 178 | 179 | @at-root { 180 | a#{&}, 181 | button#{&} { 182 | &:hover { 183 | background: #fbfbfb; 184 | } 185 | &:before { 186 | content: url(/assets/icons/right-arrow.svg); 187 | background: transparent; 188 | position: absolute; 189 | right: 13px; 190 | top: calc(50% + 2px); 191 | transform: translateY(-50%); 192 | } 193 | 194 | &.no-arrow { 195 | &:before { 196 | display: none; 197 | } 198 | } 199 | } 200 | } 201 | } 202 | 203 | &__input { 204 | padding: 0 $left-padding; 205 | background: white; 206 | height: $cell-height; 207 | display: flex; 208 | align-items: center; 209 | 210 | &__country { 211 | display: flex; 212 | align-items: center; 213 | } 214 | 215 | &__flag { 216 | height: 18px; 217 | margin-right: 10px; 218 | } 219 | 220 | &__country-code { 221 | color: $black; 222 | font-size: 14px; 223 | font-weight: 500; 224 | margin-right: 7px; 225 | } 226 | 227 | > input { 228 | font-size: 14px; 229 | width: 100%; 230 | font-weight: $medium; 231 | color: $black; 232 | 233 | @include placeholder { 234 | color: rgba( 235 | $color: ( 236 | $slateDark 237 | ), 238 | $alpha: 0.5 239 | ); 240 | font-weight: $regular; 241 | } 242 | } 243 | 244 | + div { 245 | &:after { 246 | content: ""; 247 | height: 1px; 248 | width: calc(100% - #{$left-padding}); 249 | background: $border-color; 250 | top: 0; 251 | right: 0; 252 | position: absolute; 253 | } 254 | } 255 | } 256 | 257 | &__caption { 258 | position: relative; 259 | background: white; 260 | font-size: 11px; 261 | color: rgba(0, 0, 0, 0.4); 262 | padding: 15px $left-padding; 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /source/popup/pages/settings/settings.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, SetStateAction } from 'react'; 2 | import classNames from 'classnames'; 3 | import { Link } from 'react-router-dom'; 4 | import { useTracking } from 'react-tracking'; 5 | import { trackComponent } from '../../../services/analytics'; 6 | import { resizeFrame } from '../../../services/frame'; 7 | import { BitpayUser } from '../../../services/bitpay-id'; 8 | import { launchNewTab } from '../../../services/browser'; 9 | import Gravatar from '../../components/gravatar/gravatar'; 10 | import packageJson from '../../../../package.json'; 11 | import { IOSSwitch } from '../../components/ios-switch/ios-switch'; 12 | import { set } from '../../../services/storage'; 13 | import './settings.scss'; 14 | 15 | const Settings: React.FC<{ 16 | clientId: string; 17 | email: string; 18 | user: BitpayUser; 19 | promptAtCheckout: boolean; 20 | setPromptAtCheckout: Dispatch>; 21 | }> = ({ clientId, email, user, promptAtCheckout, setPromptAtCheckout }) => { 22 | const tracking = useTracking(); 23 | resizeFrame(450); 24 | const launchRepo = (): void => { 25 | launchNewTab('https://github.com/bitpay/bitpay-browser-extension'); 26 | tracking.trackEvent({ action: 'clickedVersion' }); 27 | }; 28 | const handlePromptAtCheckoutChange = async (): Promise => { 29 | const newPromptAtCheckout = !promptAtCheckout; 30 | await set('promptAtCheckout', newPromptAtCheckout); 31 | setPromptAtCheckout(newPromptAtCheckout); 32 | tracking.trackEvent({ action: newPromptAtCheckout ? 'enabledPromptAtCheckout' : 'disabledPromptAtCheckout' }); 33 | }; 34 | return ( 35 |
36 |
37 |
Wallet
38 | 39 | Archived Gift Cards 40 | 41 |
42 |
Prompt at Checkout
43 |
44 | 45 |
46 |
47 |
Automatically show BitPay widget at checkout
48 |
49 |
50 |
BitPay Account
51 | 60 | {user ? ( 61 | 62 | ) : ( 63 | BitPay Logo 64 | )} 65 | {user ? <>{user.email} : <>Connect to BitPay} 66 | 67 |
68 | {user ? ( 69 | <>Your BitPay account is linked to this app 70 | ) : ( 71 | <>Sign in with BitPay to sync your gift card purchases 72 | )} 73 |
74 |
75 | {email && !user && ( 76 |
77 |
Email
78 | 79 | {email || 'None'} 80 | 81 |
Email used for purchase receipts and communication
82 |
83 | )} 84 |
85 |
Other
86 | 87 | Legal 88 | 89 | 93 |
94 |
95 |
Extension ID
96 |
{clientId}
97 |
98 |
99 |
100 |
101 | ); 102 | }; 103 | 104 | export default trackComponent(Settings, { page: 'settings' }); 105 | -------------------------------------------------------------------------------- /source/popup/pages/shop/shop.scss: -------------------------------------------------------------------------------- 1 | @import "../../../styles/variables"; 2 | 3 | .shop-page { 4 | position: relative; 5 | 6 | &__content { 7 | min-height: 333px; 8 | padding: 5px 10px 10px; 9 | } 10 | 11 | &__section-header { 12 | display: flex; 13 | align-items: center; 14 | justify-content: space-between; 15 | padding: 10px 12px 12px 16px; 16 | font-weight: $medium; 17 | font-size: 13px; 18 | color: rgba($slateDark, 0.75); 19 | 20 | &--action { 21 | font-weight: $medium; 22 | font-size: 12px; 23 | color: $blue; 24 | } 25 | 26 | &--emoji { 27 | justify-content: flex-start; 28 | transform: translateY(-2px); 29 | margin-right: 4px; 30 | font-size: 15px; 31 | color: black; 32 | @-moz-document url-prefix() { 33 | transform: translateY(0); 34 | margin-right: 8px; 35 | } 36 | } 37 | 38 | &--large { 39 | font-weight: $bold; 40 | font-size: 16px; 41 | color: $fossil; 42 | } 43 | 44 | &--wrapper { 45 | display: flex; 46 | align-items: center; 47 | } 48 | } 49 | 50 | &__divider { 51 | background-color: rgba(black, 0.05); 52 | height: 1px; 53 | width: 90%; 54 | margin: 18px auto; 55 | } 56 | 57 | &__categories { 58 | $border-color: rgba(0, 0, 0, 0.05); 59 | $input-border: 1px solid $border-color; 60 | $left-padding: 16px; 61 | $cell-height: 52px; 62 | 63 | &__item { 64 | font-weight: $medium; 65 | font-size: 14px; 66 | color: $blue; 67 | display: flex; 68 | align-items: center; 69 | position: relative; 70 | padding: 0 $left-padding + 2; 71 | width: 100%; 72 | text-align: left; 73 | height: $cell-height; 74 | 75 | &:not(:last-child) { 76 | &:after { 77 | content: ""; 78 | height: 1px; 79 | background: $border-color; 80 | bottom: 0; 81 | right: 0; 82 | left: calc(#{$left-padding}); 83 | position: absolute; 84 | transition: all 250ms linear; 85 | } 86 | } 87 | 88 | &:before { 89 | content: ""; 90 | background: darken(#f8fbfd, 1%); 91 | top: 0; 92 | bottom: 0; 93 | right: -10px; 94 | left: -10px; 95 | position: absolute; 96 | z-index: -1; 97 | transition: all 250ms ease-in-out; 98 | opacity: 0; 99 | } 100 | 101 | &:hover { 102 | &:before { 103 | opacity: 1; 104 | } 105 | &:not(:last-child) { 106 | &:after { 107 | transform: scaleX(1.25); 108 | } 109 | } 110 | } 111 | 112 | &__icon { 113 | font-size: 18px; 114 | margin-right: 10px; 115 | transform: translateY(-1px); 116 | @-moz-document url-prefix() { 117 | transform: translateY(0); 118 | } 119 | } 120 | } 121 | } 122 | 123 | .zero-state { 124 | margin-top: 25vh; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /source/popup/pages/wallet/wallet.scss: -------------------------------------------------------------------------------- 1 | .wallet { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100%; 5 | padding-top: 12px; 6 | 7 | .wallet-codes { 8 | flex-grow: 1; 9 | 10 | .zero-state { 11 | height: 100%; 12 | min-height: 180px; 13 | margin-bottom: 20px; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /source/popup/pages/wallet/wallet.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react'; 2 | import { motion } from 'framer-motion'; 3 | import './wallet.scss'; 4 | import { GiftCard, CardConfig } from '../../../services/gift-card.types'; 5 | import { Merchant } from '../../../services/merchant'; 6 | import MerchantCta from '../../components/merchant-cta/merchant-cta'; 7 | import WalletCards from '../../components/wallet-cards/wallet-cards'; 8 | import { sortByDescendingDate } from '../../../services/gift-card'; 9 | import { resizeToFitPage } from '../../../services/frame'; 10 | import { trackComponent } from '../../../services/analytics'; 11 | 12 | const Wallet: React.FC<{ 13 | supportedMerchant?: Merchant; 14 | supportedGiftCards: CardConfig[]; 15 | purchasedGiftCards: GiftCard[]; 16 | }> = ({ supportedMerchant, supportedGiftCards, purchasedGiftCards }) => { 17 | const ref = useRef(null); 18 | useEffect(() => { 19 | resizeToFitPage(ref, 100); 20 | }, [ref]); 21 | const activeGiftCards = purchasedGiftCards 22 | .filter(card => !card.archived && card.status !== 'UNREDEEMED') 23 | .sort(sortByDescendingDate); 24 | return ( 25 | 26 |
27 | 28 |
29 | {activeGiftCards.length && supportedGiftCards.length ? ( 30 | 31 | ) : ( 32 |
33 |
No Codes Yet
34 |
Your purchased credits will show up here
35 |
36 | )} 37 |
38 |
39 |
40 | ); 41 | }; 42 | 43 | export default trackComponent(Wallet, { page: 'wallet' }); 44 | -------------------------------------------------------------------------------- /source/popup/styles.scss: -------------------------------------------------------------------------------- 1 | @import "../styles/fonts"; 2 | @import "../styles/reset"; 3 | @import "../styles/variables"; 4 | @import "../styles/animations"; 5 | 6 | body { 7 | color: black; 8 | background-color: white; 9 | } 10 | 11 | html, 12 | body, 13 | #popup-root { 14 | display: flex; 15 | flex-direction: column; 16 | height: 100%; 17 | overflow: hidden; 18 | 19 | .header-bar + div { 20 | flex-grow: 1; 21 | overflow-x: hidden; 22 | overflow-y: auto; 23 | } 24 | 25 | .ellipsis { 26 | display: block; 27 | overflow: hidden; 28 | text-overflow: ellipsis; 29 | white-space: nowrap; 30 | } 31 | } 32 | 33 | button { 34 | background: none; 35 | cursor: pointer; 36 | } 37 | 38 | .zero-state { 39 | display: flex; 40 | align-items: center; 41 | justify-content: center; 42 | flex-direction: column; 43 | 44 | &__title { 45 | font-size: 14px; 46 | color: rgba(black, 0.4); 47 | font-weight: $medium; 48 | } 49 | 50 | &__subtitle { 51 | font-size: 12px; 52 | color: rgba(black, 0.25); 53 | margin-top: 4px; 54 | } 55 | } 56 | 57 | span.MuiTooltip-arrow { 58 | color: $black; 59 | } 60 | 61 | a { 62 | color: $blue; 63 | } 64 | -------------------------------------------------------------------------------- /source/services/analytics.ts: -------------------------------------------------------------------------------- 1 | import track, { Options } from 'react-tracking'; 2 | import packageJson from '../../package.json'; 3 | import { get } from './storage'; 4 | 5 | let cachedAnalyticsClientId: string | undefined; 6 | 7 | function getSafePathname(pathname: string): string { 8 | const parts = pathname.split('/'); 9 | const invoiceIdIndex = 2; 10 | const safeParts = [...parts.slice(0, invoiceIdIndex), ...parts.slice(invoiceIdIndex + 1)]; 11 | return pathname.startsWith('/card') ? `${safeParts.join('/')}` : pathname; 12 | } 13 | 14 | export function trackComponent( 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | component: React.FC, 17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 18 | eventProperties: any = {}, 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | options: Options> = {} 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | ): React.FC { 23 | return track( 24 | props => ({ 25 | ...eventProperties, 26 | ...(props.location && props.location.pathname && { pathname: getSafePathname(props.location.pathname) }) 27 | }), 28 | eventProperties.page ? { ...options, dispatchOnMount: true } : { ...options } 29 | )(component); 30 | } 31 | export async function sendEventToGa(event: { [key: string]: string }): Promise { 32 | const clientId = cachedAnalyticsClientId || (await get('analyticsClientId')); 33 | cachedAnalyticsClientId = clientId; 34 | const request = new XMLHttpRequest(); 35 | const eventTypeAndData = 36 | event.action === 'viewedPage' 37 | ? `&t=pageview&dp=${event.pathname}&dt=${event.page}` 38 | : `&t=event&ea=${event.gaAction || event.action}`; 39 | const message = `v=1&tid=${process.env.GA_UA}&cid=${clientId}&aip=1&ds=add-on&ec=${packageJson.version}${eventTypeAndData}`; 40 | 41 | request.open('POST', 'https://www.google-analytics.com/collect', true); 42 | request.send(message); 43 | } 44 | 45 | export function dispatchEvent(event: { [key: string]: string }): void { 46 | if (process.env.TARGET_BROWSER === 'firefox') return; 47 | sendEventToGa(event); 48 | } 49 | -------------------------------------------------------------------------------- /source/services/animations.ts: -------------------------------------------------------------------------------- 1 | export const listAnimation = { 2 | base: (i: number): Record => ({ 3 | opacity: 1, 4 | y: 0, 5 | transition: { 6 | type: 'spring', 7 | damping: 20, 8 | stiffness: 250, 9 | delay: i * 0.04 10 | } 11 | }), 12 | delta: { opacity: 0, y: -32 } 13 | }; 14 | 15 | export const buttonAnimation = { 16 | visible: { 17 | opacity: 1, 18 | transition: { 19 | type: 'tween', 20 | duration: 0.2, 21 | when: 'beforeChildren', 22 | staggerChildren: 0.075 23 | } 24 | }, 25 | hidden: { 26 | opacity: 0 27 | } 28 | }; 29 | 30 | export const buttonTextAnimation = { 31 | visible: { 32 | opacity: 1, 33 | x: 0, 34 | transition: { 35 | type: 'spring', 36 | damping: 100, 37 | stiffness: 100, 38 | mass: 0.25 39 | } 40 | }, 41 | hidden: { 42 | opacity: 0, 43 | x: 12 44 | } 45 | }; 46 | 47 | export const buttonSpinnerAnimation = { 48 | visible: { 49 | opacity: 1, 50 | x: 0, 51 | transition: { 52 | type: 'spring', 53 | damping: 100, 54 | stiffness: 150, 55 | mass: 0.5 56 | } 57 | }, 58 | hidden: { 59 | opacity: 0, 60 | x: 10 61 | } 62 | }; 63 | 64 | export const spinAnimation = { 65 | visible: { 66 | rotate: 360, 67 | transition: { 68 | loop: Infinity, 69 | ease: 'linear', 70 | duration: 1 71 | } 72 | } 73 | }; 74 | 75 | export const upperCut = { 76 | visible: (i: number): Record => ({ 77 | opacity: 1, 78 | rotateX: 0, 79 | y: 0, 80 | transition: { 81 | type: 'spring', 82 | damping: 25, 83 | delay: i * 0.15 84 | } 85 | }), 86 | hidden: { 87 | y: 20, 88 | rotateX: 10, 89 | opacity: 0 90 | } 91 | }; 92 | 93 | export const counterPunch = { 94 | visible: (i: number): Record => ({ 95 | opacity: 1, 96 | rotateX: 0, 97 | y: 0, 98 | transition: { 99 | type: 'spring', 100 | damping: 25, 101 | delay: i * 0.15 102 | } 103 | }), 104 | hidden: { 105 | y: -20, 106 | rotateX: -8, 107 | opacity: 0 108 | } 109 | }; 110 | -------------------------------------------------------------------------------- /source/services/bitpay-id.ts: -------------------------------------------------------------------------------- 1 | import * as bitauthService from 'bitauth'; 2 | import { keyBy } from 'lodash'; 3 | import { sortByDescendingDate } from './gift-card'; 4 | import { GiftCard } from './gift-card.types'; 5 | import { get, set } from './storage'; 6 | import { post } from './utils'; 7 | 8 | export interface BitauthIdentity { 9 | created: number; 10 | priv: string; 11 | pub: string; 12 | sin: string; 13 | } 14 | 15 | export interface BitpayUser { 16 | eid?: string; 17 | email: string; 18 | familyName?: string; 19 | givenName?: string; 20 | syncGiftCards: boolean; 21 | token: string; 22 | incentiveLevel: string; 23 | incentiveLevelId: string; 24 | } 25 | export interface PairingData { 26 | code?: string; 27 | secret: string; 28 | } 29 | 30 | async function getIdentity(): Promise { 31 | const bitauthIdentity = (await get('bitauthIdentity')) || bitauthService.generateSin(); 32 | await set('bitauthIdentity', bitauthIdentity); 33 | return bitauthIdentity; 34 | } 35 | 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | export async function apiCall(token: string, method: string, params: any = {}): Promise { 38 | const url = `${process.env.API_ORIGIN}/api/v2/`; 39 | const json = { 40 | method, 41 | params: JSON.stringify(params), 42 | token 43 | }; 44 | const dataToSign = `${url}${token}${JSON.stringify(json)}`; 45 | const appIdentity = await getIdentity(); 46 | const signedData = bitauthService.sign(dataToSign, appIdentity.priv); 47 | const headers = { 48 | 'content-type': 'application/json', 49 | 'x-identity': appIdentity.pub, 50 | 'x-signature': signedData 51 | }; 52 | const res = await post(`${url}${token}`, json, { headers }); 53 | if (res && res.error) { 54 | throw new Error(res.error); 55 | } 56 | return res && res.data; 57 | } 58 | 59 | export async function syncGiftCards(user: BitpayUser): Promise { 60 | const savedGiftCards = (await get('purchasedGiftCards')) || []; 61 | const syncedGiftCards = savedGiftCards.filter(giftCard => giftCard.userEid === user.eid).sort(sortByDescendingDate); 62 | const latestSyncDate = syncedGiftCards[0]?.date; 63 | const olderThanThreeDays = (dateString: string): boolean => { 64 | const threeDaysAgo = Date.now() - 1000 * 60 * 60 * 24 * 3; 65 | return new Date(dateString).getTime() < threeDaysAgo; 66 | }; 67 | const unsyncedGiftCardsResponse = await apiCall(user.token, 'findGiftCards', { dateStart: latestSyncDate }); 68 | const unsyncedGiftCards = unsyncedGiftCardsResponse.map((resObj: any) => ({ 69 | ...resObj, 70 | brand: undefined, 71 | createdOn: undefined, 72 | name: resObj.brand, 73 | date: resObj.createdOn, 74 | userEid: user.eid, 75 | archived: olderThanThreeDays(resObj.createdOn), 76 | totalDiscount: resObj.totalDiscount, 77 | status: 'SYNCED' 78 | })) as GiftCard[]; 79 | if (!unsyncedGiftCards.length) { 80 | return savedGiftCards; 81 | } 82 | const giftCardMap = keyBy(savedGiftCards, giftCard => giftCard.invoiceId); 83 | const giftCards = unsyncedGiftCards.reduce( 84 | (newSavedGiftCards, unsyncedGiftCard) => 85 | giftCardMap[unsyncedGiftCard.invoiceId] ? newSavedGiftCards : newSavedGiftCards.concat(unsyncedGiftCard), 86 | savedGiftCards as GiftCard[] 87 | ); 88 | await set('purchasedGiftCards', giftCards); 89 | return giftCards; 90 | } 91 | 92 | export async function refreshUserInfo(token: string): Promise { 93 | const [userRes, previouslySavedUser] = await Promise.all([ 94 | apiCall(token, 'getBasicInfo'), 95 | get('bitpayUser') 96 | ]); 97 | if (userRes) { 98 | if (userRes.error) { 99 | throw userRes.error; 100 | } 101 | const { eid, email, familyName, givenName, incentiveLevel, incentiveLevelId } = userRes; 102 | const user = { 103 | eid, 104 | email, 105 | familyName, 106 | givenName, 107 | token, 108 | syncGiftCards: previouslySavedUser ? previouslySavedUser.syncGiftCards : true, 109 | incentiveLevel, 110 | incentiveLevelId 111 | }; 112 | await set('bitpayUser', user); 113 | } 114 | } 115 | 116 | export async function refreshUserInfoIfNeeded(forceRefresh = false): Promise { 117 | const user = await get('bitpayUser'); 118 | if (user && (!Object.prototype.hasOwnProperty.call(user, 'incentiveLevel') || forceRefresh)) { 119 | await refreshUserInfo(user.token); 120 | } 121 | } 122 | 123 | export async function generatePairingToken(payload: PairingData): Promise { 124 | const { secret, code } = payload; 125 | const appIdentity = await getIdentity(); 126 | const params = { 127 | ...(code && { code }), 128 | secret, 129 | version: 2, 130 | deviceName: 'Pay With BitPay Browser Extension' 131 | }; 132 | const dataToSign = JSON.stringify(params); 133 | const signature = bitauthService.sign(dataToSign, appIdentity.priv); 134 | const finalParamsObject = { 135 | ...params, 136 | pubkey: appIdentity.pub, 137 | signature 138 | }; 139 | const requestParams = { 140 | method: 'createToken', 141 | params: JSON.stringify(finalParamsObject) 142 | }; 143 | const { data: token }: { data: string } = await post(`${process.env.API_ORIGIN}/api/v2/`, requestParams); 144 | await refreshUserInfo(token); 145 | } 146 | -------------------------------------------------------------------------------- /source/services/browser.ts: -------------------------------------------------------------------------------- 1 | import { browser } from 'webextension-polyfill-ts'; 2 | import { CardConfig } from './gift-card.types'; 3 | 4 | export const launchNewTab = (url: string): void => { 5 | browser.runtime.sendMessage({ 6 | name: 'LAUNCH_TAB', 7 | url 8 | }); 9 | }; 10 | 11 | export const goToPage = (link: string): void => { 12 | const detectProtocolPresent = /^https?:\/\//i; 13 | const url = detectProtocolPresent.test(link) ? link : `https://${link}`; 14 | browser.runtime.sendMessage({ 15 | name: 'REDIRECT', 16 | url 17 | }); 18 | }; 19 | 20 | export const dispatchUrlChange = (window: Window): void => { 21 | browser.runtime.sendMessage(undefined, { 22 | name: 'URL_CHANGED', 23 | url: window.location.href 24 | }); 25 | }; 26 | 27 | export const dispatchAnalyticsEvent = (event: { [key: string]: string }): void => { 28 | browser.runtime.sendMessage(undefined, { 29 | name: 'TRACK', 30 | event 31 | }); 32 | }; 33 | 34 | export const injectClaimInfo = (cardConfig: CardConfig, claimInfo: { claimCode: string; pin?: string }): void => { 35 | browser.runtime.sendMessage(undefined, { 36 | name: 'INJECT_CLAIM_INFO', 37 | cssSelectors: cardConfig.cssSelectors, 38 | claimInfo 39 | }); 40 | }; 41 | 42 | export const refreshMerchantCache = (): void => { 43 | browser.runtime.sendMessage(undefined, { 44 | name: 'REFRESH_MERCHANT_CACHE' 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /source/services/copy-util.ts: -------------------------------------------------------------------------------- 1 | const COPY_FAILED = new Error('Copy to Clipboard Failed!'); 2 | 3 | const zeroStyles = (i: HTMLInputElement, ...properties: string[]): void => { 4 | for (const property of properties) { 5 | i.style.setProperty(property, '0'); 6 | } 7 | }; 8 | 9 | const createInput = (): HTMLInputElement => { 10 | const i: HTMLInputElement = document.createElement('input'); 11 | zeroStyles(i, 'border-width', 'outline-width', 'right', 'bottom', 'opacity'); 12 | i.style.setProperty('position', 'absolute'); 13 | i.style.setProperty('box-sizing', 'border-box'); 14 | i.style.setProperty('outline-color', 'transparent'); 15 | i.style.setProperty('background-color', 'transparent'); 16 | i.style.setProperty('overflow', 'hidden'); 17 | i.style.setProperty('margin', '0 0 0 0'); 18 | i.style.setProperty('padding', '0 0 0 0'); 19 | i.style.setProperty('height', '1px'); 20 | i.style.setProperty('width', '1px'); 21 | i.style.setProperty('max-height', '1px'); 22 | i.style.setProperty('max-width', '1px'); 23 | i.style.setProperty('min-height', '1px'); 24 | i.style.setProperty('min-width', '1px'); 25 | document.body.appendChild(i); 26 | return i; 27 | }; 28 | 29 | const removeInput = (i: HTMLInputElement): void => { 30 | document.body.removeChild(i); 31 | }; 32 | 33 | const write = (text: string): void => { 34 | const i = createInput(); 35 | i.setAttribute('value', text); 36 | i.select(); 37 | const success = document.execCommand('copy'); 38 | removeInput(i); 39 | if (!success) { 40 | throw COPY_FAILED; 41 | } 42 | }; 43 | 44 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 45 | const copyUtil = (target: string): any => { 46 | async function writeClipboard(text: string): Promise { 47 | try { 48 | write(text); 49 | } catch (e) { 50 | try { 51 | await navigator.clipboard.writeText(text); 52 | } catch (_e) { 53 | throw COPY_FAILED; 54 | } 55 | } 56 | } 57 | return writeClipboard(target); 58 | }; 59 | 60 | export default copyUtil; 61 | -------------------------------------------------------------------------------- /source/services/currency.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-nested-ternary */ 2 | export function getPrecision(currencyCode: string): number { 3 | return currencyCode.toUpperCase() === 'JPY' ? 0 : 2; 4 | } 5 | function getMinimalPrecision(amount: number, currencyCode: string): number { 6 | return Number.isInteger(amount) ? 0 : getPrecision(currencyCode); 7 | } 8 | 9 | export const currencySymbols = { 10 | BRL: 'R$', 11 | CAD: 'C$', 12 | EUR: '€', 13 | GBP: '£', 14 | INR: '₹', 15 | JPY: '¥', 16 | PHP: '₱', 17 | USD: '$' 18 | } as { [currency: string]: string }; 19 | 20 | export function formatCurrency( 21 | amount: number, 22 | currencyCode: string, 23 | opts: { customPrecision?: number | 'minimal'; hideSymbol?: boolean } = {} 24 | ): string { 25 | const precision = 26 | opts.customPrecision === 'minimal' 27 | ? getMinimalPrecision(amount, currencyCode) 28 | : typeof opts.customPrecision === 'number' 29 | ? opts.customPrecision 30 | : getPrecision(currencyCode); 31 | const formatter = new Intl.NumberFormat('en-US', { 32 | style: 'decimal', 33 | minimumFractionDigits: precision, 34 | maximumFractionDigits: precision 35 | }); 36 | const numericValue = formatter.format(amount); 37 | const symbol = opts.hideSymbol ? undefined : currencySymbols[currencyCode.toUpperCase()]; 38 | const finalValue = symbol ? `${symbol}${numericValue}` : `${numericValue} ${currencyCode}`; 39 | 40 | return finalValue; 41 | } 42 | -------------------------------------------------------------------------------- /source/services/directory.ts: -------------------------------------------------------------------------------- 1 | import { Merchant } from './merchant'; 2 | import { set, get } from './storage'; 3 | 4 | export interface DirectoryCurationApiObject { 5 | displayName: string; 6 | merchants: string[]; 7 | } 8 | 9 | export interface DirectoryCategoryApiObject { 10 | displayName: string; 11 | emoji: string; 12 | tags: string[]; 13 | } 14 | 15 | export interface CurationsObject { 16 | [curation: string]: DirectoryCurationApiObject; 17 | } 18 | 19 | export interface CategoriesObject { 20 | [category: string]: DirectoryCategoryApiObject; 21 | } 22 | 23 | export interface DirectoryRawData { 24 | curated: CurationsObject; 25 | categories: CategoriesObject; 26 | } 27 | 28 | export interface DirectoryCuration extends DirectoryCurationApiObject { 29 | availableMerchants: Merchant[]; 30 | name: string; 31 | } 32 | 33 | export interface DirectoryCategory extends DirectoryCategoryApiObject { 34 | availableMerchants: Merchant[]; 35 | name: string; 36 | } 37 | 38 | export interface Directory { 39 | curated: DirectoryCuration[]; 40 | categories: DirectoryCategory[]; 41 | } 42 | 43 | export interface DirectoryDiscount { 44 | type: 'flatrate' | 'percentage' | 'custom'; 45 | displayType?: 'boost' | 'discount'; 46 | amount?: number; 47 | currency?: string; 48 | value?: string; 49 | } 50 | 51 | export interface DirectIntegrationApiObject { 52 | displayName: string; 53 | caption: string; 54 | cta?: { 55 | displayText: string; 56 | link: string; 57 | }; 58 | icon: string; 59 | link: string; 60 | displayLink: string; 61 | tags: string[]; 62 | domains: string[]; 63 | discount?: DirectoryDiscount; 64 | theme: string; 65 | instructions: string; 66 | } 67 | 68 | export interface DirectIntegration extends DirectIntegrationApiObject { 69 | name: string; 70 | } 71 | 72 | export interface DirectIntegrationMap { 73 | [name: string]: DirectIntegrationApiObject; 74 | } 75 | 76 | export const getDirectIntegrations = (res: DirectIntegrationMap): DirectIntegration[] => 77 | Object.keys(res).map(name => ({ ...res[name], name })); 78 | 79 | export function fetchDirectIntegrations(): Promise { 80 | return fetch(`${process.env.API_ORIGIN}/merchant-directory/integrations`) 81 | .then(res => res.json()) 82 | .then((merchantMap: DirectIntegrationMap) => getDirectIntegrations(merchantMap)); 83 | } 84 | 85 | export function convertToArray(object: { [key: string]: T }): T[] { 86 | return Object.keys(object).map(key => ({ name: key, ...object[key] })); 87 | } 88 | 89 | export function convertObjectsToArrays(directory: DirectoryRawData): Directory { 90 | const categories = convertToArray(directory.categories); 91 | const curated = convertToArray(directory.curated); 92 | const newDirectory = { curated, categories } as Directory; 93 | return newDirectory; 94 | } 95 | 96 | export const saturateDirectory = ( 97 | unsaturatedDirectory: Directory = { curated: [], categories: [] }, 98 | merchants: Merchant[] = [] 99 | ): Directory => { 100 | const directory = { ...unsaturatedDirectory } as Directory; 101 | directory.curated = unsaturatedDirectory.curated 102 | .map(curation => ({ 103 | ...curation, 104 | availableMerchants: merchants 105 | .filter( 106 | merchant => 107 | curation.merchants.includes(merchant.displayName) || 108 | (curation.displayName === 'Popular Brands' && merchant.featured) 109 | ) 110 | .sort( 111 | (a: Merchant, b: Merchant) => 112 | curation.merchants.indexOf(a.displayName) - curation.merchants.indexOf(b.displayName) 113 | ) 114 | })) 115 | .filter(curation => curation.availableMerchants.length); 116 | directory.categories = unsaturatedDirectory.categories 117 | .map(category => ({ 118 | ...category, 119 | availableMerchants: merchants.filter(merchant => category.tags.some(tag => merchant.tags.includes(tag))) 120 | })) 121 | .filter(category => category.availableMerchants.length); 122 | return directory; 123 | }; 124 | 125 | export async function fetchDirectory(): Promise { 126 | const directory = await fetch(`${process.env.API_ORIGIN}/merchant-directory/directory`).then(res => res.json()); 127 | const newDirectory: Directory = convertObjectsToArrays(directory); 128 | await set('directory', newDirectory); 129 | return newDirectory; 130 | } 131 | 132 | export async function getCachedDirectory(): Promise { 133 | // TODO: Remove this method in a few months (after we're sure everyone has the updated directory schema saved) 134 | const savedDirectory = (await get('directory')) || { 135 | curated: [], 136 | categories: [] 137 | }; 138 | return savedDirectory && Array.isArray(savedDirectory.categories) && Array.isArray(savedDirectory.curated) 139 | ? (savedDirectory as Directory) 140 | : convertObjectsToArrays(savedDirectory as DirectoryRawData); 141 | } 142 | -------------------------------------------------------------------------------- /source/services/frame.ts: -------------------------------------------------------------------------------- 1 | import { browser } from 'webextension-polyfill-ts'; 2 | 3 | export enum FrameDimensions { 4 | amountPageHeight = 360, 5 | collapsedHeight = 47, 6 | height = 350, 7 | width = 300, 8 | maxFrameHeight = 600, 9 | minExpandedFrameHeight = 200, 10 | zIndex = 2147483647 11 | } 12 | 13 | export const resizeFrame = (height: number): void => { 14 | browser.runtime.sendMessage({ name: `POPUP_RESIZED`, height }); 15 | }; 16 | 17 | export const resizeToFitPage = (ref: React.RefObject, padding = 0, delay = 10): void => { 18 | setTimeout(() => { 19 | const fullHeight = ref.current ? ref.current.scrollHeight + padding : FrameDimensions.minExpandedFrameHeight; 20 | const height = 21 | // eslint-disable-next-line no-nested-ternary 22 | fullHeight < FrameDimensions.minExpandedFrameHeight 23 | ? FrameDimensions.minExpandedFrameHeight 24 | : fullHeight > FrameDimensions.maxFrameHeight 25 | ? FrameDimensions.maxFrameHeight 26 | : fullHeight; 27 | resizeFrame(height); 28 | }, delay); 29 | }; 30 | -------------------------------------------------------------------------------- /source/services/gift-card-storage.ts: -------------------------------------------------------------------------------- 1 | import { Observable, Observer } from 'rxjs'; 2 | import { GiftCard, Invoice } from './gift-card.types'; 3 | import { set } from './storage'; 4 | import { redeemGiftCard, getBitPayInvoice } from './gift-card'; 5 | import { BitpayUser, apiCall } from './bitpay-id'; 6 | 7 | export interface CustomEvent extends Event { 8 | data: string; 9 | } 10 | 11 | export const updateCard = async (card: GiftCard, purchasedGiftCards: GiftCard[]): Promise => { 12 | const newCards = purchasedGiftCards.map(purchasedCard => 13 | purchasedCard.invoiceId === card.invoiceId ? { ...purchasedCard, ...card } : { ...purchasedCard } 14 | ); 15 | await set('purchasedGiftCards', newCards); 16 | return newCards; 17 | }; 18 | 19 | export const deleteCard = async (card: GiftCard, purchasedGiftCards: GiftCard[]): Promise => { 20 | const newCards = purchasedGiftCards.filter(purchasedCard => purchasedCard.invoiceId !== card.invoiceId); 21 | await set('purchasedGiftCards', newCards); 22 | return newCards; 23 | }; 24 | 25 | export const handlePaymentEvent = async ( 26 | unredeemedGiftCard: GiftCard, 27 | invoice: Invoice, 28 | purchasedGiftCards: GiftCard[] 29 | ): Promise => { 30 | if (invoice.status === 'expired' || invoice.status === 'invalid') { 31 | return deleteCard(unredeemedGiftCard, purchasedGiftCards); 32 | } 33 | if (invoice.status === 'paid') { 34 | return updateCard({ ...unredeemedGiftCard, status: 'PENDING', invoice }, purchasedGiftCards); 35 | } 36 | if (['confirmed', 'complete'].includes(invoice.status)) { 37 | return updateCard(await redeemGiftCard(unredeemedGiftCard), purchasedGiftCards); 38 | } 39 | return purchasedGiftCards; 40 | }; 41 | 42 | const getBusUrl = async ({ invoiceId, user }: { invoiceId: string; user?: BitpayUser }): Promise => { 43 | const { 44 | data: { url, token } 45 | } = 46 | user && user.syncGiftCards 47 | ? { data: await apiCall(user.token, 'getInvoiceBusToken', { invoiceId }) } 48 | : await fetch(`${process.env.API_ORIGIN}/invoices/${invoiceId}/events`).then(res => res.json()); 49 | return `${url}?token=${token}&action=subscribe&events[]=payment&events[]=confirmation&events[]=paymentRejected`; 50 | }; 51 | 52 | export const createEventSourceObservable = async ({ 53 | invoiceId, 54 | user 55 | }: { 56 | invoiceId: string; 57 | user?: BitpayUser; 58 | }): Promise> => { 59 | const busUrl = await getBusUrl({ invoiceId, user }); 60 | return Observable.create((observer: Observer) => { 61 | const source = new EventSource(busUrl); 62 | source.addEventListener('statechange', (event: Event) => { 63 | const { data } = event as CustomEvent; 64 | const updatedInvoice = JSON.parse(data); 65 | observer.next(updatedInvoice); 66 | }); 67 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 68 | source.addEventListener('error', (event: any) => observer.error(event)); 69 | return (): void => { 70 | source.close(); 71 | }; 72 | }); 73 | }; 74 | 75 | const createEventSource = (url: string): Promise => 76 | new Promise((resolve, reject) => { 77 | const source = new EventSource(url); 78 | source.addEventListener('statechange', (event: Event) => { 79 | const { data } = event as CustomEvent; 80 | const updatedInvoice = JSON.parse(data); 81 | source.close(); 82 | resolve(updatedInvoice); 83 | }); 84 | source.addEventListener('paymentRejected', () => { 85 | reject(new Error('paymentRejected')); 86 | }); 87 | source.addEventListener('error', (e: Event) => { 88 | console.log('EventSource closed unexpectedly', e); 89 | reject(new Error()); 90 | }); 91 | }); 92 | 93 | export const waitForServerEvent = async ({ 94 | user, 95 | unredeemedGiftCard 96 | }: { 97 | user?: BitpayUser; 98 | unredeemedGiftCard: GiftCard; 99 | }): Promise => { 100 | const busUrl = await getBusUrl({ invoiceId: unredeemedGiftCard.invoiceId, user }); 101 | return createEventSource(busUrl); 102 | }; 103 | 104 | export const listenForInvoiceChanges = async ({ 105 | unredeemedGiftCard, 106 | user 107 | }: { 108 | unredeemedGiftCard: GiftCard; 109 | user?: BitpayUser; 110 | }): Promise => { 111 | const invoice = await getBitPayInvoice(unredeemedGiftCard.invoiceId); 112 | return invoice.status === 'new' ? waitForServerEvent({ unredeemedGiftCard, user }) : invoice; 113 | }; 114 | -------------------------------------------------------------------------------- /source/services/gift-card.types.ts: -------------------------------------------------------------------------------- 1 | export enum ClaimCodeType { 2 | barcode = 'barcode', 3 | code = 'code', 4 | link = 'link' 5 | } 6 | 7 | export interface CheckoutPageCssSelectors { 8 | orderTotal: string[]; 9 | claimCodeInput: string[]; 10 | pinInput: string[]; 11 | } 12 | 13 | export interface GiftCardDiscount { 14 | code: string; 15 | hidden?: boolean; 16 | type: 'flatrate' | 'percentage'; 17 | amount: number; 18 | } 19 | 20 | export interface GiftCardCoupon extends GiftCardDiscount { 21 | code: string; 22 | displayType: 'boost' | 'discount'; 23 | type: 'flatrate' | 'percentage'; 24 | amount: number; 25 | } 26 | 27 | export interface GiftCardActivationFee { 28 | amountRange: { 29 | min: number; 30 | max: number; 31 | }; 32 | fee: number; 33 | type: 'fixed' | 'percentage'; 34 | } 35 | 36 | export interface CommonCardConfig { 37 | activationFees?: GiftCardActivationFee[]; 38 | allowedPhoneCountries?: string[]; 39 | brandColor?: string; 40 | cardImage: string; 41 | cssSelectors?: CheckoutPageCssSelectors; 42 | currency: string; 43 | defaultClaimCodeType: ClaimCodeType; 44 | description: string; 45 | discounts?: GiftCardDiscount[]; 46 | coupons?: GiftCardCoupon[]; 47 | displayName: string; 48 | emailRequired: boolean; 49 | featured?: boolean; 50 | hidden?: boolean; 51 | hidePin?: boolean; 52 | icon: string; 53 | integersOnly?: boolean; 54 | logo: string; 55 | logoBackgroundColor: string; 56 | minAmount?: number; 57 | maxAmount?: number; 58 | mobilePaymentsSupported?: boolean; 59 | phoneRequired?: boolean; 60 | printRequired?: boolean; 61 | redeemButtonText?: string; 62 | redeemInstructions?: string; 63 | redeemUrl?: string; 64 | supportedUrls?: string[]; 65 | terms: string; 66 | website: string; 67 | tags?: string[]; 68 | } 69 | 70 | export interface CardConfig extends CommonCardConfig { 71 | name: string; 72 | supportedAmounts?: number[]; 73 | } 74 | 75 | export interface UnsoldGiftCard { 76 | amount: number; 77 | currency: string; 78 | name: string; 79 | coupons?: GiftCardCoupon[]; 80 | } 81 | 82 | export interface GiftCardBalanceEntry { 83 | date: string; 84 | amount: number; 85 | } 86 | 87 | export interface GiftCard extends UnsoldGiftCard { 88 | accessKey: string; 89 | archived: boolean; 90 | barcodeData?: string; 91 | barcodeFormat?: string; 92 | barcodeImage?: string; 93 | claimCode: string; 94 | claimLink?: string; 95 | date: string; 96 | discounts?: GiftCardDiscount[]; 97 | displayName: string; 98 | invoiceId: string; 99 | pin?: string; 100 | status: 'SUCCESS' | 'PENDING' | 'FAILURE' | 'UNREDEEMED' | 'SYNCED'; 101 | clientId: string; 102 | totalDiscount?: number; 103 | balanceHistory?: GiftCardBalanceEntry[]; 104 | invoice: Invoice; 105 | userEid?: string; 106 | } 107 | 108 | export type GiftCardSaveParams = Partial<{ 109 | error: string; 110 | status: string; 111 | remove: boolean; 112 | }>; 113 | 114 | export interface ApiCard extends CommonCardConfig { 115 | amount?: number; 116 | type: 'fixed' | 'range'; 117 | } 118 | 119 | export interface GiftCardInvoiceParams { 120 | brand: string; 121 | currency: string; 122 | amount: number; 123 | clientId: string; 124 | discounts?: string[]; 125 | coupons?: string[]; 126 | email?: string; 127 | phone?: string; 128 | } 129 | 130 | export interface GiftCardOrder { 131 | accessKey: string; 132 | invoiceId: string; 133 | totalDiscount: number; 134 | } 135 | 136 | export interface GiftCardRedeemParams { 137 | accessKey: string; 138 | clientId: string; 139 | invoiceId: string; 140 | } 141 | 142 | export interface GiftCardInvoiceMessage { 143 | data: { status: 'closed' | 'paid' | 'confirmed' | 'complete' }; 144 | } 145 | 146 | export type ApiCardConfig = ApiCard[]; 147 | 148 | export interface AvailableCardMap { 149 | [cardName: string]: ApiCardConfig; 150 | } 151 | 152 | export interface CardConfigMap { 153 | [cardName: string]: CardConfig; 154 | } 155 | 156 | export interface Invoice { 157 | url: string; 158 | paymentTotals: { [currency: string]: number }; 159 | paymentDisplayTotals: { [currency: string]: string }; 160 | amountPaid: number; 161 | displayAmountPaid: string; 162 | nonPayProPaymentReceived?: boolean; 163 | transactionCurrency: string; 164 | status: 'new' | 'paid' | 'confirmed' | 'complete' | 'expired' | 'invalid'; 165 | } 166 | 167 | export interface PhoneCountryInfo { 168 | phoneCountryCode: string; 169 | countryIsoCode: string; 170 | } 171 | -------------------------------------------------------------------------------- /source/services/phone.ts: -------------------------------------------------------------------------------- 1 | import { countries } from 'countries-list'; 2 | 3 | export interface PhoneCountryCode { 4 | emoji: string; 5 | phone: string; 6 | name: string; 7 | countryCode: string; 8 | } 9 | 10 | export function getPhoneCountryCodes(allowedPhoneCountries?: string[]): PhoneCountryCode[] { 11 | const countryCodes = Object.keys(countries); 12 | const countryList = Object.values(countries); 13 | const countryListWithCodes = countryList 14 | .map((country, index) => ({ 15 | ...country, 16 | countryCode: countryCodes[index] 17 | })) 18 | .filter(country => (allowedPhoneCountries ? allowedPhoneCountries.includes(country.countryCode) : true)); 19 | const countriesWithMultiplePhoneCodes = countryListWithCodes 20 | .filter(country => country.phone.includes(',')) 21 | .map(country => { 22 | const codes = country.phone.split(','); 23 | return codes.map(code => ({ ...country, phone: code })); 24 | }); 25 | const countriesWithSinglePhoneCode = countryListWithCodes.filter(country => !country.phone.includes(',')); 26 | const multiplePhoneCodesFlattened = countriesWithMultiplePhoneCodes.flat(); 27 | return countriesWithSinglePhoneCode 28 | .concat(multiplePhoneCodesFlattened) 29 | .sort((a, b) => (a.name < b.name ? -1 : 1)) 30 | .filter(country => country.name !== 'Antarctica'); 31 | } 32 | 33 | export function getPhoneMask(phoneCountryCode: string): string[] { 34 | const usMask = ['(', /[1-9]/, /\d/, /\d/, ')', ' ', /\d/, /\d/, /\d/, '-', /\d/, /\d/, /\d/, /\d/]; 35 | return phoneCountryCode === '1' ? usMask : Array(15).fill(/\d/); 36 | } 37 | 38 | export function getSavedPhoneCountryCode(phoneCountryCode: string, countryIsoCode: string): PhoneCountryCode { 39 | const countryCodes = getPhoneCountryCodes(); 40 | return countryCodes.find( 41 | countryCodeObj => countryCodeObj.phone === phoneCountryCode && countryIsoCode === countryCodeObj.countryCode 42 | ) as PhoneCountryCode; 43 | } 44 | -------------------------------------------------------------------------------- /source/services/storage.ts: -------------------------------------------------------------------------------- 1 | import { browser } from 'webextension-polyfill-ts'; 2 | 3 | function getKeyString(key: string): string { 4 | return process.env.NODE_ENV === 'production' || process.env.API_ORIGIN === 'https://bitpay.com' 5 | ? key 6 | : `${process.env.API_ORIGIN}_${key}`; 7 | } 8 | 9 | export async function get(key: string): Promise { 10 | const keys = await browser.storage.local.get(getKeyString(key)); 11 | return keys[getKeyString(key)]; 12 | } 13 | 14 | export function set(key: string, value: T): Promise { 15 | return browser.storage.local.set({ [getKeyString(key)]: value }); 16 | } 17 | 18 | export function remove(key: string): Promise { 19 | return browser.storage.local.remove(getKeyString(key)); 20 | } 21 | -------------------------------------------------------------------------------- /source/services/utils.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export function removeProtocolAndWww(url: string): string { 4 | return url.replace(/(^\w+:|^)\/\//, '').replace(/^www\./, ''); 5 | } 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | export async function post(url: string, params: any, opts?: { headers?: { [name: string]: string } }): Promise { 9 | const response = await fetch(url, { 10 | method: 'POST', 11 | headers: (opts && opts.headers) || { 12 | 'Content-Type': 'application/json' 13 | }, 14 | body: JSON.stringify(params) 15 | }); 16 | if (!response.ok) { 17 | const err = await response.json(); 18 | throw Error(err.message); 19 | } 20 | const data = await response.json(); 21 | return data; 22 | } 23 | 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | export function groupBy(list: any[], props: any): {} { 26 | return list.reduce((a, b) => { 27 | (a[b[props]] = a[b[props]] || []).push(b); 28 | return a; 29 | }, {}); 30 | } 31 | 32 | export const wait = (ms: number): Promise => new Promise(_ => setTimeout(_, ms)); 33 | 34 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 35 | export function useKeyPress(targetKey: string): any { 36 | // State for keeping track of whether key is pressed 37 | const [keyPressed, setKeyPressed] = useState(false); 38 | 39 | // If pressed key is our target key then set to true 40 | function downHandler({ key }: { key: string }): void { 41 | if (key === targetKey) { 42 | setKeyPressed(true); 43 | } 44 | } 45 | 46 | // If released key is our target key then set to false 47 | const upHandler = ({ key }: { key: string }): void => { 48 | if (key === targetKey) { 49 | setKeyPressed(false); 50 | } 51 | }; 52 | 53 | // Add event listeners 54 | useEffect(() => { 55 | window.addEventListener('keydown', downHandler); 56 | window.addEventListener('keyup', upHandler); 57 | // Remove event listeners on cleanup 58 | return (): void => { 59 | window.removeEventListener('keydown', downHandler); 60 | window.removeEventListener('keyup', upHandler); 61 | }; 62 | }, []); // Empty array ensures that effect is only run on mount and unmount 63 | 64 | return keyPressed; 65 | } 66 | 67 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 68 | export function safelyParseJSON(json: any): object | undefined { 69 | let parsed; 70 | try { 71 | parsed = JSON.parse(json); 72 | // eslint-disable-next-line no-empty 73 | } catch (e) {} 74 | return parsed; 75 | } 76 | -------------------------------------------------------------------------------- /source/setupTests.ts: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | Enzyme.configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /source/styles/_animations.scss: -------------------------------------------------------------------------------- 1 | .loading-spinner { 2 | width: 24px; 3 | height: 24px; 4 | animation: spin 400ms cubic-bezier(0.5, 0, 0.25, 1.2) infinite; 5 | user-select: none; 6 | &__wrapper { 7 | display: flex; 8 | justify-content: center; 9 | } 10 | } 11 | 12 | @keyframes spin { 13 | to { 14 | transform: rotateZ(360deg); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /source/styles/_fonts.scss: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css?family=Heebo:100,300,400,500,700,800,900"); 2 | 3 | * { 4 | font-family: "Heebo", sans-serif; 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | } 8 | -------------------------------------------------------------------------------- /source/styles/_reset.scss: -------------------------------------------------------------------------------- 1 | // Forked from Normalize.css, Reboot.css, Sanitize.css, and Untouched.css 2 | 3 | *, 4 | *:before, 5 | *:after { 6 | box-sizing: border-box; 7 | } 8 | 9 | *:focus { 10 | outline: 0; 11 | } 12 | 13 | ol, 14 | ul { 15 | list-style-type: none; 16 | } 17 | 18 | * { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | outline: 0; 23 | 24 | scrollbar-width: none; 25 | -ms-overflow-style: none; 26 | &::-webkit-scrollbar { 27 | display: none; 28 | } 29 | } 30 | 31 | body { 32 | overflow-x: hidden; 33 | } 34 | 35 | a:link { 36 | text-decoration: none; 37 | } 38 | -------------------------------------------------------------------------------- /source/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | // Primary 2 | $blue: #4f6ef7; 3 | $lightBlue: #eef1ff; 4 | $oceanBlue: #081125; 5 | $midnightBlue: #0c204e; 6 | $bitpayBlue: #1a3b8b; 7 | 8 | // Secondary 9 | $fossil: #2c3e4f; 10 | $slateDark: #434d5a; 11 | $slate: #9ba3ae; 12 | $air: #ebedf8; 13 | $fog: #f5f5f7; 14 | $feather: #f6f7fc; 15 | $black: #303133; 16 | 17 | // Interface 18 | $caution: #ef476f; 19 | $success: #2fcfa4; 20 | $warning: #fdb455; 21 | 22 | // Font Weights 23 | $thin: 200; 24 | $light: 300; 25 | $regular: 400; 26 | $medium: 500; 27 | $bold: 700; 28 | $exbold: 800; 29 | $exblack: 900; 30 | 31 | // Utils 32 | .d-none { 33 | display: none !important; 34 | } 35 | 36 | .d-flex { 37 | display: flex !important; 38 | } 39 | -------------------------------------------------------------------------------- /source/testData.ts: -------------------------------------------------------------------------------- 1 | import { CardConfig, ClaimCodeType, GiftCard } from './services/gift-card.types'; 2 | import { Merchant } from './services/merchant'; 3 | 4 | export const GiftCardsData: GiftCard[] = [ 5 | { 6 | accessKey: '5664461f4ec3a5', 7 | archived: false, 8 | claimCode: 'ABCD-EFGH-IJKL', 9 | clientId: 'abcdef123-1234-abc3-abca-795e030dce9a', 10 | currency: 'USD', 11 | date: '2020-04-23T22:57:05.298Z', 12 | invoiceId: 'BCZVk7Bk8Zwk6maEAzK123', 13 | name: 'Amazon.com', 14 | status: 'SUCCESS', 15 | amount: 1, 16 | displayName: 'Amazon', 17 | invoice: { 18 | url: 'https://bitpay.com/invoice?id=BCZVk7Bk8Zwk6maEAzK123', 19 | paymentTotals: { 20 | BCH: 414900, 21 | BTC: 13300, 22 | ETH: 5286000000000000, 23 | GUSD: 100, 24 | PAX: 1000000000000000000, 25 | USDC: 1000000, 26 | XRP: 5127416 27 | }, 28 | paymentDisplayTotals: { 29 | BCH: '0.004149', 30 | BTC: '0.000133', 31 | ETH: '0.005286', 32 | GUSD: '1.00', 33 | PAX: '1.00', 34 | USDC: '1.00', 35 | XRP: '5.127416' 36 | }, 37 | amountPaid: 13300, 38 | displayAmountPaid: '0.000133', 39 | transactionCurrency: 'BTC', 40 | status: 'confirmed' 41 | } 42 | } 43 | ]; 44 | 45 | export const CardConfigData: CardConfig = { 46 | name: 'Amazon.com', 47 | activationFees: [], 48 | brandColor: '#FF9902', 49 | cardImage: 'https://bitpay.com/gift-cards/assets/amazoncom/card2.png', 50 | currency: 'USD', 51 | defaultClaimCodeType: ClaimCodeType.code, 52 | displayName: 'Amazon', 53 | emailRequired: true, 54 | featured: true, 55 | icon: 'https://bitpay.com/gift-cards/assets/amazoncom/icon2.svg', 56 | logo: 'https://bitpay.com/gift-cards/assets/amazoncom/logo.svg', 57 | logoBackgroundColor: '#363636', 58 | maxAmount: 2000, 59 | minAmount: 1, 60 | redeemUrl: 'https://www.amazon.com/gc/redeem?claimCode=abc', 61 | terms: 'Amazon.com is not a sponsor of this promotion. Except as required by law, Amazon.com Gift Card', 62 | website: 'amazon.com', 63 | description: 'card description', 64 | cssSelectors: { 65 | claimCodeInput: ['.pmts-claim-code', '#spc-gcpromoinput'], 66 | orderTotal: ['.grand-total-price'], 67 | pinInput: [] 68 | } 69 | }; 70 | 71 | export const MerchantData: Merchant = { 72 | caption: 73 | 'Only redeemable on www.amazon.com (USA website)↵↵Amazon.com Gift Cards never expire and can be redeemed towards millions of items at www.amazon.com.', 74 | displayLink: 'amazon.com', 75 | displayName: 'Amazon', 76 | domains: ['amazon.com'], 77 | featured: true, 78 | giftCards: [], 79 | hasDirectIntegration: false, 80 | icon: 'https://bitpay.com/gift-cards/assets/amazoncom/icon2.svg', 81 | instructions: 82 | 'Only redeemable on www.amazon.com (USA website)↵↵Amazon.com Gift Cards never expire and can be redeemed towards millions of items at www.amazon.com.', 83 | link: 'amazon.com', 84 | name: 'Amazon.com', 85 | tags: ['online', 'games'], 86 | theme: '#FF9902' 87 | }; 88 | -------------------------------------------------------------------------------- /source/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'bitauth'; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@abhijithvijayan/tsconfig", 3 | "compilerOptions": { 4 | "target": "es5", // ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. 5 | "module": "esnext", // Module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. 6 | "lib": [ 7 | "dom", 8 | "dom.iterable", 9 | "esnext" 10 | ], 11 | "declaration": false, 12 | "isolatedModules": true, 13 | /* Additional Checks */ 14 | "useDefineForClassFields": true, 15 | "skipLibCheck": true, 16 | }, 17 | "include": [ 18 | "source", 19 | "webpack.config.js" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /views/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Options 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /views/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Popup 7 | 8 | 9 | 10 | 11 | 12 | --------------------------------------------------------------------------------