├── .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 | | [](https://chrome.google.com/webstore/detail/pay-with-bitpay/jkjgekcefbkpogohigkgooodolhdgcda) | [](https://addons.mozilla.org/en-US/firefox/addon/pay-with-bitpay/) | [](https://chrome.google.com/webstore/detail/pay-with-bitpay/jkjgekcefbkpogohigkgooodolhdgcda) | [](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 |
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 |
17 |
18 |
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 ;
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 |
104 | {card.invoice.displayAmountPaid} {card.invoice.transactionCurrency}
105 |
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 |
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 |
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 |
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 |
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 |
47 |
53 |
54 | ) : (
55 |
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 |
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 |
10 | ) : (
11 |
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 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
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 |
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 |
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 |
36 | selectPhoneCountryInfo({
37 | phoneCountryCode: phoneCountryCode.phone,
38 | countryIsoCode: phoneCountryCode.countryCode
39 | })
40 | }
41 | >
42 |
47 | {phoneCountryCode.name}
48 | +{phoneCountryCode.phone}
49 |
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 |
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 |
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 |
21 | Privacy Policy
22 |
23 |
24 | Terms of Service
25 |
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 |
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 |
90 | Version
91 | {packageJson.version}
92 |
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 |
--------------------------------------------------------------------------------