diff --git a/.eslintrc.json b/.eslintrc.json index bf32ff463..fc1b8749c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,16 +1,27 @@ { - "parser": "babel-eslint", + "parser": "@typescript-eslint/parser", "plugins": ["jasmine"], "env": { "browser": true, "jasmine": true }, - "extends": ["angular", "plugin:jasmine/recommended"], + "extends": [ + "angular", + "plugin:jasmine/recommended", + "plugin:import/typescript", + "plugin:@typescript-eslint/recommended" + ], "globals": { - "inject": true, - "require": true + "inject": true }, "rules": { + "import/no-duplicates": "error", + "import/extensions": "error", + "import/order": "error", + "import/newline-after-import": "error", + "import/no-named-default": "error", + "import/no-anonymous-default-export": "error", + "import/dynamic-import-chunkname": "error", "no-alert": "error", "no-array-constructor": "off", "no-bitwise": "off", @@ -213,6 +224,11 @@ "spaced-comment": "off", "strict": "off", "template-curly-spacing": "off", + "@typescript-eslint/camelcase": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_", "varIgnorePattern": "^_" } + ], "use-isnan": "error", "valid-jsdoc": "off", "valid-typeof": "error", diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..db7f5f0d3 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,96 @@ +name: Build + +on: + push: + branches: [staging, master] + pull_request: + branches: [staging, master] + workflow_dispatch: + # Allows manual build and deploy of any branch/ref + inputs: + auto-deploy: + type: boolean + description: Deploy image after building? + required: true + default: 'false' + +permissions: + id-token: write + contents: write + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: 16.10.0 + + - name: Install Dependencies + run: yarn install --immutable --immutable-cache + + - name: Lint Check + run: yarn lint + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + # Help Codecov detect commit SHA + fetch-depth: 2 + + - uses: actions/setup-node@v2 + with: + node-version: 16.10.0 + + - name: Install Dependencies + run: yarn install --immutable --immutable-cache + + - name: Run Tests + run: yarn test + + - name: Upload Codecov Reports + uses: codecov/codecov-action@v2 + + deploy: + runs-on: ubuntu-latest + environment: + name: ${{ (github.ref == 'refs/heads/master' && 'production') || 'staging' }} + needs: [lint, test] + if: (github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/staging')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.auto-deploy == 'true') + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-node@v2 + with: + node-version: 16.10.0 + + - name: Install Dependencies + run: yarn install --immutable --immutable-cache + + - name: Build App + env: + S3_GIVE_DOMAIN: //${{ secrets.GIVE_WEB_HOSTNAME }} + ROLLBAR_ACCESS_TOKEN: ${{ secrets.ROLLBAR_ACCESS_TOKEN }} + DATADOG_RUM_CLIENT_TOKEN: ${{ secrets.DATADOG_RUM_CLIENT_TOKEN }} + run: yarn run build + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@036a4a1ddf2c0e7a782dca6e083c6c53e5d90321 + with: + aws-region: us-east-1 + role-to-assume: ${{ secrets.AWS_GITHUB_ACTIONS_ROLE }} + + # Deploying to S3 with the --delete flag can be problematic if a cached file references a deleted file + # Solution: deploy once without deleting old files, invalidate the Cloudfront cache, then deploy a second time with the flag to delete previous files + - name: Deploy to S3 + run: aws s3 sync dist s3://${{ secrets.AWS_WEB_STATIC_BUCKET_NAME }} --region us-east-1 --acl public-read --cache-control 'public, max-age=300' + + - name: AWS Cloudfront + run: aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*" + + - name: Deploy to S3 and Delete Previous Files + run: aws s3 sync dist s3://${{ secrets.AWS_WEB_STATIC_BUCKET_NAME }} --region us-east-1 --acl public-read --cache-control 'public, max-age=300' --delete diff --git a/.github/workflows/update-staging.yml b/.github/workflows/update-staging.yml new file mode 100644 index 000000000..0235ee832 --- /dev/null +++ b/.github/workflows/update-staging.yml @@ -0,0 +1,20 @@ +name: Update staging +on: + push: + branches: + - master + pull_request: + types: [labeled, synchronize] + +jobs: + update-staging: + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' || contains(github.event.pull_request.labels.*.name, 'On Staging') + steps: + - uses: actions/checkout@v3 + - name: 🖇️ Merge current branch into staging + uses: devmasx/merge-branch@1.4.0 + with: + type: now + target_branch: 'staging' + github_token: ${{ github.token }} diff --git a/.gitignore b/.gitignore index f3ab53ebc..adaf85031 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,12 @@ dist .DS_STORE coverage *.iml +.vscode *.log *.back.* .sass-cache stats.json -*.iml + +# asdf +.tool-versions diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 000000000..509914599 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +nodejs 16.10.0 +yarn 1.22.17 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9dbc4bb33..000000000 --- a/.travis.yml +++ /dev/null @@ -1,41 +0,0 @@ -language: node_js -node_js: "8" -script: - - yarn lint - - yarn test - - if [ "$TRAVIS_PULL_REQUEST" = "false" ] && ([ "$TRAVIS_BRANCH" == "master" ] || [ "$TRAVIS_BRANCH" == "staging" ]); then yarn run build; fi -after_success: - - bash <(curl -s https://codecov.io/bash) -cache: - yarn: true - directories: - - node_modules - -before_deploy: - - pip install --user awscli - -deploy: - - provider: s3 - access_key_id: $AWS_ACCESS_KEY_ID - secret_access_key: $AWS_SECRET_ACCESS_KEY - bucket: cru-givestage - acl: public_read - cache_control: "max-age=300" - local-dir: dist - skip_cleanup: true - on: - branch: staging - - - provider: s3 - access_key_id: $AWS_ACCESS_KEY_ID - secret_access_key: $AWS_SECRET_ACCESS_KEY - bucket: cru-giveprod - acl: public_read - cache_control: "max-age=3600" - local-dir: dist - skip_cleanup: true - on: - branch: master - -after_deploy: - - if [ "$TRAVIS_BRANCH" == "master" ]; then aws cloudfront create-invalidation --distribution-id E51L08TW3241I --paths "/*"; fi diff --git a/README.md b/README.md index ef44404fe..6902ba1ca 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # give-web ## Angular front-end components for use in AEM on [give.cru.org](https://give.cru.org) -[![Build Status](https://travis-ci.org/CruGlobal/give-web.svg?branch=master)](https://travis-ci.org/CruGlobal/give-web) +[![Build Status](https://github.com/CruGlobal/give-web/actions/workflows/build.yml/badge.svg)](https://github.com/CruGlobal/give-web/actions) [![codecov](https://codecov.io/gh/CruGlobal/give-web/branch/master/graph/badge.svg)](https://codecov.io/gh/CruGlobal/give-web) ## Usage @@ -57,7 +57,9 @@ Add the following code to your page where appropriate. See the [Branded checkout donor-details="" show-cover-fees="true" on-order-completed="$event.$window.onOrderCompleted($event.purchase)" - on-order-failed="$event.$window.onOrderFailed($event.donorDetails)"> + on-order-failed="$event.$window.onOrderFailed($event.donorDetails)" + radio-station-api-url="https://api.domain.com/getStations" + radio-station-radius="100"> @@ -131,28 +133,32 @@ The `` element is where the branded checkout Angular app will - `on-order-completed` - an Angular expression that is executed when the order was submitted successfully - *Optional* - provides 2 variables: - `$event.$window` - Provides access to the browser's global `window` object. This allows you to call a custom callback function like `onOrderCompleted` in the example. - `$event.purchase` - contains the order's details that are loaded for the thank you page +- `radio-station-api-url` - Provides a URL path for fetching a list of radio stations in the donor's vicinity. If you plan to use this feature, contact Cru's Digital Products and Services (DPS) department ([help@cru.org](mailto:help@cru.org)) to have your URL domain whitelisted to interact with our API - *Optional* +- `radio-station-radius` - Provides a radius (in miles) for fetching a list of radio stations in the donor's vicinity - *Optional* + #### Server-side configuration for a new branded checkout domain 1. Figure out what domain you will be hosting the branded checkout form on. For example, `myministry.org` 2. Make sure HTTPS is enabled on that domain -3. You will need to setup a subdomain for the give.cru.org API. We've experienced cross-domain cookie issues trying to hit the give.cru.org API directly from a custom domain. Create a CNAME record for `brandedcheckout.myministry.org` (the subdomain could be different but using that suggested subdomain makes it consistent with other sites) and point it at `give.cru.org`. -4. In order to accept credit cards on your own domain, you will need to setup a TSYS merchant account. Contact the Cru's Financial Services Group ([chad.vaughan@cru.org](mailto:chad.vaughan@cru.org)) if you don't already have one. You will need a TSYS device id (a numeric id around 14 digits) to complete step 6. +3. You will need to setup a subdomain for the give.cru.org API. We've experienced cross-domain cookie issues trying to hit the give.cru.org API directly from a custom domain. Create a CNAME record for `brandedcheckout.myministry.org` (the subdomain could be different but using that suggested subdomain makes it consistent with other sites) and point it at `cortex-gateway-production-alb-425941461.us-east-1.elb.amazonaws.com`. +4. In order to accept credit cards on your own domain, you will need a new TSYS device id (a numeric id around 14 digits) associated with one of our Merchant Accounts. Contact the Cru's Financial Services Group ([hazel.mcpherson@cru.org](mailto:hazel.mcpherson@cru.org)) and request one. You will use the device id to complete step 6. 5. To prepare for the next step, think of a unique identifier like `"jesusfilm"` or `"aia"` that uniquely identifies your ministry and domain. We can create this for you but we need enough information about your ministry to do so. 6. Once you have completed the steps above, contact Cru's Digital Products and Services (DPS) department ([help@cru.org](mailto:help@cru.org)). Below is an example email: (replace the `{{}}`s with the info for your site) - > I'm working on implementing branded checkout for {{my ministry}}. I would like to host the branded checkout form on {{myminsitry.org}}. HTTPS is setup on my domain and I have created a CNAME record for the subdomain {{brandedcheckout.myministry.org}} that points to give.cru.org. (DPS may be able to help with the CNAME record configuration if the domain is hosted with us.) + > I'm working on implementing branded checkout for {{my ministry}}. I would like to host the branded checkout form on {{myminsitry.org}}. HTTPS is setup on my domain and I have created a CNAME record for the subdomain {{brandedcheckout.myministry.org}} that points to cortex-gateway-production-alb-425941461.us-east-1.elb.amazonaws.com. (DPS may be able to help with the CNAME record configuration if the domain is hosted with us.) > > I need help configuring the give.cru.org API to work on my domain. Can you: > - Add an SSL certificate to cruorg-alb for my subdomain {{brandedcheckout.myministry.org}} > - Add that same subdomain to cortex_gateway's AUTH_PROXY_VALID_ORIGINS environment variable - > - Add the user facing domain to the maintenence app's cortex_gateway CORS Whitelist + > - Add the user facing domain to the maintenance app's cortex_gateway CORS Whitelist > > I also need help setting up a my TSYS merchant account with the give.cru.org API to be able to proccess credit cards on my site. Can you: > - Add my TSYS device id to the give.cru.org API configuration. My device id is {{12345678901234}} and the url I would like to use for the branded checkout form is {{https://myministry.org}}. I would like to use a identifier of "{{myministry}}". (Or uniquely describe your ministry and domain if you want DPS to create the identifier. We can't have multiple sites that use the same identifier.) > - Whitelist my site {{https://myministry.org}} with TSYS so their TSEP credit card tokenization services will work on my domain. + > - Whitelist my domain {{myminisry.org}} with Recaptcha. 7. Test the subdomain configured to point to the give.cru.org API. https://brandedcheckout.myministry.org/cortex/nextdrawdate is a good test url. There should be no certificate errors and you should get a response that looks like this `{"next-draw-date":"2018-09-27"}`. If there are errors, please get in touch with ([help@cru.org](mailto:help@cru.org)) again and provide details as to what is happening. -8. Add the `` tag to a page on the domain you've configured above. You can follow the documentation above for all the possible attributes and the required style and script tags. The email conversations above should have provided the values for the `api-url` (the subdomain that has a CNAME to give.cru.org) and `tsys-device` (the unique string identifier created by you or by DPS) attributes. You can add them like this: +8. Add the `` tag to a page on the domain you've configured above. You can follow the documentation above for all the possible attributes and the required style and script tags. The email conversations above should have provided the values for the `api-url` (the subdomain that has a CNAME to cortex-gateway-production-alb-425941461.us-east-1.elb.amazonaws.com) and `tsys-device` (the unique string identifier created by you or by DPS) attributes. You can add them like this: ```html ` element is where the branded checkout Angular app will ``` -9. If you go to this page in a browser, you should see the `` tag fill with content. There should also be no errors in the browser's console. If you see errors that appear to be caused by branded checkout please contact us. +9. If you go to this page in a browser, you should see the `` tag fill with content. There should also be no errors in the browser's console. If you see errors that appear to be caused by branded checkout please contact us at [help@cru.org](mailto:help@cru.org). ## Development @@ -191,7 +197,7 @@ Note: For session cookies to work correctly, add an entry to your hosts file for Use the `cortexApiService` which provides convenience methods for sending requests to Cortex. For more documentation see the [cortexApiService docs](docs/cortexApiService.md). ### Staging Environment -Replace `https://give-static.cru.org` with `https://cru-givestage.s3.amazonaws.com` to use the staging environment. +Replace `https://give-static.cru.org` with `https://give-stage-static.cru.org` to use the staging environment. ### Deployments diff --git a/babel.config.js b/babel.config.js index a65dfe738..d263ed5c4 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,11 +1,15 @@ // File required for jest, webpack includes different babel config module.exports = { presets: [ - ['@babel/preset-env', { - targets: { - browsers: ['defaults', 'ie >= 11'] + [ + '@babel/preset-env', { + targets: { + browsers: ['defaults', 'ie >= 11'] + } } - }] + ], + '@babel/preset-react', + '@babel/preset-typescript' ], plugins: [ '@babel/plugin-transform-runtime' diff --git a/docs/cortexApiService.md b/docs/cortexApiService.md index dbaf113be..7628f1ad4 100644 --- a/docs/cortexApiService.md +++ b/docs/cortexApiService.md @@ -175,7 +175,7 @@ cortexApiService.get({ path: '/purchases/crugive/giydanby=', zoom: { donorDetails: 'donordetails', - paymentMeans: 'paymentmeans:element', + paymentInstruments: 'paymentinstruments:element', lineItems: 'lineitems:element,lineitems:element:code,lineitems:element:rate', } }); @@ -186,11 +186,13 @@ This will return: (the `rawData` key shows what the original response looked lik "donorDetails": { "donor-type": "Household", "mailing-address": { - "country-name": "US", - "locality": "sdag", - "postal-code": "12423", - "region": "AR", - "street-address": "dsfg" + address: { + "country-name": "US", + "locality": "sdag", + "postal-code": "12423", + "region": "AR", + "street-address": "dsfg" + } }, "name": { "family-name": "Lname", @@ -210,7 +212,7 @@ This will return: (the `rawData` key shows what the original response looked lik "title": "Mrs." } }, - "paymentMeans": { + "paymentInstruments": { "billing-address": { "address": { "country-name": "US", @@ -280,11 +282,13 @@ This will return: (the `rawData` key shows what the original response looked lik { "donor-type": "Household", "mailing-address": { - "country-name": "US", - "locality": "sdag", - "postal-code": "12423", - "region": "AR", - "street-address": "dsfg" + address: { + "country-name": "US", + "locality": "sdag", + "postal-code": "12423", + "region": "AR", + "street-address": "dsfg" + } }, "name": { "family-name": "Lname", @@ -356,7 +360,7 @@ This will return: (the `rawData` key shows what the original response looked lik ] } ], - "_paymentmeans": [ + "_paymentinstruments": [ { "_element": [ { diff --git a/jest.config.js b/jest.config.js index fd40c2da3..c25dc6256 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,10 +2,12 @@ module.exports = { collectCoverage: true, collectCoverageFrom: [ 'src/**/*.js', + 'src/**/*.{ts,tsx}', '!**/*.fixture.js' ], restoreMocks: true, setupFilesAfterEnv: [ + '/jest/setupAngularMocks.js', 'angular', 'angular-mocks', 'jest-date-mock', @@ -17,8 +19,9 @@ module.exports = { modulePaths: [ '/src' ], + testEnvironment: 'jsdom', transform: { - '^.+\\.js?$': 'babel-jest', + '^.+\\.(js|tsx)?$': 'babel-jest', '^.+\\.html$': '/jest/htmlTransform.js' } } diff --git a/jest/htmlTransform.js b/jest/htmlTransform.js index 498646b7a..1d9b41125 100644 --- a/jest/htmlTransform.js +++ b/jest/htmlTransform.js @@ -7,6 +7,6 @@ module.exports = { resource: path.basename(filename) } const result = ngTemplateLoader.call(webpackContext, `\`${src}\``) - return `${result}; module.exports = "${path.basename(filename)}"` + return { code: `${result}; module.exports = "${path.basename(filename)}"` } } } diff --git a/jest/setupAngularMocks.js b/jest/setupAngularMocks.js new file mode 100644 index 000000000..98e962a89 --- /dev/null +++ b/jest/setupAngularMocks.js @@ -0,0 +1,2 @@ +// angular-mocks won't setup mocking unless Jasmine or Karma is defined, so pretend like it is +window.jasmine = true diff --git a/package.json b/package.json index 9909f4434..6015e1271 100644 --- a/package.json +++ b/package.json @@ -3,53 +3,72 @@ "version": "1.0.0", "dependencies": { "@babel/polyfill": "^7.7.0", - "@babel/runtime-corejs2": "^7.0.0", - "angular": "1.7.9", - "angular-cookies": "^1.7.8", + "@babel/runtime-corejs2": "^7.25.9", + "@cruglobal/cru-payments": "^1.2.4", + "@datadog/browser-rum": "^5.16.0", + "angular": "^1.8.3", + "angular-cookies": "^1.8.2", "angular-environment": "https://github.com/jonshaffer/angular-environment.git#d3082c06fb16804d324faac9b7e753fd64a44e5d", "angular-filter": "^0.5.17", - "angular-gettext": "^2.4.1", - "angular-messages": "^1.7.8", + "angular-gettext": "^2.4.2", + "angular-messages": "^1.8.2", "angular-ordinal": "^2.1.3", - "angular-sanitize": "^1.7.8", + "angular-sanitize": "^1.8.2", "angular-scroll": "^1.0.2", - "angular-translate": "^2.18.1", - "angular-ui-bootstrap": "2.5.6", - "angular-ui-router": "^1.0.22", - "angular-upload": "^1.0.13", - "bootstrap-sass": "3.4.1", + "angular-translate": "^2.19.0", + "angular-ui-bootstrap": "^2.5.6", + "angular-ui-router": "^1.0.30", + "bootstrap-sass": "^3.4.1", "change-case-object": "^2.0.0", - "cru-payments": "^1.2.2", - "crypto-js": "^3.1.9-1", + "crypto-js": "^4.2.0", "jwt-decode": "^2.2.0", - "lodash": "^4.17.11", + "lodash": "^4.17.21", "moment": "^2.24.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react2angular": "^4.0.6", "rollbar": "^2.7.1", "rxjs": "^5.2.0", "slick-carousel": "1.8.1", - "textangularjs": "^2.1.2" + "textangularjs": "^2.1.2", + "typescript": "^4.5.5" }, "devDependencies": { - "@babel/cli": "^7.5.5", - "@babel/core": "^7.5.5", - "@babel/plugin-transform-runtime": "^7.5.5", - "@babel/preset-env": "^7.11.5", - "@babel/runtime": "^7.5.5", - "angular-mocks": "^1.7.8", + "@babel/cli": "^7.25.9", + "@babel/core": "^7.25.9", + "@babel/node": "^7.25.9", + "@babel/plugin-transform-runtime": "^7.25.9", + "@babel/preset-env": "^7.25.9", + "@babel/preset-react": "^7.25.9", + "@babel/preset-typescript": "^7.25.9", + "@babel/runtime": "^7.25.9", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^12.1.5", + "@testing-library/react-hooks": "^8.0.1", + "@testing-library/user-event": "^14.4.3", + "@types/angular": "^1.8.4", + "@types/node": "^17.0.12", + "@types/react": "^17.0.38", + "@types/react-dom": "^17.0.11", + "@typescript-eslint/eslint-plugin": "^5.10.1", + "@typescript-eslint/parser": "^5.10.1", + "angular-mocks": "^1.8.2", "babel-loader": "^8.0.6", "babel-plugin-angularjs-annotate": "^0.10.0", "copy-webpack-plugin": "^5.0.4", "css-loader": "^3.2.0", "html-loader": "^0.5.5", - "jest": "^24.9.0", + "jest": "^29.7.0", "jest-date-mock": "^1.0.7", + "jest-environment-jsdom": "^29.4.2", "mini-css-extract-plugin": "^0.8.0", "moment-locales-webpack-plugin": "^1.1.0", "ngtemplate-loader": "^2.0.1", - "node-sass": "4.14.1", - "sass-loader": "^7.3.1", - "standard": "^13.1.0", + "sass": "^1.49.0", + "sass-loader": "^10", + "standard": "^16.0.4", "style-loader": "^1.0.0", + "typescript-eslint": "^0.0.1-alpha.0", "webpack": "^4.39.2", "webpack-bundle-analyzer": "^3.4.1", "webpack-cli": "^3.3.7", @@ -61,7 +80,10 @@ "build": "webpack -p", "build:analyze": "webpack -p --env.analyze", "test": "jest", - "lint": "standard" + "test:log": "jest --silent=false", + "lint": "standard", + "lint:write": "standard --fix", + "lint:ts": "tsc" }, "standard": { "env": { diff --git a/src/app/analytics/analytics.factory.js b/src/app/analytics/analytics.factory.js index 24f464426..32e9c405e 100644 --- a/src/app/analytics/analytics.factory.js +++ b/src/app/analytics/analytics.factory.js @@ -5,241 +5,312 @@ import find from 'lodash/find' import sha3 from 'crypto-js/sha3' import merge from 'lodash/merge' import isEmpty from 'lodash/isEmpty' +import moment from 'moment' /* global localStorage */ -const analyticsFactory = /* @ngInject */ function ($window, $timeout, sessionService) { +function suppressErrors (func) { + return function wrapper (...args) { + try { + return func.apply(this, args) + } catch (e) { + console.error(e) + } + } +} + +function testingTransactionName (item) { + const designationNumber = item.designationNumber + const frequencyObj = find(item.frequencies, { name: item.frequency }) + const frequency = frequencyObj?.display || item.frequency + if (designationNumber && frequency) { + return `isItemTestingTransaction_${designationNumber}_${frequency.toLowerCase()}` + } else { + return undefined + } +} + +// Generate a datalayer product object +const generateProduct = suppressErrors(function (item, additionalData = {}) { + const sessionStorageTestName = testingTransactionName(item) + const testingTransaction = Boolean(sessionStorageTestName && + window.sessionStorage.getItem(sessionStorageTestName) === 'true').toString() + const price = additionalData?.price || item.amount + const category = additionalData?.category || item.designationType + const name = additionalData?.name || item.displayName || undefined + const recurringDate = additionalData.recurringDate + ? additionalData.recurringDate.format('MMMM D, YYYY') + : item.giftStartDate + ? moment(item.giftStartDate).format('MMMM D, YYYY') + : undefined + const frequencyObj = find(item.frequencies, { name: item.frequency }) + const variant = additionalData?.variant || frequencyObj?.display || item.frequency + return { - buildProductVar: function (cartData) { - try { - var item, donationType + item_id: item.designationNumber, + item_name: name, + item_brand: item.orgId, + item_category: category ? category.toLowerCase() : undefined, + item_variant: variant ? variant.toLowerCase() : undefined, + currency: 'USD', + price: price ? price.toString() : undefined, + quantity: '1', + recurring_date: recurringDate, + testing_transaction: testingTransaction + } +}) - // Instantiate cart data layer - const hash = sha3(cartData.id, { outputLength: 80 }) // limit hash to 20 characters - $window.digitalData.cart = { - id: cartData.id, - hash: cartData.id ? hash.toString() : null, - item: [] - } +const analyticsFactory = /* @ngInject */ function ($window, $timeout, envService, sessionService) { + return { + checkoutFieldError: suppressErrors((field, error) => { + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ + event: 'checkout_error', + error_type: field, + error_details: error + }) + }), - // Build cart data layer - $window.digitalData.cart.price = { - cartTotal: cartData && cartData.cartTotal - } + // Send checkoutFieldError events for any invalid fields in a form + handleCheckoutFormErrors: function (form) { + if (!envService.read('isCheckout') && !envService.read('isBrandedCheckout')) { + // Ignore errors not during checkout, like a logged-in user updating their payment methods + return + } - if (cartData && cartData.items) { - for (var i = 0; i < cartData.items.length; i++) { - // Set donation type - if (cartData.items[i].frequency.toLowerCase() === 'single') { - donationType = 'one-time donation' - } else { - donationType = 'recurring donation' - } + Object.entries(form).forEach(([fieldName, field]) => { + if (!fieldName.startsWith('$') && field.$invalid) { + // The keys of $error are the validators that failed for this field + Object.keys(field.$error).forEach((validator) => { + this.checkoutFieldError(fieldName, validator) + }) + } + }) + }, - item = { - productInfo: { - productID: cartData.items[i].designationNumber, - designationType: cartData.items[i].designationType, - orgId: cartData.items[i].orgId ? cartData.items[i].orgId : 'cru' - }, - price: { - basePrice: cartData.items[i].amount - }, - attributes: { - donationType: donationType, - donationFrequency: cartData.items[i].frequency.toLowerCase(), - siebel: { - productType: 'designation', - campaignCode: cartData.items[i].config['campaign-code'] - } - } - } + buildProductVar: suppressErrors(function (cartData) { + if (!cartData) return + let donationType - $window.digitalData.cart.item.push(item) + const { id } = cartData + // Instantiate cart data layer + const hash = id ? sha3(id, { outputLength: 80 }).toString() : null // limit hash to 20 characters + if ($window?.digitalData) { + $window.digitalData.cart = { + id: id, + hash: hash, + item: [] + } + } else { + $window.digitalData = { + cart: { + id: id, + hash: hash, + item: [] } } - } catch (e) { - // Error caught in analyticsFactory.buildProductVar } - }, - cartAdd: function (itemConfig, productData) { - try { - var siteSubSection - var cart = { - item: [{ + + // Build cart data layer + $window.digitalData.cart.price = { + cartTotal: cartData?.cartTotal + } + + if (cartData.items?.length) { + cartData.items.forEach((item) => { + const frequency = item?.frequency ? item.frequency.toLowerCase() : undefined + // Set donation type + if (frequency === 'single') { + donationType = 'one-time donation' + } else { + donationType = 'recurring donation' + } + + item = { productInfo: { - productID: productData.designationNumber, - designationType: productData.designationType + productID: item.designationNumber, + designationType: item.designationType, + orgId: item.orgId ? item.orgId : 'cru' }, price: { - basePrice: itemConfig.amount + basePrice: item.amount }, attributes: { + donationType: donationType, + donationFrequency: frequency, siebel: { - productType: 'designation' + productType: 'designation', + campaignCode: item.config.CAMPAIGN_CODE } } - }] - } + } - // Set site sub-section - if (typeof $window.digitalData.page !== 'undefined') { - if (typeof $window.digitalData.page.category !== 'undefined') { - $window.digitalData.page.category.subCategory1 = siteSubSection - } else { - $window.digitalData.page.category = { - subCategory1: siteSubSection + $window.digitalData.cart.item.push(item) + }) + } + }), + saveTestingTransaction: suppressErrors(function (item, testingTransaction) { + if (testingTransaction) { + $window.sessionStorage.setItem(testingTransactionName(item), testingTransaction) + } + }), + cartAdd: suppressErrors(function (itemConfig, productData) { + let siteSubSection + const cart = { + item: [{ + productInfo: { + productID: productData.designationNumber, + designationType: productData.designationType + }, + price: { + basePrice: itemConfig.AMOUNT + }, + attributes: { + siebel: { + productType: 'designation' } } + }] + } + + // Set site sub-section + if ($window?.digitalData?.page) { + if ($window.digitalData.page?.category) { + $window.digitalData.page.category.subCategory1 = siteSubSection } else { + $window.digitalData.page.category = { + subCategory1: siteSubSection + } + } + } else { + if ($window?.digitalData) { $window.digitalData.page = { category: { subcategory1: siteSubSection } } - } - - // Set donation type - if (productData.frequency === 'NA') { - cart.item[0].attributes.donationType = 'one-time donation' } else { - cart.item[0].attributes.donationType = 'recurring donation' - } - - // Set donation frequency - const frequencyObj = find(productData.frequencies, { name: productData.frequency }) - cart.item[0].attributes.donationFrequency = frequencyObj && frequencyObj.display.toLowerCase() - - // Set data layer - $window.digitalData.cart = cart - // Send GTM Advance Ecommerce event - if (typeof $window.dataLayer !== 'undefined') { - $window.dataLayer.push({ - event: 'add-to-cart', - ecommerce: { - currencyCode: 'USD', - add: { - products: [{ - name: productData.designationNumber, - id: productData.designationNumber, - price: itemConfig.amount.toString(), - brand: productData.orgId, - category: productData.designationType.toLowerCase(), - variant: frequencyObj.display.toLowerCase(), - quantity: '1' - }] + $window.digitalData = { + page: { + category: { + subcategory1: siteSubSection } } - }) + } } - } catch (e) { - // Error caught in analyticsFactory.cartAdd } - }, - cartRemove: function (item) { - try { - if (item) { - $window.digitalData.cart.item = [{ - productInfo: { - productID: item.designationNumber, - designationType: item.designationType - }, - price: { - basePrice: item.amount - }, - attributes: { - donationType: item.frequency.toLowerCase() === 'single' ? 'one-time donation' : 'recurring donation', - donationFrequency: item.frequency.toLowerCase(), - siebel: { - productType: 'designation', - campaignCode: item.config['campaign-code'] - } - } - }] - // Send GTM Advance Ecommerce event - if (typeof $window.dataLayer !== 'undefined') { - $window.dataLayer.push({ - event: 'remove-from-cart', - ecommerce: { - currencyCode: 'USD', - remove: { - products: [{ - name: item.designationNumber, - id: item.designationNumber, - price: item.amount.toString(), - brand: item.orgId, - category: item.designationType.toLowerCase(), - variant: item.frequency.toLowerCase(), - quantity: '1' - }] - } - } - }) + + let recurringDate = null + // Set donation type + if (productData.frequency === 'NA') { + cart.item[0].attributes.donationType = 'one-time donation' + } else { + cart.item[0].attributes.donationType = 'recurring donation' + recurringDate = moment(`${moment().year()}-${itemConfig.RECURRING_START_MONTH}-${itemConfig.RECURRING_DAY_OF_MONTH}`, 'YYYY-MM-DD') + } + + // Set donation frequency + const frequencyObj = find(productData.frequencies, { name: productData.frequency }) + cart.item[0].attributes.donationFrequency = frequencyObj && frequencyObj.display.toLowerCase() + + // Set data layer + $window.digitalData.cart = cart + // Send GTM Advance Ecommerce event + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ + event: 'add_to_cart', + ecommerce: { + currencyCode: 'USD', + value: itemConfig.AMOUNT.toFixed(2), + items: [generateProduct(productData, { + price: itemConfig.AMOUNT, + recurringDate + })] + } + }) + }), + cartRemove: suppressErrors(function (item) { + if (!item) return + const frequency = item.frequency ? item.frequency.toLowerCase() : undefined + $window.digitalData.cart.item = [{ + productInfo: { + productID: item.designationNumber, + designationType: item.designationType + }, + price: { + basePrice: item.amount + }, + attributes: { + donationType: frequency === 'single' ? 'one-time donation' : 'recurring donation', + donationFrequency: frequency, + siebel: { + productType: 'designation', + campaignCode: item.config.CAMPAIGN_CODE } } - } catch (e) { - // Error caught in analyticsFactory.cartRemove - } - }, - cartView: function (isMiniCart = false) { - try { - // Send GTM Advance Ecommerce event - if (typeof $window.dataLayer !== 'undefined') { - $window.dataLayer.push({ - event: isMiniCart ? 'view-mini-cart' : 'view-cart' - }) + }] + // Send GTM Advance Ecommerce event + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ + event: 'remove_from_cart', + ecommerce: { + currencyCode: 'USD', + value: item.amount.toFixed(2), + items: [generateProduct(item)] } - } catch (e) { - // Error caught in analyticsFactory.cartView - } - }, - checkoutStepEvent: function (step, cart) { + }) + }), + cartView: suppressErrors(function (isMiniCart = false) { + // Send GTM Advance Ecommerce event + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ + event: isMiniCart ? 'view-mini-cart' : 'view-cart' + }) + }), + checkoutStepEvent: suppressErrors(function (step, cart) { + $window.dataLayer = $window.dataLayer || [] + const cartObject = cart.items.map((cartItem) => generateProduct(cartItem)) let stepNumber switch (step) { case 'contact': stepNumber = 1 + $window.dataLayer.push({ + event: 'begin_checkout', + ecommerce: { + items: cartObject + } + }) break case 'payment': stepNumber = 2 + $window.dataLayer.push({ + event: 'add_payment_info' + }) break case 'review': stepNumber = 3 + $window.dataLayer.push({ + event: 'review_order' + }) break } - const cartObject = cart.items.map((cartItem) => { - return { - name: cartItem.designationNumber, - id: cartItem.designationNumber, - price: cartItem.amount.toString(), - brand: cartItem.orgId, - category: cartItem.designationType.toLowerCase(), - variant: cartItem.frequency.toLowerCase(), - quantity: '1' + $window.dataLayer.push({ + event: 'checkout-step', + cartId: cart.id, + ecommerce: { + currencyCode: 'USD', + checkout: { + actionField: { + step: stepNumber, + option: '' + }, + products: [ + ...cartObject + ] + } } }) - try { - if (typeof $window.dataLayer !== 'undefined') { - $window.dataLayer.push({ - event: 'checkout-step', - cartId: cart.id, - ecommerce: { - currencyCode: 'USD', - checkout: { - actionField: { - step: stepNumber, - option: '' - }, - products: [ - ...cartObject - ] - } - } - }) - } - } catch (e) { - // Error caught in analyticsFactory.checkoutStepEvent - } - }, - checkoutStepOptionEvent: function (option, step) { + }), + checkoutStepOptionEvent: suppressErrors(function (option, step) { let stepNumber switch (step) { case 'contact': @@ -252,527 +323,481 @@ const analyticsFactory = /* @ngInject */ function ($window, $timeout, sessionSer stepNumber = 3 break } - try { - $window.dataLayer.push({ - event: 'checkout-option', - ecommerce: { - checkout_option: { - actionField: { - step: stepNumber, - option: option.toLowerCase() - } + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ + event: 'checkout-option', + ecommerce: { + checkout_option: { + actionField: { + step: stepNumber, + option: option ? option.toLowerCase() : undefined } } - }) - } catch (e) { - // Error caught in analyticsFactory.checkoutStepOptionEvent - } - }, - editRecurringDonation: function (giftData) { - try { - var frequency = '' + } + }) + }), + editRecurringDonation: suppressErrors(function (giftData) { + let frequency = '' - if (giftData && giftData.length) { - if (get(giftData, '[0].gift["updated-rate"].recurrence.interval')) { - frequency = giftData[0].gift['updated-rate'].recurrence.interval.toLowerCase() - } else { - const interval = get(giftData, '[0].parentDonation.rate.recurrence.interval') - frequency = interval && interval.toLowerCase() - } + if (giftData?.length) { + if (get(giftData, '[0].gift["updated-rate"].recurrence.interval')) { + frequency = giftData[0].gift['updated-rate'].recurrence.interval.toLowerCase() + } else { + const interval = get(giftData, '[0].parentDonation.rate.recurrence.interval') + frequency = interval && interval.toLowerCase() + } - if (typeof $window.digitalData !== 'undefined') { - if (typeof $window.digitalData.recurringGift !== 'undefined') { - $window.digitalData.recurringGift.originalFrequency = frequency - } else { - $window.digitalData.recurringGift = { - originalFrequency: frequency - } - } + if ($window?.digitalData) { + if ($window.digitalData?.recurringGift) { + $window.digitalData.recurringGift.originalFrequency = frequency } else { - $window.digitalData = { - recurringGift: { - originalFrequency: frequency - } + $window.digitalData.recurringGift = { + originalFrequency: frequency } } - } - - this.pageLoaded() - } catch (e) { - // Error caught in analyticsFactory.editRecurringDonation - } - }, - getPath: function () { - try { - var pagename = '' - var delim = ' : ' - var path = $window.location.pathname - - if (path !== '/') { - var extension = ['.html', '.htm'] - - for (var i = 0; i < extension.length; i++) { - if (path.indexOf(extension[i]) > -1) { - path = path.split(extension[i]) - path = path.splice(0, 1) - path = path.toString() - - break + } else { + $window.digitalData = { + recurringGift: { + originalFrequency: frequency } } + } + } - path = path.split('/') + this.pageLoaded() + }), + getPath: suppressErrors(function () { + let pagename = '' + const delim = ' : ' + let path = $window.location.pathname - if (path[0].length === 0) { - path.shift() - } + if (path !== '/') { + const extension = ['.html', '.htm'] - // Capitalize first letter of each page - for (i = 0; i < path.length; i++) { - path[i] = path[i].charAt(0).toUpperCase() + path[i].slice(1) - } + for (let i = 0; i < extension.length; i++) { + if (path.indexOf(extension[i]) > -1) { + path = path.split(extension[i]) + path = path.splice(0, 1) + path = path.toString() - // Set pageName - pagename = 'Give' + delim + path.join(delim) - } else { - // Set pageName - pagename = 'Give' + delim + 'Home' + break + } } - this.setPageNameObj(pagename) + path = path.split('/') - return path - } catch (e) { - // Error caught in analyticsFactory.getPath - } - }, - getSetProductCategory: function (path) { - try { - var allElements = $window.document.getElementsByTagName('*') - - for (var i = 0, n = allElements.length; i < n; i++) { - var desigType = allElements[i].getAttribute('designationtype') - - if (desigType !== null) { - const productConfig = $window.document.getElementsByTagName('product-config') - $window.digitalData.product = [{ - productInfo: { - productID: productConfig.length ? productConfig[0].getAttribute('product-code') : null - }, - category: { - primaryCategory: 'donation ' + desigType.toLowerCase(), - siebelProductType: 'designation', - organizationId: path[0] - } - }] + if (path[0].length === 0) { + path.shift() + } - return path[0] - } + // Capitalize first letter of each page + for (let i = 0; i < path.length; i++) { + path[i] = path[i].charAt(0).toUpperCase() + path[i].slice(1) } - return false - } catch (e) { - // Error caught in analyticsFactory.getSetProductCategory + // Set pageName + pagename = 'Give' + delim + path.join(delim) + } else { + // Set pageName + pagename = 'Give' + delim + 'Home' } - }, - giveGiftModal: function (productData) { - try { - var product = [{ - productInfo: { - productID: productData.designationNumber - }, - attributes: { - siebel: { - producttype: 'designation' - } - } - }] - $window.digitalData.product = product - $window.dataLayer.push({ - event: 'give-gift-modal', - ecommerce: { - currencyCode: 'USD', - detail: { - products: [{ - name: productData.designationNumber, - id: productData.designationNumber, - price: undefined, - brand: productData.orgId, - category: productData.designationType.toLowerCase(), - variant: undefined, - quantity: '1' - }] + this.setPageNameObj(pagename) + + return path + }), + getSetProductCategory: suppressErrors(function (path) { + const allElements = $window.document.getElementsByTagName('*') + + for (let i = 0, n = allElements.length; i < n; i++) { + const desigType = allElements[i].getAttribute('designationtype') + + if (desigType !== null) { + const productConfig = $window.document.getElementsByTagName('product-config') + $window.digitalData.product = [{ + productInfo: { + productID: productConfig.length ? productConfig[0].getAttribute('product-code') : null + }, + category: { + primaryCategory: 'donation ' + desigType ? desigType.toLowerCase() : '', + siebelProductType: 'designation', + organizationId: path[0] } - } - }) - this.setEvent('give gift modal') - this.pageLoaded() - } catch (e) { - // Error caught in analyticsFactory.giveGiftModal + }] + + return path[0] + } } - }, - pageLoaded: function (skipImageRequests) { - try { - const path = this.getPath() - this.getSetProductCategory(path) - this.setSiteSections(path) - this.setLoggedInStatus() - - if (typeof $window.digitalData.page.attributes !== 'undefined') { - if ($window.digitalData.page.attributes.angularLoaded === 'true') { - $window.digitalData.page.attributes.angularLoaded = 'false' - } else { - $window.digitalData.page.attributes.angularLoaded = 'true' + + return false + }), + giveGiftModal: suppressErrors(function (productData) { + const product = [{ + productInfo: { + productID: productData.designationNumber + }, + attributes: { + siebel: { + producttype: 'designation' } + } + }] + const modifiedProductData = { ...productData } + modifiedProductData.frequency = undefined + $window.digitalData.product = product + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ + event: 'view_item', + ecommerce: { + currencyCode: 'USD', + // value is unavailable until the user selects a gift amount + value: undefined, + items: [generateProduct(modifiedProductData)] + } + }) + this.setEvent('give gift modal') + this.pageLoaded() + }), + pageLoaded: suppressErrors(function (skipImageRequests) { + const path = this.getPath() + this.getSetProductCategory(path) + this.setSiteSections(path) + this.setLoggedInStatus() + + if (typeof $window.digitalData.page.attributes !== 'undefined') { + if ($window.digitalData.page.attributes.angularLoaded === 'true') { + $window.digitalData.page.attributes.angularLoaded = 'false' } else { - $window.digitalData.page.attributes = { - angularLoaded: 'true' - } + $window.digitalData.page.attributes.angularLoaded = 'true' } - - if (!skipImageRequests) { - // Allow time for data layer changes to be consumed & fire image request - $timeout(function () { - try { - $window.s.t() - $window.s.clearVars() - } catch (e) { - // Error caught in analyticsFactory.pageLoaded while trying to fire analytics image request or clearVars - } - }, 1000) + } else { + $window.digitalData.page.attributes = { + angularLoaded: 'true' } - } catch (e) { - // Error caught in analyticsFactory.pageLoaded } - }, - pageReadyForOptimize: function () { - if (typeof $window.dataLayer !== 'undefined') { - let found = false - angular.forEach($window.dataLayer, (value) => { - if (value.event && value.event === 'angular.loaded') { - found = true + + if (!skipImageRequests) { + // Allow time for data layer changes to be consumed & fire image request + $timeout(function () { + try { + $window.s.t() + $window.s.clearVars() + } catch (e) { + // Error caught in analyticsFactory.pageLoaded while trying to fire analytics image request or clearVars } - }) - if (!found) { - $window.dataLayer.push({ event: 'angular.loaded' }) - } + }, 1000) } - }, - productViewDetailsEvent: function (product) { - try { - if (typeof $window.dataLayer !== 'undefined') { - $window.dataLayer.push({ - event: 'product-detail-click', - ecommerce: { - currencyCode: 'USD', - click: { - actionField: { - list: 'search results' - }, - products: [ - { - name: product.designationNumber, - id: product.designationNumber, - price: undefined, - brand: product.orgId, - category: product.type, - variant: undefined, - position: undefined - } - ] - } - } - }) + }), + pageReadyForOptimize: suppressErrors(function () { + $window.dataLayer = $window.dataLayer || [] + let found = false + angular.forEach($window.dataLayer, (value) => { + if (value.event && value.event === 'angular.loaded') { + found = true } - } catch (e) { - // Error caught in analyticsFactory.productViewDetailsEvent - } - }, - purchase: function (donorDetails, cartData) { - try { - // Build cart data layer - this.setDonorDetails(donorDetails) - this.buildProductVar(cartData) - // Stringify the cartObject and store in localStorage for the transactionEvent - localStorage.setItem('transactionCart', JSON.stringify(cartData)) - } catch (e) { - // Error caught in analyticsFactory.purchase + }) + if (!found) { + $window.dataLayer.push({ event: 'angular.loaded' }) } - }, - setPurchaseNumber: function (purchaseNumber) { - try { + }), + productViewDetailsEvent: suppressErrors(function (product) { + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ + event: 'product-detail-click', + ecommerce: { + currencyCode: 'USD', + click: { + actionField: { + list: 'search results' + }, + products: [ + generateProduct(product, { + category: product.type, + name: product.name + }) + ] + } + } + }) + }), + purchase: suppressErrors(function (donorDetails, cartData, coverFeeDecision) { + // Build cart data layer + this.setDonorDetails(donorDetails) + this.buildProductVar(cartData) + // Stringify the cartObject and store in localStorage for the transactionEvent + localStorage.setItem('transactionCart', JSON.stringify(cartData)) + // Store value of coverFeeDecision in sessionStorage for the transactionEvent + sessionStorage.setItem('coverFeeDecision', coverFeeDecision) + }), + setPurchaseNumber: suppressErrors(function (purchaseNumber) { + if ($window?.digitalData) { $window.digitalData.purchaseNumber = purchaseNumber - } catch (e) { - // Error caught in analyticsFactory.setPurchaseNumber + } else { + $window.digitalData = { + purchaseNumber + } } - }, - transactionEvent: function (purchaseData) { - try { - // Parse the cart object of the last purchase - const transactionCart = JSON.parse(localStorage.getItem('transactionCart')) - // The purchaseId number from the last purchase - const lastTransactionId = sessionStorage.getItem('transactionId') - // The purchaseId number from the pruchase data being passed in - const currentTransactionId = purchaseData && purchaseData.rawData['purchase-number'] - let purchaseTotal = 0 - // If the lastTransactionId and the current one do not match, we need to send an analytics event for the transaction - if (purchaseData && lastTransactionId !== currentTransactionId) { - // Set the transactionId in localStorage to be the one that is passed in - sessionStorage.setItem('transactionId', currentTransactionId) - const cartObject = transactionCart.items.map((cartItem) => { - purchaseTotal += cartItem.amount - return { - name: cartItem.designationNumber, - id: cartItem.designationNumber, - price: cartItem.amount.toString(), - brand: cartItem.orgId, - category: cartItem.designationType.toLowerCase(), - variant: cartItem.frequency.toLowerCase(), - quantity: '1', - dimension1: localStorage.getItem('gaDonorType'), - dimension3: cartItem.frequency.toLowerCase() === 'single' ? 'one-time' : 'recurring', - dimension4: cartItem.frequency.toLowerCase(), - dimension6: purchaseData.paymentMeans['account-type'] ? 'bank account' : 'credit card', - dimension7: purchaseData.rawData['purchase-number'], - dimension8: 'designation', - dimension9: cartItem.config['campaign-code'] !== '' ? cartItem.config['campaign-code'] : undefined - } - }) - // Send the transaction event if the dataLayer is defined - if (typeof $window.dataLayer !== 'undefined') { - $window.dataLayer.push({ - event: 'transaction', - paymentType: purchaseData.paymentMeans['account-type'] ? 'bank account' : 'credit card', - ecommerce: { - currencyCode: 'USD', - purchase: { - actionField: { - id: purchaseData.rawData['purchase-number'], - affiliation: undefined, - revenue: purchaseTotal.toString(), - shipping: undefined, - tax: undefined, - coupon: undefined - }, - products: [ - ...cartObject - ] - } - } - }) + }), + transactionEvent: suppressErrors(function (purchaseData) { + // The value of whether or not user is covering credit card fees for the transaction + const coverFeeDecision = JSON.parse(sessionStorage.getItem('coverFeeDecision')) + // Parse the cart object of the last purchase + const transactionCart = JSON.parse(localStorage.getItem('transactionCart')) + // The purchaseId number from the last purchase + const lastTransactionId = sessionStorage.getItem('transactionId') + // The purchaseId number from the pruchase data being passed in + const currentTransactionId = purchaseData && purchaseData.rawData['purchase-number'] + let purchaseTotal = 0 + let purchaseTotalWithFees = 0 + // If the lastTransactionId and the current one do not match, we need to send an analytics event for the transaction + if (purchaseData && lastTransactionId !== currentTransactionId) { + const paymentType = purchaseData.paymentInstruments['account-type'] ? 'bank account' : 'credit card' + + // Set the transactionId in localStorage to be the one that is passed in + sessionStorage.setItem('transactionId', currentTransactionId) + const cartObject = transactionCart.items.map((cartItem) => { + const { amount, amountWithFees } = cartItem + purchaseTotal += amount + purchaseTotalWithFees += amountWithFees || 0 + const frequency = cartItem?.frequency ? cartItem.frequency.toLowerCase() : undefined + return { + ...generateProduct(cartItem), + processingFee: amountWithFees && coverFeeDecision ? (amountWithFees - amount).toFixed(2) : undefined, + ga_donator_type: localStorage.getItem('gaDonorType'), + donation_type: frequency === 'single' ? 'one-time' : 'recurring', + donation_frequency: frequency, + payment_type: paymentType, + purchase_number: purchaseData.rawData['purchase-number'], + campaign_code: cartItem.config.CAMPAIGN_CODE !== '' ? cartItem.config.CAMPAIGN_CODE : undefined + } + }) + // Send the transaction event if the dataLayer is defined + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ + event: 'purchase', + paymentType: paymentType, + ecommerce: { + currency: 'USD', + payment_type: paymentType, + donator_type: purchaseData.donorDetails['donor-type'], + pays_processing: purchaseTotalWithFees && coverFeeDecision ? 'yes' : 'no', + value: purchaseTotalWithFees && coverFeeDecision ? purchaseTotalWithFees.toFixed(2).toString() : purchaseTotal.toFixed(2).toString(), + processing_fee: purchaseTotalWithFees && coverFeeDecision ? (purchaseTotalWithFees - purchaseTotal).toFixed(2) : undefined, + transaction_id: purchaseData.rawData['purchase-number'], + items: [ + ...cartObject + ] } + }) + // Send cover fees event if value is true + if (coverFeeDecision) { + $window.dataLayer.push({ + event: 'ga-cover-fees-checkbox' + }) } - // Remove the transactionCart from localStorage since it is no longer needed - localStorage.removeItem('transactionCart') - } catch (e) { - // Error in analyticsFactory.transactionEvent } - }, - search: function (params, results) { - try { - if (typeof params !== 'undefined') { - if (typeof $window.digitalData.page !== 'undefined') { - if (typeof $window.digitalData.page.pageInfo !== 'undefined') { - $window.digitalData.page.pageInfo.onsiteSearchTerm = params.keyword - $window.digitalData.page.pageInfo.onsiteSearchFilter = params.type - } else { - $window.digitalData.page.pageInfo = { - onsiteSearchTerm: params.keyword, - onsiteSearchFilter: params.type - } - } + // Remove the transactionCart from localStorage since it is no longer needed + localStorage.removeItem('transactionCart') + // Remove the coverFeeDecision from sessionStorage since it is no longer needed + sessionStorage.removeItem('coverFeeDecision') + // Remove testingTransaction from sessionStorage for each item if any since it is no longer needed + transactionCart.items.forEach((item) => { + $window.sessionStorage.removeItem(testingTransactionName(item)) + }) + }), + search: suppressErrors(function (params, results) { + if (params) { + if ($window?.digitalData?.page) { + if ($window.digitalData.page?.pageInfo) { + $window.digitalData.page.pageInfo.onsiteSearchTerm = params.keyword + $window.digitalData.page.pageInfo.onsiteSearchFilter = params.type } else { - $window.digitalData.page = { - pageInfo: { - onsiteSearchTerm: params.keyword, - onsiteSearchFilter: params.type - } + $window.digitalData.page.pageInfo = { + onsiteSearchTerm: params.keyword, + onsiteSearchFilter: params.type + } + } + } else { + $window.digitalData.page = { + pageInfo: { + onsiteSearchTerm: params.keyword, + onsiteSearchFilter: params.type } } } + } - if (typeof results !== 'undefined' && results.length > 0) { - if (typeof $window.digitalData.page !== 'undefined') { - if (typeof $window.digitalData.page.pageInfo !== 'undefined') { - $window.digitalData.page.pageInfo.onsiteSearchResults = results.length - } else { - $window.digitalData.page.pageInfo = { - onsiteSearchResults: results.length - } - } + if (results?.length) { + if ($window?.digitalData?.page) { + if ($window?.digitalData?.page?.pageInfo) { + $window.digitalData.page.pageInfo.onsiteSearchResults = results.length } else { - $window.digitalData.page = { - pageInfo: { - onsiteSearchResults: results.length - } + $window.digitalData.page.pageInfo = { + onsiteSearchResults: results.length } } } else { - $window.digitalData.page.pageInfo.onsiteSearchResults = 0 + $window.digitalData.page = { + pageInfo: { + onsiteSearchResults: results.length + } + } } - } catch (e) { - // Error caught in analyticsFactory.search + } else { + $window.digitalData.page.pageInfo.onsiteSearchResults = 0 } - }, - setLoggedInStatus: function () { - try { - const profileInfo = {} - if (typeof sessionService !== 'undefined') { - let ssoGuid - if (typeof sessionService.session['sso_guid'] !== 'undefined') { - ssoGuid = sessionService.session['sso_guid'] - } else if (typeof sessionService.session['sub'] !== 'undefined') { - ssoGuid = sessionService.session['sub'].split('|').pop() - } - if (typeof ssoGuid !== 'undefined' && ssoGuid !== 'cas') { - profileInfo['ssoGuid'] = ssoGuid - } - - if (typeof sessionService.session['gr_master_person_id'] !== 'undefined') { - profileInfo['grMasterPersonId'] = sessionService.session['gr_master_person_id'] - } + }), + setLoggedInStatus: suppressErrors(function () { + const profileInfo = {} + if (sessionService) { + let ssoGuid + if (sessionService?.session?.sso_guid) { + ssoGuid = sessionService.session.sso_guid + } else if (sessionService?.session?.sub) { + ssoGuid = sessionService.session.sub.split('|').pop() } - - if (isEmpty(profileInfo)) { - return + if (ssoGuid && ssoGuid !== 'cas') { + profileInfo.ssoGuid = ssoGuid } - // Use lodash merge to deep merge with existing data or new empty hash - $window.digitalData = merge($window.digitalData || {}, { - user: [{ profile: [{ profileInfo: profileInfo }] }] - }) - } catch (e) { - // Error caught in analyticsFactory.setLoggedInStatus + if (sessionService?.session?.gr_master_person_id) { + profileInfo.grMasterPersonId = sessionService.session.gr_master_person_id + } } - }, - setDonorDetails: function (donorDetails) { - try { - const profileInfo = {} - if (typeof sessionService !== 'undefined') { - if (typeof sessionService.session['sso_guid'] !== 'undefined') { - profileInfo['ssoGuid'] = sessionService.session['sso_guid'] - } else if (typeof sessionService.session['sub'] !== 'undefined') { - profileInfo['ssoGuid'] = sessionService.session['sub'].split('|').pop() - } - if (typeof sessionService.session['gr_master_person_id'] !== 'undefined') { - profileInfo['grMasterPersonId'] = sessionService.session['gr_master_person_id'] - } + if (isEmpty(profileInfo)) { + return + } - if (donorDetails) { - profileInfo['donorType'] = donorDetails['donor-type'].toLowerCase() - profileInfo['donorAcct'] = donorDetails['donor-number'].toLowerCase() - } + // Use lodash merge to deep merge with existing data or new empty hash + $window.digitalData = merge($window.digitalData || {}, { + user: [{ profile: [{ profileInfo: profileInfo }] }] + }) + }), + setDonorDetails: suppressErrors(function (donorDetails) { + const profileInfo = {} + if (sessionService) { + if (sessionService?.session?.sso_guid) { + profileInfo.ssoGuid = sessionService.session.sso_guid + } else if (sessionService?.session?.sub) { + profileInfo.ssoGuid = sessionService.session.sub.split('|').pop() } - // Use lodash merge to deep merge with existing data or new empty hash - $window.digitalData = merge($window.digitalData || {}, { - user: [{ profile: [{ profileInfo: profileInfo }] }] - }) + if (sessionService?.session?.gr_master_person_id) { + profileInfo.grMasterPersonId = sessionService.session.gr_master_person_id + } - // Store data for use on following page load - localStorage.setItem('gaDonorType', $window.digitalData.user[0].profile[0].profileInfo.donorType) - localStorage.setItem('gaDonorAcct', $window.digitalData.user[0].profile[0].profileInfo.donorAcct) - } catch (e) { - // Error caught in analyticsFactory.setDonorDetails + if (donorDetails) { + profileInfo.donorType = donorDetails['donor-type'] ? donorDetails['donor-type'].toLowerCase() : undefined + profileInfo.donorAcct = donorDetails['donor-number'] ? donorDetails['donor-number'].toLowerCase() : undefined + } } - }, - setEvent: function (eventName) { - try { - var evt = { - eventInfo: { - eventName: eventName - } + + // Use lodash merge to deep merge with existing data or new empty hash + $window.digitalData = merge($window.digitalData || {}, { + user: [{ profile: [{ profileInfo: profileInfo }] }] + }) + + // Store data for use on following page load + localStorage.setItem('gaDonorType', $window.digitalData.user[0].profile[0].profileInfo.donorType) + localStorage.setItem('gaDonorAcct', $window.digitalData.user[0].profile[0].profileInfo.donorAcct) + }), + setEvent: suppressErrors(function (eventName) { + const evt = { + eventInfo: { + eventName: eventName } + } + if ($window?.digitalData) { $window.digitalData.event = [] - $window.digitalData.event.push(evt) - } catch (e) { - // Error caught in analyticsFactory.setEvent + } else { + $window.digitalData = { + event: [] + } } - }, - setPageNameObj: function (pageName) { - try { - if (typeof $window.digitalData.page !== 'undefined') { - if (typeof $window.digitalData.page.pageInfo !== 'undefined') { - $window.digitalData.page.pageInfo.pageName = pageName - } else { - $window.digitalData.page.pageInfo = { - pageName: pageName - } - } + $window.digitalData.event.push(evt) + }), + setPageNameObj: suppressErrors(function (pageName) { + if ($window?.digitalData?.page) { + if ($window.digitalData.page?.pageInfo) { + $window.digitalData.page.pageInfo.pageName = pageName } else { + $window.digitalData.page.pageInfo = { + pageName: pageName + } + } + } else { + if ($window?.digitalData) { $window.digitalData.page = { pageInfo: { pageName: pageName } } + } else { + $window.digitalData = { + page: { + pageInfo: { + pageName: pageName + } + } + } } - } catch (e) { - // Error caught in analyticsFactory.setPageNameObj } - }, - setSiteSections: function (path) { - try { - var primaryCat = 'give' + }), + setSiteSections: suppressErrors(function (path) { + const primaryCat = 'give' - if (!path) { - path = this.getPath() - } + if (!path) { + path = this.getPath() + } - if (typeof $window.digitalData !== 'undefined') { - if (typeof $window.digitalData.page !== 'undefined') { - $window.digitalData.page.category = { + if ($window?.digitalData) { + if ($window.digitalData?.page) { + $window.digitalData.page.category = { + primaryCategory: primaryCat + } + } else { + $window.digitalData.page = { + category: { primaryCategory: primaryCat } - } else { - $window.digitalData.page = { - category: { - primaryCategory: primaryCat - } - } } - } else { - $window.digitalData = { - page: { - category: { - primaryCategory: primaryCat - } + } + } else { + $window.digitalData = { + page: { + category: { + primaryCategory: primaryCat } } } + } - if (path.length >= 1) { - // Check if product page - if (/^\d+$/.test(path[0])) { - this.getSetProductCategory(path) - $window.digitalData.page.category.subCategory1 = 'designation detail' - } else { - $window.digitalData.page.category.subCategory1 = path[0] === '/' ? '' : path[0] - } + if (path?.length) { + // Check if product page + if (/^\d+$/.test(path[0])) { + this.getSetProductCategory(path) + $window.digitalData.page.category.subCategory1 = 'designation detail' + } else { + $window.digitalData.page.category.subCategory1 = path[0] === '/' ? '' : path[0] + } - if (path.length >= 2) { - $window.digitalData.page.category.subCategory2 = path[1] + if (path.length >= 2) { + $window.digitalData.page.category.subCategory2 = path[1] - if (path.length >= 3) { - $window.digitalData.page.category.subCategory3 = path[2] - } + if (path.length >= 3) { + $window.digitalData.page.category.subCategory3 = path[2] } } - } catch (e) { - // Error caught in analyticsFactory.setSiteSections } - }, - track: function (eventName) { - try { - $window.dataLayer.push({ - event: eventName - }) - } catch (e) { - // Error caught in analyticsFactory.track - } - } + }), + track: suppressErrors(function (eventName) { + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ + event: eventName + }) + }) } } diff --git a/src/app/analytics/analytics.factory.spec.js b/src/app/analytics/analytics.factory.spec.js new file mode 100644 index 000000000..16889bd61 --- /dev/null +++ b/src/app/analytics/analytics.factory.spec.js @@ -0,0 +1,692 @@ +import angular from 'angular' +import 'angular-mocks' +import moment from 'moment' + +import module from './analytics.factory' + +describe('analytics factory', () => { + beforeEach(angular.mock.module(module.name, 'environment')) + + const self = {} + beforeEach(inject((analyticsFactory, envService, $window) => { + self.analyticsFactory = analyticsFactory + self.envService = envService + self.$window = $window + self.$window.digitalData = { cart: {} } + self.$window.dataLayer = [] + + self.$window.sessionStorage.clear() + self.$window.localStorage.clear() + + Date.now = jest.fn(() => new Date("2023-04-05T01:02:03.000Z")); + })) + + describe('handleCheckoutFormErrors', () => { + const form = { + $valid: false, + $dirty: true, + firstName: { + $invalid: true, + $error: { + required: true + } + }, + lastName: { + $invalid: false, + $error: {} + }, + middleName: { + $invalid: true, + $error: { + capitalized: true, + maxLength: true + } + } + } + + it('calls checkoutFieldError for each error', () => { + jest.spyOn(self.analyticsFactory, 'checkoutFieldError') + jest.spyOn(self.envService, 'read').mockImplementation(name => name === 'isCheckout') + + self.analyticsFactory.handleCheckoutFormErrors(form) + expect(self.analyticsFactory.checkoutFieldError.mock.calls).toEqual([ + ['firstName', 'required'], + ['middleName', 'capitalized'], + ['middleName', 'maxLength'] + ]) + }); + + it('does nothing when not checkout out', () => { + jest.spyOn(self.analyticsFactory, 'checkoutFieldError') + jest.spyOn(self.envService, 'read').mockReturnValue(false) + + self.analyticsFactory.handleCheckoutFormErrors(form) + expect(self.analyticsFactory.checkoutFieldError).not.toHaveBeenCalled() + }); + }); + + describe('cartAdd', () => { + const itemConfig = { + "campaign-page": "", + "jcr-title": "John Doe", + "RECURRING_DAY_OF_MONTH": "13", + "RECURRING_START_MONTH": "09", + "AMOUNT": 50 + } + const productData = { + "uri": "items/crugive/a5t4fmspmfpwpqv3le7hgksifu=", + "frequencies": [ + { + "name": "SEMIANNUAL", + "display": "Semi-Annually", + "selectAction": "/itemselections/crugive/a5t4fmspmfpwpqv3le7hgksifu=/options/izzgk4lvmvxgg6i=/values/kncu2skbjzhfkqkm=/selector" + }, + { + "name": "QUARTERLY", + "display": "Quarterly", + "selectAction": "/itemselections/crugive/a5t4fmspmfpwpqv3le7hgksifu=/options/izzgk4lvmvxgg6i=/values/kfkucusuivjeywi=/selector" + }, + { + "name": "MON", + "display": "Monthly", + "selectAction": "/itemselections/crugive/a5t4fmspmfpwpqv3le7hgksifu=/options/izzgk4lvmvxgg6i=/values/jvhu4=/selector" + }, + { + "name": "ANNUAL", + "display": "Annually", + "selectAction": "/itemselections/crugive/a5t4fmspmfpwpqv3le7hgksifu=/options/izzgk4lvmvxgg6i=/values/ifhe4vkbjq=/selector" + }, + { + "name": "NA", + "display": "Single", + "selectAction": "" + } + ], + "frequency": "MON", + "displayName": "International Staff", + "designationType": "Staff", + "code": "0643021", + "designationNumber": "0643021", + "orgId": "STAFF" + } + + + it('should add an monthly item to the datalayer', () => { + jest.spyOn(self.envService, 'read').mockImplementation(name => name === 'isCheckout') + + self.analyticsFactory.cartAdd(itemConfig, productData) + + expect(self.$window.dataLayer.length).toEqual(1) + expect(self.$window.dataLayer[0].event).toEqual('add_to_cart') + expect(self.$window.dataLayer[0].ecommerce.value).toEqual(itemConfig.AMOUNT.toFixed(2)) + expect(self.$window.dataLayer[0].ecommerce.items[0]).toEqual({ + item_id: productData.code, + item_name: productData.displayName, + item_brand: productData.orgId, + item_category: productData.designationType.toLowerCase(), + item_variant: 'monthly', + currency: 'USD', + price: itemConfig.AMOUNT.toString(), + quantity: '1', + recurring_date: 'September 13, 2023', + testing_transaction: 'false', + }) + }); + + it('should add an single item to the datalayer', () => { + productData.frequency = 'NA' + jest.spyOn(self.envService, 'read').mockImplementation(name => name === 'isCheckout') + + self.analyticsFactory.cartAdd(itemConfig, productData) + + expect(self.$window.dataLayer.length).toEqual(1) + expect(self.$window.dataLayer[0].event).toEqual('add_to_cart') + expect(self.$window.dataLayer[0].ecommerce.value).toEqual(itemConfig.AMOUNT.toFixed(2)) + expect(self.$window.dataLayer[0].ecommerce.items[0]).toEqual({ + item_id: productData.code, + item_name: productData.displayName, + item_brand: productData.orgId, + item_category: productData.designationType.toLowerCase(), + item_variant: 'single', + currency: 'USD', + price: itemConfig.AMOUNT.toString(), + quantity: '1', + recurring_date: undefined, + testing_transaction: 'false', + }) + }) + }); + + + describe('buildProductVar', () => { + const cartData = { + "id": "geydmm3cgfsgiljygjtgeljumfstkllbgfrdkljvgf", + "items": [ + { + "uri": "/carts/crugive/geydmm3cgfsgiljygjtgeljumfstkllbgfrdkljvgf/lineitems/g44wcnzrhe3wgllcmezdmljugvqtgllc", + "code": "0048461_mon", + "orgId": "STAFF", + "displayName": "John Doe", + "designationType": "Staff", + "price": "$50.00", + "priceWithFees": "$51.20", + "config": { + "AMOUNT": 50, + "AMOUNT_WITH_FEES": 51.2, + "CAMPAIGN_CODE": "", + "DONATION_SERVICES_COMMENTS": "", + "PREMIUM_CODE": "", + "RECIPIENT_COMMENTS": "", + "RECURRING_DAY_OF_MONTH": "15", + "RECURRING_START_MONTH": "09" + }, + "frequency": "Monthly", + "amount": 50, + "amountWithFees": 51.2, + "designationNumber": "0048461", + "productUri": "/items/crugive/a5t4fmspmhbkez6cvfmucmrkykwc7q4mykr4fps5ee=", + "giftStartDate": moment(new Date(2024, 8, 15)), + "giftStartDateDaysFromNow": 361, + "giftStartDateWarning": true, + } + ], + "frequencyTotals": [ + { + "frequency": "Monthly", + "amount": 50, + "amountWithFees": 51.2, + "total": "$50.00", + "totalWithFees": "$51.20" + }, + { + "frequency": "Single", + "amount": 0, + "amountWithFees": 0, + "total": "$0.00", + "totalWithFees": "$0.00" + } + ], + "cartTotal": 0, + "cartTotalDisplay": "$0.00" + } + + + it('should add data to DataLayer', () => { + self.analyticsFactory.buildProductVar(cartData) + + expect(self.$window.digitalData.cart.id).toEqual(cartData.id) + expect(self.$window.digitalData.cart.hash).toEqual('330c050e7344971e9250') + expect(self.$window.digitalData.cart.price.cartTotal).toEqual(cartData.cartTotal) + expect(self.$window.digitalData.cart.item.length).toEqual(1) + }); + }); + + describe('cartRemove', () => { + const item = { + "uri": "/carts/crugive/geydmm3cgfsgiljygjtgeljumfstkllbgfrdkljvgf/lineitems/g44wcnzrhe3wgllcmezdmljugvqtgllcmeztol", + "code": "0048461_mon", + "orgId": "STAFF", + "displayName": "John Doe", + "designationType": "Staff", + "price": "$50.00", + "priceWithFees": "$51.20", + "config": { + "AMOUNT": 50, + "AMOUNT_WITH_FEES": 51.2, + "CAMPAIGN_CODE": "CAMPAIGN", + "DONATION_SERVICES_COMMENTS": "", + "PREMIUM_CODE": "", + "RECIPIENT_COMMENTS": "", + "RECURRING_DAY_OF_MONTH": "15", + "RECURRING_START_MONTH": "09" + }, + "frequency": "Monthly", + "amount": 50, + "amountWithFees": 51.2, + "designationNumber": "0048461", + "productUri": "/items/crugive/a5t4fmspmhbkez6cv", + "giftStartDate": moment(new Date(2024, 8, 15)), + "giftStartDateDaysFromNow": 361, + "giftStartDateWarning": true, + "removing": true + } + + it('should remove item from dataLayer and fire event', () => { + jest.spyOn(self.envService, 'read').mockImplementation(name => name === 'isCheckout') + + self.analyticsFactory.cartRemove(item) + + expect(self.$window.digitalData.cart.item[0].attributes).toEqual({ + donationType: 'recurring donation', + donationFrequency: item.frequency.toLowerCase(), + siebel: { + productType: 'designation', + campaignCode: 'CAMPAIGN' + } + }) + expect(self.$window.digitalData.cart.item.length).toEqual(1) + + expect(self.$window.dataLayer[0].event).toEqual('remove_from_cart') + expect(self.$window.dataLayer[0].ecommerce).toEqual({ + currencyCode: 'USD', + value: item.amount.toFixed(2), + items: [{ + item_id: item.designationNumber, + item_name: item.displayName, + item_brand: item.orgId, + item_category: item.designationType.toLowerCase(), + item_variant: 'monthly', + currency: 'USD', + price: item.amount.toString(), + quantity: '1', + recurring_date: 'September 15, 2024', + testing_transaction: 'false', + }] + }) + }); + }); + + describe('checkoutStepEvent', () => { + const cart = { + "id": "g5stanjsgzswkllbmjtdaljuga4wmljzgnqw=", + "items": [ + { + "uri": "/carts/crugive/g5stanjsgzswkllbmjtdaljuga4wmljzgnqw/lineitems/he4wcnzvgfswgllgmizdqljumi2wkllbmjrdqljw", + "code": "0643021", + "orgId": "STAFF", + "displayName": "John Doe", + "designationType": "Staff", + "price": "$50.00", + "priceWithFees": "$51.20", + "config": { + "AMOUNT": 50, + "AMOUNT_WITH_FEES": 51.2, + "CAMPAIGN_CODE": "", + "DONATION_SERVICES_COMMENTS": "", + "PREMIUM_CODE": "", + "RECIPIENT_COMMENTS": "", + "RECURRING_DAY_OF_MONTH": "", + "RECURRING_START_MONTH": "" + }, + "frequency": "Single", + "amount": 50, + "amountWithFees": 51.2, + "designationNumber": "0643021", + "productUri": "/items/crugive/a5t4fmspm", + "giftStartDate": null, + "giftStartDateDaysFromNow": 0, + "giftStartDateWarning": false + } + ], + "frequencyTotals": [ + { + "frequency": "Single", + "amount": 50, + "amountWithFees": 51.2, + "total": "$50.00", + "totalWithFees": "$51.20" + } + ], + "cartTotal": 50, + "cartTotalDisplay": "$50.00" + } + const item = cart.items[0] + const formattedItem = { + item_id: item.designationNumber, + item_name: item.displayName, + item_brand: item.orgId, + item_category: item.designationType.toLowerCase(), + item_variant: 'single', + currency: 'USD', + price: item.amount.toString(), + quantity: '1', + recurring_date: undefined, + testing_transaction: 'false', + } + + it('should create begining checkout and checkout step DataLayer events', () => { + self.analyticsFactory.checkoutStepEvent('contact', cart) + + expect(self.$window.dataLayer.length).toEqual(2) + expect(self.$window.dataLayer[0]).toEqual({ + event: 'begin_checkout', + ecommerce: { + items: [formattedItem] + } + }) + + expect(self.$window.dataLayer[1].event).toEqual('checkout-step') + expect(self.$window.dataLayer[1].cartId).toEqual(cart.id) + expect(self.$window.dataLayer[1].ecommerce).toEqual({ + currencyCode: 'USD', + checkout: { + actionField: { + step: 1, + option: '' + }, + products: [formattedItem] + } + }) + }); + + it('should create payment info and checkout step DataLayer events', () => { + self.analyticsFactory.checkoutStepEvent('payment', cart) + + expect(self.$window.dataLayer.length).toEqual(2) + expect(self.$window.dataLayer[0]).toEqual({ + event: 'add_payment_info' + }) + + expect(self.$window.dataLayer[1].event).toEqual('checkout-step') + expect(self.$window.dataLayer[1].cartId).toEqual(cart.id) + expect(self.$window.dataLayer[1].ecommerce).toEqual({ + currencyCode: 'USD', + checkout: { + actionField: { + step: 2, + option: '' + }, + products: [formattedItem] + } + }) + }); + + it('should create review order and checkout step DataLayer events', () => { + self.analyticsFactory.checkoutStepEvent('review', cart) + + expect(self.$window.dataLayer.length).toEqual(2) + expect(self.$window.dataLayer[0]).toEqual({ + event: 'review_order', + }) + + expect(self.$window.dataLayer[1].event).toEqual('checkout-step') + expect(self.$window.dataLayer[1].cartId).toEqual(cart.id) + expect(self.$window.dataLayer[1].ecommerce).toEqual({ + currencyCode: 'USD', + checkout: { + actionField: { + step: 3, + option: '' + }, + products: [formattedItem] + } + }) + }) + }); + + describe('checkoutStepOptionEvent', () => { + it('should add contact checkout option event to DataLayer', () => { + self.analyticsFactory.checkoutStepOptionEvent('Household', 'contact') + expect(self.$window.dataLayer.length).toEqual(1) + expect(self.$window.dataLayer[0].event).toEqual('checkout-option') + expect(self.$window.dataLayer[0].ecommerce).toEqual({ + checkout_option: { + actionField: { + step: 1, + option: 'household' + } + } + }) + }); + it('should add payment checkout option event to DataLayer', () => { + self.analyticsFactory.checkoutStepOptionEvent('Visa', 'payment') + expect(self.$window.dataLayer.length).toEqual(1) + expect(self.$window.dataLayer[0].event).toEqual('checkout-option') + expect(self.$window.dataLayer[0].ecommerce).toEqual({ + checkout_option: { + actionField: { + step: 2, + option: 'visa' + } + } + }) + }); + it('should add review checkout option event to DataLayer', () => { + self.analyticsFactory.checkoutStepOptionEvent('Visa', 'review') + expect(self.$window.dataLayer.length).toEqual(1) + expect(self.$window.dataLayer[0].event).toEqual('checkout-option') + expect(self.$window.dataLayer[0].ecommerce).toEqual({ + checkout_option: { + actionField: { + step: 3, + option: 'visa' + } + } + }) + }); + }); + + describe('giveGiftModal', () => { + const productData = { + "uri": "items/crugive/a5t4fmspmfpwpqv3le7hgksifu=", + "frequencies": [ + { + "name": "SEMIANNUAL", + "display": "Semi-Annually", + "selectAction": "/itemselections/crugive/a5t4fmspmfpwpqv3le7hgksifu=/options/izzgk4lvmvxgg6i=/values/kncu2skbjzhfkqkm=/selector" + }, + { + "name": "QUARTERLY", + "display": "Quarterly", + "selectAction": "/itemselections/crugive/a5t4fmspmfpwpqv3le7hgksifu=/options/izzgk4lvmvxgg6i=/values/kfkucusuivjeywi=/selector" + }, + { + "name": "MON", + "display": "Monthly", + "selectAction": "/itemselections/crugive/a5t4fmspmfpwpqv3le7hgksifu=/options/izzgk4lvmvxgg6i=/values/jvhu4=/selector" + }, + { + "name": "ANNUAL", + "display": "Annually", + "selectAction": "/itemselections/crugive/a5t4fmspmfpwpqv3le7hgksifu=/options/izzgk4lvmvxgg6i=/values/ifhe4vkbjq=/selector" + }, + { + "name": "NA", + "display": "Single", + "selectAction": "" + } + ], + "frequency": "NA", + "displayName": "International Staff", + "designationType": "Staff", + "code": "0643021", + "designationNumber": "0643021", + "orgId": "STAFF" + } + + it('should push view_item event to the DataLayer', () => { + self.analyticsFactory.giveGiftModal(productData) + expect(self.$window.dataLayer.length).toEqual(1) + expect(self.$window.dataLayer[0].event).toEqual('view_item') + expect(self.$window.dataLayer[0].ecommerce).toEqual({ + currencyCode: 'USD', + value: undefined, + items: [{ + item_id: '0643021', + item_name: 'International Staff', + item_brand: 'STAFF', + item_category: 'staff', + item_variant: undefined, + price: undefined, + currency: 'USD', + quantity: '1', + recurring_date: undefined, + testing_transaction: 'false', + }] + }) + }); + }); + + + describe('productViewDetailsEvent', () => { + const productData = { + "path": "https://give-stage-cloud.cru.org/designations/0/6/4/3/0/0643021.html", + "designationNumber": "0643021", + "campaignPage": null, + "replacementDesignationNumber": null, + "name": "John Doe", + "type": "Staff", + "facet": "person", + "startMonth": null, + "ministry": "Staff Giving", + "orgId": "STAFF", + } + it('should add product-detail-click event', () => { + self.analyticsFactory.productViewDetailsEvent(productData) + expect(self.$window.dataLayer.length).toEqual(1) + expect(self.$window.dataLayer[0].event).toEqual('product-detail-click') + expect(self.$window.dataLayer[0].ecommerce).toEqual({ + currencyCode: 'USD', + click: { + actionField: { + list: 'search results' + }, + products: [ + { + item_id: '0643021', + item_name: 'John Doe', + item_brand: 'STAFF', + item_category: 'staff', + item_variant: undefined, + price: undefined, + currency: 'USD', + quantity: '1', + recurring_date: undefined, + testing_transaction: 'false', + } + ] + } + }) + }); + }); + + describe('transactionEvent', () => { + const item = { + "uri": "/carts/crugive/grsgezrxhfqtsljrga3gkljugvtdaljygjqtc;/lineitems/hezwgntcmrsgmllcgu3dsljumuygcljzmjsgcljwgqzdkyr", + "code": "0643021", + "orgId": "STAFF", + "displayName": "John Doe", + "designationType": "Staff", + "price": "$50.00", + "priceWithFees": "$51.20", + "config": { + "AMOUNT": 50, + "AMOUNT_WITH_FEES": 51.2, + "CAMPAIGN_CODE": "", + "DONATION_SERVICES_COMMENTS": "", + "PREMIUM_CODE": "", + "RECIPIENT_COMMENTS": "", + "RECURRING_DAY_OF_MONTH": "", + "RECURRING_START_MONTH": "" + }, + "frequency": "Single", + "amount": 50, + "amountWithFees": 51.2, + "designationNumber": "0643021", + "productUri": "/items/crugive/a5t4fmspmfpwpqv", + "giftStartDate": null, + "giftStartDateDaysFromNow": 0, + "giftStartDateWarning": false, + } + const transactionCart = { + "id": "grsgezrxhfqtsljrga3gkljugvtdaljygjqtcl", + "items": [item], + "frequencyTotals": [ + { + "frequency": "Single", + "amount": 50, + "amountWithFees": 51.2, + "total": "$50.00", + "totalWithFees": "$51.20", + } + ], + "cartTotal": 50, + "cartTotalDisplay": "$50.00" + } + const purchaseData = { + "donorDetails": { + "donor-type": "Household", + }, + "paymentInstruments": { + "card-type": "Visa", + }, + "lineItems": [], + "rateTotals": [], + "rawData": { + "purchase-number": "23032", + } + } + it('should add purchase event', async () => { + self.$window.sessionStorage.setItem('coverFeeDecision', null) + self.$window.localStorage.setItem('transactionCart', JSON.stringify(transactionCart)) + self.$window.sessionStorage.setItem('transactionId', 23031) + + expect(self.$window.sessionStorage.getItem('transactionId')).toEqual('23031') + + self.analyticsFactory.transactionEvent(purchaseData) + + expect(self.$window.sessionStorage.getItem('transactionId')).toEqual(purchaseData.rawData['purchase-number']) + + expect(self.$window.dataLayer.length).toEqual(1) + expect(self.$window.dataLayer[0].event).toEqual('purchase') + expect(self.$window.dataLayer[0].paymentType).toEqual('credit card') + expect(self.$window.dataLayer[0].ecommerce).toEqual({ + currency: 'USD', + payment_type: 'credit card', + donator_type: 'Household', + pays_processing: 'no', + value: '50.00', + processing_fee: undefined, + transaction_id: purchaseData.rawData['purchase-number'], + items: [ + { + item_id: '0643021', + item_name: 'John Doe', + item_category: 'staff', + item_variant: 'single', + price: '50', + currency: 'USD', + quantity: '1', + recurring_date: undefined, + ga_donator_type: null, + donation_type: 'one-time', + donation_frequency: 'single', + payment_type: 'credit card', + purchase_number: '23032', + campaign_code: undefined, + item_brand: 'STAFF', + processingFee: undefined, + testing_transaction: 'false', + } + ] + }) + }); + + it('should ignore and not send event', async () => { + self.$window.sessionStorage.setItem('coverFeeDecision', null) + self.$window.localStorage.setItem('transactionCart', JSON.stringify(transactionCart)) + self.$window.sessionStorage.setItem('transactionId', 23032) + self.analyticsFactory.transactionEvent(purchaseData) + + expect(self.$window.dataLayer.length).toEqual(0) + }); + + it('should add up totals correctly purchase event', async () => { + // Adding three items to the cart + transactionCart.items = [item,item,item] + self.$window.sessionStorage.setItem('coverFeeDecision', true) + self.$window.localStorage.setItem('transactionCart', JSON.stringify(transactionCart)) + self.$window.sessionStorage.setItem('transactionId', 23031) + + self.analyticsFactory.transactionEvent(purchaseData) + + const totalWithFees = 51.2 * 3 + const totalWithoutFees = 50 * 3 + + expect(self.$window.dataLayer[0].ecommerce.processing_fee).toEqual((totalWithFees - totalWithoutFees).toFixed(2)) + expect(self.$window.dataLayer[0].ecommerce.value).toEqual((totalWithFees).toFixed(2)) + expect(self.$window.dataLayer[0].ecommerce.pays_processing).toEqual('yes') + expect(self.$window.dataLayer[0].ecommerce.items[0].processingFee).toEqual((51.2 - 50).toFixed(2)) + }); + }); +}); diff --git a/src/app/analytics/analytics.module.js b/src/app/analytics/analytics.module.js index fc37f44ef..cbfdbdff2 100644 --- a/src/app/analytics/analytics.module.js +++ b/src/app/analytics/analytics.module.js @@ -3,5 +3,6 @@ import sessionService from 'common/services/session/session.service' export default angular .module('analytics', [ + 'environment', sessionService.name ]) diff --git a/src/app/branded/analytics/branded-analytics.factory.js b/src/app/branded/analytics/branded-analytics.factory.js new file mode 100644 index 000000000..5550d30f1 --- /dev/null +++ b/src/app/branded/analytics/branded-analytics.factory.js @@ -0,0 +1,151 @@ +import '../../analytics/analytics.module' +import angular from 'angular' + +const factoryName = 'brandedAnalyticsFactory' + +const brandedState = { + coverFees: undefined, + donorType: undefined, + isCreditCard: undefined, + item: undefined, + paymentType: undefined, + purchase: undefined, + testingTransaction: undefined +} + +// Generate a datalayer ecommerce object +function generateEcommerce (siebelTransactionId) { + const item = brandedState.item + const amountPaid = (brandedState.isCreditCard && brandedState.coverFees ? item.amountWithFees : item.amount).toFixed(2) + + return { + payment_type: brandedState.paymentType, + currency: 'USD', + donator_type: brandedState.donorType, + pays_processing: brandedState.isCreditCard ? brandedState.coverFees ? 'yes' : 'no' : undefined, + value: amountPaid, + processing_fee: brandedState.isCreditCard ? (item.amountWithFees - item.amount).toFixed(2) : undefined, + transaction_id: siebelTransactionId, + testing_transaction: Boolean(brandedState.testingTransaction), + items: [{ + item_id: item.designationNumber, + item_name: item.displayName, + item_brand: item.orgId, + item_category: item.designationType, + item_variant: item.frequency.toLowerCase(), + currency: 'USD', + price: amountPaid, + quantity: '1', + recurring_date: item.giftStartDate ? item.giftStartDate.format('MMMM D, YYYY') : undefined + }] + } +} + +function suppressErrors (func) { + return function wrapper (...args) { + try { + return func.apply(this, args) + } catch {} + } +} + +const brandedAnalyticsFactory = /* @ngInject */ function ($window) { + return { + saveCoverFees: suppressErrors(function (coverFees) { + brandedState.coverFees = coverFees + }), + + saveDonorDetails: suppressErrors(function (donorDetails) { + brandedState.donorType = donorDetails['donor-type'] + }), + + saveItem: suppressErrors(function (item) { + brandedState.item = item + }), + + savePaymentType: suppressErrors(function (paymentType, isCreditCard) { + brandedState.paymentType = paymentType + brandedState.isCreditCard = isCreditCard + }), + + savePurchase: suppressErrors(function (purchase) { + brandedState.purchase = purchase + }), + + saveTestingTransaction: suppressErrors(function (testingTransaction) { + brandedState.testingTransaction = testingTransaction + }), + + beginCheckout: suppressErrors(function (productData) { + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ ecommerce: null }) + $window.dataLayer.push({ + event: 'begin_checkout', + ecommerce: { + items: [{ + item_id: productData.designationNumber, + item_name: productData.displayName, + item_brand: productData.orgId, + item_category: productData.designationType, + currency: 'USD', + price: undefined, + quantity: '1' + }] + } + }) + }), + + // saveCoverFees, saveDonorDetails, saveItem, savePaymentType, and saveTestingTransaction should have been called before this + addPaymentInfo: suppressErrors(function () { + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ ecommerce: null }) + $window.dataLayer.push({ + event: 'add_payment_info', + ecommerce: generateEcommerce(undefined) + }) + }), + + reviewOrder: suppressErrors(function () { + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ ecommerce: null }) + $window.dataLayer.push({ + event: 'review_order' + }) + }), + + // saveCoverFees, saveDonorDetails, saveItem, savePaymentType, savePurchase, and saveTestingTransaction should have been called before this + purchase: suppressErrors(function () { + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ ecommerce: null }) + $window.dataLayer.push({ + event: 'purchase', + ecommerce: generateEcommerce(brandedState.purchase.rawData['purchase-number']) + }) + }), + + checkoutChange: suppressErrors(function (newStep) { + let optionChanged + switch (newStep) { + case 'contact': + optionChanged = 'contact info' + break + case 'cart': + optionChanged = 'gift' + break + case 'payment': + optionChanged = 'payment method' + break + } + + $window.dataLayer = $window.dataLayer || [] + $window.dataLayer.push({ + event: 'checkout_change_option', + checkout_option_changed: optionChanged + }) + }) + } +} + +export default angular + .module('analytics') + .factory(factoryName, brandedAnalyticsFactory) diff --git a/src/app/branded/analytics/branded-analytics.factory.spec.js b/src/app/branded/analytics/branded-analytics.factory.spec.js new file mode 100644 index 000000000..fcebc4ce6 --- /dev/null +++ b/src/app/branded/analytics/branded-analytics.factory.spec.js @@ -0,0 +1,524 @@ +import angular from 'angular' +import 'angular-mocks' +import moment from 'moment' + +import module from './branded-analytics.factory' + +const productData = { + designationNumber: '1234567', + displayName: 'Staff Person', + orgId: 'CRU', + designationType: 'STAFF' +} + +describe('branded analytics factory', () => { + beforeEach(angular.mock.module(module.name)) + + const self = {} + beforeEach(inject((brandedAnalyticsFactory, $window) => { + self.brandedAnalyticsFactory = brandedAnalyticsFactory + self.$window = $window + self.$window.dataLayer = [] + })) + + describe('beginCheckout', () => { + it('should silently ignore bad data', () => { + self.brandedAnalyticsFactory.beginCheckout() + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + ]) + }) + + it('should add begin_checkout event', () => { + self.brandedAnalyticsFactory.beginCheckout(productData) + + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + { + event: 'begin_checkout', + ecommerce: { + items: [{ + item_id: '1234567', + item_name: 'Staff Person', + item_brand: 'CRU', + item_category: 'STAFF', + currency: 'USD', + price: undefined, + quantity: '1' + }] + } + } + ]) + }) + }) + + describe('addPaymentInfo', () => { + beforeEach(() => { + self.brandedAnalyticsFactory.saveDonorDetails({ + 'donor-type': 'Household' + }) + self.brandedAnalyticsFactory.saveTestingTransaction(false) + self.brandedAnalyticsFactory.savePaymentType('Visa', true) + self.brandedAnalyticsFactory.saveCoverFees(false) + }) + + it('should silently ignore bad data', () => { + self.brandedAnalyticsFactory.addPaymentInfo() + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + ]) + }) + + it('should add add_payment_info event', () => { + self.brandedAnalyticsFactory.saveItem({ + amount: 100, + amountWithFees: 102.5, + frequency: 'Single', + giftStartDate: null, + ...productData + }) + self.brandedAnalyticsFactory.addPaymentInfo() + + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + { + event: 'add_payment_info', + ecommerce: { + payment_type: 'Visa', + currency: 'USD', + donator_type: 'Household', + pays_processing: 'no', + value: '100.00', + processing_fee: '2.50', + testing_transaction: false, + items: [{ + item_id: '1234567', + item_name: 'Staff Person', + item_brand: 'CRU', + item_category: 'STAFF', + item_variant: 'single', + currency: 'USD', + price: '100.00', + quantity: '1', + recurring_date: undefined + }] + } + } + ]) + }) + + it('with testing transaction should add add_payment_info event', () => { + self.brandedAnalyticsFactory.saveTestingTransaction(true) + self.brandedAnalyticsFactory.saveItem({ + amount: 100, + amountWithFees: 102.5, + frequency: 'Single', + giftStartDate: null, + ...productData + }) + self.brandedAnalyticsFactory.addPaymentInfo() + + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + { + event: 'add_payment_info', + ecommerce: { + payment_type: 'Visa', + currency: 'USD', + donator_type: 'Household', + pays_processing: 'no', + value: '100.00', + processing_fee: '2.50', + testing_transaction: true, + items: [{ + item_id: '1234567', + item_name: 'Staff Person', + item_brand: 'CRU', + item_category: 'STAFF', + item_variant: 'single', + currency: 'USD', + price: '100.00', + quantity: '1', + recurring_date: undefined + }] + } + } + ]) + }) + + it('with paying fees should add add_payment_info event', () => { + self.brandedAnalyticsFactory.saveCoverFees(true) + self.brandedAnalyticsFactory.saveItem({ + amount: 100, + amountWithFees: 102.5, + frequency: 'Single', + giftStartDate: null, + ...productData + }) + self.brandedAnalyticsFactory.addPaymentInfo() + + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + { + event: 'add_payment_info', + ecommerce: { + payment_type: 'Visa', + currency: 'USD', + donator_type: 'Household', + pays_processing: 'yes', + value: '102.50', + processing_fee: '2.50', + testing_transaction: false, + items: [{ + item_id: '1234567', + item_name: 'Staff Person', + item_brand: 'CRU', + item_category: 'STAFF', + item_variant: 'single', + currency: 'USD', + price: '102.50', + quantity: '1', + recurring_date: undefined + }] + } + } + ]) + }) + + it('with bank account should add add_payment_info event', () => { + self.brandedAnalyticsFactory.savePaymentType('Checking', false) + self.brandedAnalyticsFactory.saveItem({ + amount: 100, + amountWithFees: 102.5, + frequency: 'Single', + giftStartDate: null, + ...productData + }) + self.brandedAnalyticsFactory.addPaymentInfo() + + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + { + event: 'add_payment_info', + ecommerce: { + payment_type: 'Checking', + currency: 'USD', + donator_type: 'Household', + pays_processing: undefined, + value: '100.00', + processing_fee: undefined, + testing_transaction: false, + items: [{ + item_id: '1234567', + item_name: 'Staff Person', + item_brand: 'CRU', + item_category: 'STAFF', + item_variant: 'single', + currency: 'USD', + price: '100.00', + quantity: '1', + recurring_date: undefined + }] + } + } + ]) + }) + + it('with monthly gift should add add_payment_info event', () => { + self.brandedAnalyticsFactory.saveItem({ + amount: 100, + amountWithFees: 102.5, + frequency: 'Monthly', + giftStartDate: moment(new Date(2024, 0, 1)), + ...productData + }) + self.brandedAnalyticsFactory.addPaymentInfo() + + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + { + event: 'add_payment_info', + ecommerce: { + payment_type: 'Visa', + currency: 'USD', + donator_type: 'Household', + pays_processing: 'no', + value: '100.00', + processing_fee: '2.50', + testing_transaction: false, + items: [{ + item_id: '1234567', + item_name: 'Staff Person', + item_brand: 'CRU', + item_category: 'STAFF', + item_variant: 'monthly', + currency: 'USD', + price: '100.00', + quantity: '1', + recurring_date: 'January 1, 2024' + }] + } + } + ]) + }) + }) + + describe('reviewOrder', () => { + it('should add review_order event', () => { + self.brandedAnalyticsFactory.reviewOrder() + + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + { event: 'review_order' } + ]) + }) + }) + + describe('purchase', () => { + beforeEach(() => { + self.brandedAnalyticsFactory.saveDonorDetails({ + 'donor-type': 'Household' + }) + self.brandedAnalyticsFactory.saveTestingTransaction(false) + self.brandedAnalyticsFactory.savePaymentType('Visa', true) + self.brandedAnalyticsFactory.saveCoverFees(false) + self.brandedAnalyticsFactory.savePurchase({ + rawData: { + 'purchase-number': '12345' + } + }) + }) + + it('should silently ignore bad data', () => { + self.brandedAnalyticsFactory.savePurchase(undefined) + self.brandedAnalyticsFactory.purchase() + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + ]) + }) + + it('should add purchase event', () => { + self.brandedAnalyticsFactory.saveItem({ + amount: 100, + amountWithFees: 102.5, + frequency: 'Single', + giftStartDate: null, + ...productData + }) + self.brandedAnalyticsFactory.purchase() + + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + { + event: 'purchase', + ecommerce: { + payment_type: 'Visa', + currency: 'USD', + donator_type: 'Household', + pays_processing: 'no', + value: '100.00', + processing_fee: '2.50', + transaction_id: '12345', + testing_transaction: false, + items: [{ + item_id: '1234567', + item_name: 'Staff Person', + item_brand: 'CRU', + item_category: 'STAFF', + item_variant: 'single', + currency: 'USD', + price: '100.00', + quantity: '1', + recurring_date: undefined + }] + } + } + ]) + }) + + it('with testing transaction should add purchase event', () => { + self.brandedAnalyticsFactory.saveTestingTransaction(true) + self.brandedAnalyticsFactory.saveItem({ + amount: 100, + amountWithFees: 102.5, + frequency: 'Single', + giftStartDate: null, + ...productData + }) + self.brandedAnalyticsFactory.purchase() + + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + { + event: 'purchase', + ecommerce: { + payment_type: 'Visa', + currency: 'USD', + donator_type: 'Household', + pays_processing: 'no', + value: '100.00', + processing_fee: '2.50', + transaction_id: '12345', + testing_transaction: true, + items: [{ + item_id: '1234567', + item_name: 'Staff Person', + item_brand: 'CRU', + item_category: 'STAFF', + item_variant: 'single', + currency: 'USD', + price: '100.00', + quantity: '1', + recurring_date: undefined + }] + } + } + ]) + }) + + it('with paying fees should add purchase event', () => { + self.brandedAnalyticsFactory.saveCoverFees(true) + self.brandedAnalyticsFactory.saveItem({ + amount: 100, + amountWithFees: 102.5, + frequency: 'Single', + giftStartDate: null, + ...productData + }) + self.brandedAnalyticsFactory.purchase() + + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + { + event: 'purchase', + ecommerce: { + payment_type: 'Visa', + currency: 'USD', + donator_type: 'Household', + pays_processing: 'yes', + value: '102.50', + processing_fee: '2.50', + transaction_id: '12345', + testing_transaction: false, + items: [{ + item_id: '1234567', + item_name: 'Staff Person', + item_brand: 'CRU', + item_category: 'STAFF', + item_variant: 'single', + currency: 'USD', + price: '102.50', + quantity: '1', + recurring_date: undefined + }] + } + } + ]) + }) + + it('with bank account should add purchase event', () => { + self.brandedAnalyticsFactory.savePaymentType('Checking', false) + self.brandedAnalyticsFactory.saveItem({ + amount: 100, + amountWithFees: 102.5, + frequency: 'Single', + giftStartDate: null, + ...productData + }) + self.brandedAnalyticsFactory.purchase() + + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + { + event: 'purchase', + ecommerce: { + payment_type: 'Checking', + currency: 'USD', + donator_type: 'Household', + pays_processing: undefined, + value: '100.00', + processing_fee: undefined, + transaction_id: '12345', + testing_transaction: false, + items: [{ + item_id: '1234567', + item_name: 'Staff Person', + item_brand: 'CRU', + item_category: 'STAFF', + item_variant: 'single', + currency: 'USD', + price: '100.00', + quantity: '1', + recurring_date: undefined + }] + } + } + ]) + }) + + it('with monthly gift should add purchase event', () => { + self.brandedAnalyticsFactory.saveItem({ + amount: 100, + amountWithFees: 102.5, + frequency: 'Monthly', + giftStartDate: moment(new Date(2024, 0, 1)), + ...productData + }) + self.brandedAnalyticsFactory.purchase() + + expect(self.$window.dataLayer).toEqual([ + { ecommerce: null }, + { + event: 'purchase', + ecommerce: { + payment_type: 'Visa', + currency: 'USD', + donator_type: 'Household', + pays_processing: 'no', + value: '100.00', + processing_fee: '2.50', + transaction_id: '12345', + testing_transaction: false, + items: [{ + item_id: '1234567', + item_name: 'Staff Person', + item_brand: 'CRU', + item_category: 'STAFF', + item_variant: 'monthly', + currency: 'USD', + price: '100.00', + quantity: '1', + recurring_date: 'January 1, 2024' + }] + } + } + ]) + }) + }) + + describe('checkoutChange', () => { + it('contact should add review_order event', () => { + self.brandedAnalyticsFactory.checkoutChange('contact') + + expect(self.$window.dataLayer).toEqual([ + { event: 'checkout_change_option', checkout_option_changed: 'contact info' } + ]) + }) + + it('cart should add review_order event', () => { + self.brandedAnalyticsFactory.checkoutChange('cart') + + expect(self.$window.dataLayer).toEqual([ + { event: 'checkout_change_option', checkout_option_changed: 'gift' } + ]) + }) + + it('payment should add review_order event', () => { + self.brandedAnalyticsFactory.checkoutChange('payment') + + expect(self.$window.dataLayer).toEqual([ + { event: 'checkout_change_option', checkout_option_changed: 'payment method' } + ]) + }) + }) +}) diff --git a/src/app/branded/branded-checkout.component.js b/src/app/branded/branded-checkout.component.js index 250de988c..ecf6b8648 100644 --- a/src/app/branded/branded-checkout.component.js +++ b/src/app/branded/branded-checkout.component.js @@ -13,6 +13,7 @@ import thankYouSummary from 'app/thankYou/summary/thankYouSummary.component' import sessionService from 'common/services/session/session.service' import orderService from 'common/services/api/order.service' +import brandedAnalyticsFactory from './analytics/branded-analytics.factory' import 'common/lib/fakeLocalStorage' @@ -22,9 +23,11 @@ const componentName = 'brandedCheckout' class BrandedCheckoutController { /* @ngInject */ - constructor ($window, analyticsFactory, tsysService, sessionService, envService, orderService, $translate) { + constructor ($element, $window, analyticsFactory, brandedAnalyticsFactory, tsysService, sessionService, envService, orderService, $translate) { + this.$element = $element[0] // extract the DOM element from the jqLite wrapper this.$window = $window this.analyticsFactory = analyticsFactory + this.brandedAnalyticsFactory = brandedAnalyticsFactory this.tsysService = tsysService this.sessionService = sessionService this.envService = envService @@ -47,7 +50,12 @@ class BrandedCheckoutController { this.sessionService.signOut().subscribe(() => { this.checkoutStep = 'giftContactPayment' this.fireAnalyticsEvents('contact', 'payment') - }, angular.noop) + // Remove initialLoadComplete session storage. Used on src/common/components/contactInfo/contactInfo.component.js + // To prevent users who complete a gift and give again. + this.$window.sessionStorage.removeItem('initialLoadComplete') + }, (err) => { + console.error(err) + }) this.$translate.use(this.language || 'en') this.itemConfig = {} @@ -76,21 +84,55 @@ class BrandedCheckoutController { this.checkoutStep = 'thankYou' break } - this.$window.document.querySelector('branded-checkout').scrollIntoView({ behavior: 'smooth' }) + this.$element.scrollIntoView({ behavior: 'smooth' }) } - previous () { - switch (this.checkoutStep) { - case 'review': - this.fireAnalyticsEvents('contact', 'payment') - this.checkoutStep = 'giftContactPayment' - break + previous (newStep) { + let scrollElement + + if (this.checkoutStep === 'review') { + this.fireAnalyticsEvents('contact', 'payment') + this.checkoutStep = 'giftContactPayment' + + switch (newStep) { + case 'contact': + scrollElement = 'contact-info' + break + case 'cart': + scrollElement = 'product-config-form' + break + case 'payment': + scrollElement = 'checkout-step-2' + break + } + } + + if (scrollElement && this.$window.MutationObserver) { + // Watch for changes until the element we are scrolling to exists and everything has loaded + // because there will be layout shift every time a new component finishes loading + const observer = new this.$window.MutationObserver(() => { + // TODO: When support for :has() is high enough, this query could be changed to `.panel :has(${scrollElement})` + // instead of having to find the scrollElement and manually navigate up to the grandparent element + // https://caniuse.com/css-has + const element = this.$element.querySelector(scrollElement) + if (element && this.$element.querySelector('loading') === null) { + // Traverse up to the .panel grandparent + const panel = element.parentElement.parentElement + // Scroll 100px past the top of the element + this.$window.scrollTo({ top: panel.getBoundingClientRect().top + this.$window.scrollY - 100, behavior: 'smooth' }) + observer.disconnect() + } + }) + observer.observe(this.$element, { childList: true, subtree: true }) + } else { + this.$element.scrollIntoView({ behavior: 'smooth' }) } - this.$window.document.querySelector('branded-checkout').scrollIntoView({ behavior: 'smooth' }) } onThankYouPurchaseLoaded (purchase) { this.onOrderCompleted({ $event: { $window: this.$window, purchase: changeCaseObject.camelCase(pick(purchase, ['donorDetails', 'lineItems'])) } }) + this.brandedAnalyticsFactory.savePurchase(purchase) + this.brandedAnalyticsFactory.purchase() } onPaymentFailed (donorDetails) { @@ -112,6 +154,7 @@ export default angular thankYouSummary.name, sessionService.name, orderService.name, + brandedAnalyticsFactory.name, uibModal, 'environment', 'pascalprecht.translate' @@ -132,6 +175,8 @@ export default angular frequency: '@', day: '@', apiUrl: '@', + radioStationApiUrl: '@', + radioStationRadius: '@', premiumCode: '@', premiumName: '@', premiumImageUrl: '@', diff --git a/src/app/branded/branded-checkout.spec.js b/src/app/branded/branded-checkout.spec.js index aff7eb365..9f099e65c 100644 --- a/src/app/branded/branded-checkout.spec.js +++ b/src/app/branded/branded-checkout.spec.js @@ -10,15 +10,36 @@ describe('branded checkout', () => { beforeEach(angular.mock.module(module.name)) let $ctrl + const querySelectorMock = jest.fn((selector) => (selector === 'loading' ? null : element)) + const element = { + getBoundingClientRect: jest.fn(() => ({ top: 300 })), + querySelector: querySelectorMock, + scrollIntoView: scrollIntoViewMock + } + element.parentElement = element + beforeEach(inject($componentController => { $ctrl = $componentController( module.name, { + $element: [element], $window: { - document: { - querySelector: jest.fn(() => ({ scrollIntoView: scrollIntoViewMock })), + MutationObserver: jest.fn((callback) => ({ + observe: jest.fn(() => { + callback(); + }), + disconnect: jest.fn(), + })), + scrollY: 100, + scrollTo: jest.fn(), + sessionStorage: { + removeItem: jest.fn(), }, }, + brandedAnalyticsFactory: { + savePurchase: jest.fn(), + purchase: jest.fn() + }, tsysService: { setDevice: jest.fn(), }, @@ -51,6 +72,7 @@ describe('branded checkout', () => { expect($ctrl.tsysService.setDevice).toHaveBeenCalledWith('test-env') expect($ctrl.checkoutStep).toEqual('giftContactPayment') expect($ctrl.formatDonorDetails).toHaveBeenCalled() + expect($ctrl.$window.sessionStorage.removeItem).toHaveBeenCalledWith('initialLoadComplete') }) }) @@ -146,28 +168,58 @@ describe('branded checkout', () => { }) describe('previous', () => { - afterEach(() => { - expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth' }) + beforeEach(() => { + $ctrl.checkoutStep = 'review' }) it('should transition from review to giftContactPayment', () => { - $ctrl.checkoutStep = 'review' - $ctrl.previous() + $ctrl.previous('contact') + expect($ctrl.checkoutStep).toEqual('giftContactPayment') + expect($ctrl.$window.scrollTo).toHaveBeenCalledWith({ top: 300, behavior: 'smooth' }) + }) + + it('should scroll to the contact form when change contact info was clicked', () => { + $ctrl.previous('contact') + expect(querySelectorMock).toHaveBeenCalledWith('contact-info') + expect($ctrl.$window.scrollTo).toHaveBeenCalledWith({ top: 300, behavior: 'smooth' }) + }) + + it('should scroll to the contact form when change cart was clicked', () => { + $ctrl.previous('cart') + expect(querySelectorMock).toHaveBeenCalledWith('product-config-form') + expect($ctrl.$window.scrollTo).toHaveBeenCalledWith({ top: 300, behavior: 'smooth' }) + }) + + it('should scroll to the contact form when change payment was clicked', () => { + $ctrl.previous('payment') + expect(querySelectorMock).toHaveBeenCalledWith('checkout-step-2') + expect($ctrl.$window.scrollTo).toHaveBeenCalledWith({ top: 300, behavior: 'smooth' }) + }) + it('should scroll even when MutationObserver is unavailable', () => { + $ctrl.$window.MutationObserver = undefined + $ctrl.previous('contact') expect($ctrl.checkoutStep).toEqual('giftContactPayment') + expect(scrollIntoViewMock).toHaveBeenCalledWith({ behavior: 'smooth' }) }) }) describe('onThankYouPurchaseLoaded', () => { + const purchaseData = { + donorDetails: { + 'donor-type': 'Household' + }, + paymentInstruments: {}, + lineItems: {}, + rawData: {} + } + + beforeEach(() => { + $ctrl.onThankYouPurchaseLoaded() + }) + it('should pass the purchase info to the onOrderCompleted binding', () => { - $ctrl.onThankYouPurchaseLoaded({ - donorDetails: { - 'donor-type': 'Household' - }, - paymentMeans: {}, - lineItems: {}, - rawData: {} - }) + $ctrl.onThankYouPurchaseLoaded(purchaseData) expect($ctrl.onOrderCompleted).toHaveBeenCalledWith({ $event: { $window: $ctrl.$window, purchase: { @@ -177,6 +229,13 @@ describe('branded checkout', () => { lineItems: {} } } }) }) + + it('should call purchase', () => { + $ctrl.onThankYouPurchaseLoaded(purchaseData) + + expect($ctrl.brandedAnalyticsFactory.savePurchase).toHaveBeenCalledWith(purchaseData) + expect($ctrl.brandedAnalyticsFactory.purchase).toHaveBeenCalled() + }) }) describe('onPaymentFailed', () => { diff --git a/src/app/branded/branded-checkout.tpl.html b/src/app/branded/branded-checkout.tpl.html index 399ca6d54..bfe68a587 100644 --- a/src/app/branded/branded-checkout.tpl.html +++ b/src/app/branded/branded-checkout.tpl.html @@ -16,6 +16,8 @@ show-cover-fees="$ctrl.showCoverFees" next="$ctrl.next()" on-payment-failed="$ctrl.onPaymentFailed($event.donorDetails)" + radio-station-api-url="$ctrl.radioStationApiUrl" + radio-station-radius="$ctrl.radioStationRadius" premium-code="$ctrl.premiumCode" premium-name="$ctrl.premiumName" premium-image-url="$ctrl.premiumImageUrl" @@ -24,7 +26,7 @@ diff --git a/src/app/branded/step-1/branded-checkout-step-1.component.js b/src/app/branded/step-1/branded-checkout-step-1.component.js index b6eb7884c..33081d72d 100644 --- a/src/app/branded/step-1/branded-checkout-step-1.component.js +++ b/src/app/branded/step-1/branded-checkout-step-1.component.js @@ -7,6 +7,7 @@ import checkoutStep2 from 'app/checkout/step-2/step-2.component' import cartService from 'common/services/api/cart.service' import orderService from 'common/services/api/order.service' +import brandedAnalyticsFactory from '../../branded/analytics/branded-analytics.factory' import { FEE_DERIVATIVE } from 'common/components/paymentMethods/coverFees/coverFees.component' @@ -16,9 +17,10 @@ const componentName = 'brandedCheckoutStep1' class BrandedCheckoutStep1Controller { /* @ngInject */ - constructor ($log, $filter, cartService, orderService) { + constructor ($log, $filter, brandedAnalyticsFactory, cartService, orderService) { this.$log = $log this.$filter = $filter + this.brandedAnalyticsFactory = brandedAnalyticsFactory this.cartService = cartService this.orderService = orderService } @@ -33,13 +35,13 @@ class BrandedCheckoutStep1Controller { this.defaultItemConfig = angular.copy(this.itemConfig) this.itemConfig = {} - this.itemConfig['campaign-code'] = this.campaignCode - if (this.itemConfig['campaign-code'] && - (this.itemConfig['campaign-code'].match(/^[a-z0-9]+$/i) === null || this.itemConfig['campaign-code'].length > 30)) { - this.itemConfig['campaign-code'] = '' + this.itemConfig.CAMPAIGN_CODE = this.campaignCode + if (this.itemConfig.CAMPAIGN_CODE && + (this.itemConfig.CAMPAIGN_CODE.match(/^[a-z0-9]+$/i) === null || this.itemConfig.CAMPAIGN_CODE.length > 30)) { + this.itemConfig.CAMPAIGN_CODE = '' } this.itemConfig['campaign-page'] = this.campaignPage - this.itemConfig.amount = this.amount + this.itemConfig.AMOUNT = this.amount // These lines calculate the price with fees for amounts coming in from the client site via component config if (this.amount) { @@ -58,7 +60,7 @@ class BrandedCheckoutStep1Controller { this.defaultFrequency = 'ANNUAL' break } - this.itemConfig['recurring-day-of-month'] = this.day + this.itemConfig.RECURRING_DAY_OF_MONTH = this.day this.itemConfig.frequency = this.frequency this.premiumSelected = false @@ -134,6 +136,7 @@ class BrandedCheckoutStep1Controller { onContactInfoSubmit (success) { if (success) { this.submission.contactInfo.completed = true + this.brandedAnalyticsFactory.saveDonorDetails(this.donorDetails) } else { this.submission.contactInfo.completed = true this.submission.contactInfo.error = true @@ -182,7 +185,8 @@ export default angular contactInfo.name, checkoutStep2.name, cartService.name, - orderService.name + orderService.name, + brandedAnalyticsFactory.name ]) .component(componentName, { controller: BrandedCheckoutStep1Controller, @@ -200,6 +204,8 @@ export default angular showCoverFees: '<', next: '&', onPaymentFailed: '&', + radioStationApiUrl: '<', + radioStationRadius: '<', premiumCode: '<', premiumName: '<', premiumImageUrl: '<', diff --git a/src/app/branded/step-1/branded-checkout-step-1.spec.js b/src/app/branded/step-1/branded-checkout-step-1.spec.js index 40668d45b..343295260 100644 --- a/src/app/branded/step-1/branded-checkout-step-1.spec.js +++ b/src/app/branded/step-1/branded-checkout-step-1.spec.js @@ -39,11 +39,11 @@ describe('branded checkout step 1', () => { $ctrl.initItemConfig() expect($ctrl.itemConfig).toEqual({ - 'campaign-code': '1234', + CAMPAIGN_CODE: '1234', 'campaign-page': '135', - amount: '75', + AMOUNT: '75', priceWithFees: '$76.80', - 'recurring-day-of-month': '9' + 'RECURRING_DAY_OF_MONTH': '9' }) expect($ctrl.defaultFrequency).toBeUndefined() @@ -76,13 +76,13 @@ describe('branded checkout step 1', () => { it('should validate campaignCode (too long)', () => { $ctrl.campaignCode = 'abcdefghijklmnopqrstuvwxyz0123456789' $ctrl.initItemConfig() - expect($ctrl.itemConfig['campaign-code']).toEqual('') + expect($ctrl.itemConfig.CAMPAIGN_CODE).toEqual('') }) it('should validate campaignCode (non alpha numeric)', () => { $ctrl.campaignCode = '😅😳' $ctrl.initItemConfig() - expect($ctrl.itemConfig['campaign-code']).toEqual('') + expect($ctrl.itemConfig.CAMPAIGN_CODE).toEqual('') }) it('should persist premium-code in item config', () => { @@ -209,7 +209,11 @@ describe('branded checkout step 1', () => { describe('onContactInfoSubmit', () => { beforeEach(() => { jest.spyOn($ctrl, 'checkSuccessfulSubmission').mockImplementation(() => {}) + jest.spyOn($ctrl.brandedAnalyticsFactory, 'saveDonorDetails') $ctrl.resetSubmission() + $ctrl.donorDetails = { + 'donor-type': 'Household' + } }) it('should handle a successful submission', () => { @@ -221,6 +225,7 @@ describe('branded checkout step 1', () => { }) expect($ctrl.checkSuccessfulSubmission).toHaveBeenCalled() + expect($ctrl.brandedAnalyticsFactory.saveDonorDetails).toHaveBeenCalledWith($ctrl.donorDetails) }) it('should handle an error submitting', () => { @@ -232,6 +237,7 @@ describe('branded checkout step 1', () => { }) expect($ctrl.checkSuccessfulSubmission).toHaveBeenCalled() + expect($ctrl.brandedAnalyticsFactory.saveDonorDetails).not.toHaveBeenCalled() }) }) diff --git a/src/app/branded/step-1/branded-checkout-step-1.tpl.html b/src/app/branded/step-1/branded-checkout-step-1.tpl.html index a137e3106..dd929ddf8 100644 --- a/src/app/branded/step-1/branded-checkout-step-1.tpl.html +++ b/src/app/branded/step-1/branded-checkout-step-1.tpl.html @@ -22,19 +22,25 @@

{{'YOUR_INFORMATION'}}

- + +

{{'PAYMENT'}}

+ submitted="$ctrl.submitted" + on-state-change="$ctrl.onPaymentStateChange(state)" + mailing-address="$ctrl.donorDetails.mailingAddress" + default-payment-type="$ctrl.defaultPaymentType" + hide-payment-type-options="$ctrl.hidePaymentTypeOptions" + branded-checkout-item="$ctrl.showCoverFees === 'true' ? $ctrl.itemConfig : undefined">
diff --git a/src/app/branded/step-2/branded-checkout-step-2.component.js b/src/app/branded/step-2/branded-checkout-step-2.component.js index 648555696..9b53490fa 100644 --- a/src/app/branded/step-2/branded-checkout-step-2.component.js +++ b/src/app/branded/step-2/branded-checkout-step-2.component.js @@ -2,6 +2,8 @@ import angular from 'angular' import checkoutStep3 from 'app/checkout/step-3/step-3.component' import cartService from 'common/services/api/cart.service' +import orderService from '../../../common/services/api/order.service' +import brandedAnalyticsFactory from '../analytics/branded-analytics.factory' import template from './branded-checkout-step-2.tpl.html' @@ -9,20 +11,29 @@ const componentName = 'brandedCheckoutStep2' class BrandedCheckoutStep2Controller { /* @ngInject */ - constructor ($log, cartService) { + constructor ($log, brandedAnalyticsFactory, cartService, orderService) { this.$log = $log + this.brandedAnalyticsFactory = brandedAnalyticsFactory this.cartService = cartService + this.orderService = orderService } $onInit () { this.loadCart() + this.loadRadioStation() } loadCart () { this.errorLoadingCart = false this.cartService.get() .subscribe( - data => { this.cartData = data }, + data => { + this.cartData = data + this.brandedAnalyticsFactory.saveCoverFees(this.orderService.retrieveCoverFeeDecision()) + this.brandedAnalyticsFactory.saveItem(this.cartData.items[0]) + this.brandedAnalyticsFactory.addPaymentInfo() + this.brandedAnalyticsFactory.reviewOrder() + }, error => { this.errorLoadingCart = true this.$log.error('Error loading cart data for branded checkout step 2', error) @@ -30,11 +41,16 @@ class BrandedCheckoutStep2Controller { ) } + loadRadioStation () { + this.radioStationName = this.orderService.retrieveRadioStationName() + } + changeStep (newStep) { if (newStep === 'thankYou') { this.next() } else { - this.previous() + this.previous({ newStep }) + this.brandedAnalyticsFactory.checkoutChange(newStep) } } } @@ -42,7 +58,9 @@ class BrandedCheckoutStep2Controller { export default angular .module(componentName, [ checkoutStep3.name, - cartService.name + cartService.name, + orderService.name, + brandedAnalyticsFactory.name ]) .component(componentName, { controller: BrandedCheckoutStep2Controller, diff --git a/src/app/branded/step-2/branded-checkout-step-2.spec.js b/src/app/branded/step-2/branded-checkout-step-2.spec.js index d32701f15..aa5d92ac8 100644 --- a/src/app/branded/step-2/branded-checkout-step-2.spec.js +++ b/src/app/branded/step-2/branded-checkout-step-2.spec.js @@ -20,19 +20,34 @@ describe('branded checkout step 2', () => { describe('$onInit', () => { it('should load cart', () => { jest.spyOn($ctrl, 'loadCart').mockImplementation(() => {}) + jest.spyOn($ctrl, 'loadRadioStation').mockImplementation(() => {}) $ctrl.$onInit() expect($ctrl.loadCart).toHaveBeenCalled() + expect($ctrl.loadRadioStation).toHaveBeenCalled() }) }) describe('loadCart', () => { + beforeEach(() => { + jest.spyOn($ctrl.brandedAnalyticsFactory, 'saveCoverFees') + jest.spyOn($ctrl.brandedAnalyticsFactory, 'saveItem') + jest.spyOn($ctrl.brandedAnalyticsFactory, 'addPaymentInfo') + jest.spyOn($ctrl.brandedAnalyticsFactory, 'reviewOrder') + jest.spyOn($ctrl.orderService, 'retrieveCoverFeeDecision').mockReturnValue(true) + }) + it('should load cart data', () => { - jest.spyOn($ctrl.cartService, 'get').mockReturnValue(Observable.of('some data')) + const cartData = { items: [] } + jest.spyOn($ctrl.cartService, 'get').mockReturnValue(Observable.of(cartData)) $ctrl.loadCart() - expect($ctrl.cartData).toEqual('some data') + expect($ctrl.cartData).toEqual(cartData) expect($ctrl.errorLoadingCart).toEqual(false) + expect($ctrl.brandedAnalyticsFactory.saveCoverFees).toHaveBeenCalledWith(true) + expect($ctrl.brandedAnalyticsFactory.saveItem).toHaveBeenCalledWith($ctrl.cartData.items[0]) + expect($ctrl.brandedAnalyticsFactory.addPaymentInfo).toHaveBeenCalled() + expect($ctrl.brandedAnalyticsFactory.reviewOrder).toHaveBeenCalled() }) it('should handle error', () => { @@ -42,20 +57,39 @@ describe('branded checkout step 2', () => { expect($ctrl.cartData).toBeUndefined() expect($ctrl.errorLoadingCart).toEqual(true) expect($ctrl.$log.error.logs[0]).toEqual(['Error loading cart data for branded checkout step 2', 'some error']) + expect($ctrl.brandedAnalyticsFactory.addPaymentInfo).not.toHaveBeenCalled() + expect($ctrl.brandedAnalyticsFactory.reviewOrder).not.toHaveBeenCalled() + }) + }) + + describe('loadRadioStation', () => { + it('should load radio station name', () => { + jest.spyOn($ctrl.orderService, 'retrieveRadioStationName').mockReturnValue('some data') + $ctrl.loadRadioStation() + + expect($ctrl.radioStationName).toEqual('some data') }) }) describe('changeStep', () => { + beforeEach(() => { + jest.spyOn($ctrl.brandedAnalyticsFactory, 'checkoutChange') + jest.spyOn($ctrl.orderService, 'retrieveCoverFeeDecision').mockReturnValue(true) + }) + it('should call next if nextStep is thankYou', () => { + $ctrl.cartData = { items: [] } $ctrl.changeStep('thankYou') expect($ctrl.next).toHaveBeenCalled() + expect($ctrl.brandedAnalyticsFactory.checkoutChange).not.toHaveBeenCalled() }) it('should call previous otherwise', () => { $ctrl.changeStep('otherStep') expect($ctrl.previous).toHaveBeenCalled() + expect($ctrl.brandedAnalyticsFactory.checkoutChange).toHaveBeenCalledWith('otherStep') }) }) }) diff --git a/src/app/branded/step-2/branded-checkout-step-2.tpl.html b/src/app/branded/step-2/branded-checkout-step-2.tpl.html index 3972cbb94..2e89419e0 100644 --- a/src/app/branded/step-2/branded-checkout-step-2.tpl.html +++ b/src/app/branded/step-2/branded-checkout-step-2.tpl.html @@ -5,6 +5,7 @@

{{'REVIEW'}}

diff --git a/src/app/cart/cart.component.js b/src/app/cart/cart.component.js index fe5be23e8..9eef901ba 100644 --- a/src/app/cart/cart.component.js +++ b/src/app/cart/cart.component.js @@ -46,8 +46,18 @@ class CartController { } else { this.loading = true } + // Remember the order of the existing items in the cart + const orderByCode = this.cartData?.items?.map(item => item.code) || [] this.cartService.get() .subscribe(data => { + if (reload) { + // Sort the incoming cart to match the order of the previous cart, with new items at the top + // The code of recurring gifts have a suffix and look like 0123456_MON or 0123456_QUARTERLY. + // We will be able to maintain the order of items in the cart as long as the user doesn't + // change the frequency of a gift. The server prevents carts from containing multiple gifts + // with the same frequency and designation account, which would interfere with sorting. + data.items?.sort((item1, item2) => orderByCode.indexOf(item1.code) - orderByCode.indexOf(item2.code)) + } this.cartData = data this.setLoadCartVars(reload) }, @@ -97,13 +107,14 @@ class CartController { .configureProduct(item.code, item.config, true, item.uri) modal && modal.result .then(() => { - pull(this.cartData.items, item) this.loadCart(true) }, angular.noop) } checkout () { - this.$window.location = this.sessionService.getRole() === 'REGISTERED' ? '/checkout.html' : '/sign-in.html' + this.$window.location.href = this.sessionService.getRole() === 'REGISTERED' + ? `/checkout.html${this.$window.location.search}` + : `/sign-in.html${this.$window.location.search}` } setContinueBrowsingUrl () { diff --git a/src/app/cart/cart.component.spec.js b/src/app/cart/cart.component.spec.js index 275822bfa..ca14206fe 100644 --- a/src/app/cart/cart.component.spec.js +++ b/src/app/cart/cart.component.spec.js @@ -26,7 +26,10 @@ describe('cart', () => { getRole: () => 'REGISTERED' }, $window: { - location: '/cart.html' + location: { + href: '/cart.html', + search: '' + } }, $document: [{ referrer: '' @@ -70,6 +73,23 @@ describe('cart', () => { expect(self.controller.updating).toEqual(false) }) + it('should preserve the order', () => { + self.controller.cartService.get.mockReturnValue(Observable.of({ items: [{ code: '1' }, { code: '2' }, { code: '3' }] })) + self.controller.loadCart(true) + + self.controller.cartService.get.mockReturnValue(Observable.of({ items: [{ code: '3' }, { code: '2' }, { code: '1' }, { code: '4' }] })) + self.controller.loadCart(true) + + expect(self.controller.cartData).toEqual({ items: [{ code: '4' }, { code: '1' }, { code: '2' }, { code: '3' }] }) + }) + + it('should handle empty data', () => { + self.controller.cartService.get.mockReturnValue(Observable.of({})) + self.controller.loadCart(true) + + expect(self.controller.cartData).toEqual({}) + }) + it('should handle an error loading cart data', () => { self.controller.cartData = 'previous data' self.controller.cartService.get.mockReturnValue(Observable.throw('error')) @@ -175,7 +195,6 @@ describe('cart', () => { expect(self.controller.productModalService.configureProduct).toHaveBeenCalledWith('0123456', 'some config', true, 'uri1') expect(self.controller.loadCart).toHaveBeenCalledWith(true) - expect(self.controller.cartData.items).toEqual([{ uri: 'uri2' }]) }) }) @@ -183,11 +202,11 @@ describe('cart', () => { it('should return uri', () => { self.controller.checkout() - expect(self.controller.$window.location).toBe('/checkout.html') + expect(self.controller.$window.location.href).toBe('/checkout.html') self.controller.sessionService.getRole = () => 'foo' self.controller.checkout() - expect(self.controller.$window.location).toBe('/sign-in.html') + expect(self.controller.$window.location.href).toBe('/sign-in.html') }) }) diff --git a/src/app/cart/cart.tpl.html b/src/app/cart/cart.tpl.html index 7d8605774..c131d9dd3 100644 --- a/src/app/cart/cart.tpl.html +++ b/src/app/cart/cart.tpl.html @@ -9,7 +9,7 @@
-

Your Gift Cart

+

Your Gift Cart

@@ -28,7 +28,7 @@

Your Gift Cart

Your cart is empty

- +
@@ -39,8 +39,8 @@

Your Gift Cart

Gift
- - {{i.displayName}} + + {{i.displayName}} #{{i.designationNumber}} @@ -57,9 +57,9 @@

Your Gift Cart

Gift Amount
- Edit + Edit | - Remove + Remove