diff --git a/@kiva/kv-shop/package.json b/@kiva/kv-shop/package.json index 421d1352..1cc7cd62 100644 --- a/@kiva/kv-shop/package.json +++ b/@kiva/kv-shop/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "@apollo/client": "^3.7.14", + "@kiva/kv-analytics": "^1.1.1", "@kiva/kv-components": "^3.48.1", "@types/braintree-web-drop-in": "^1.34.2", "braintree-web-drop-in": "^1.37.0", diff --git a/@kiva/kv-shop/src/index.ts b/@kiva/kv-shop/src/index.ts index 8ee32aa3..b7a459e2 100644 --- a/@kiva/kv-shop/src/index.ts +++ b/@kiva/kv-shop/src/index.ts @@ -5,6 +5,7 @@ export * from './basketTotals'; export * from './basketVerification'; export * from './checkoutStatus'; export * from './oneTimeCheckout'; +export * from './receipt'; export * from './shopError'; export * from './shopQueries'; export * from './subscriptionCheckout'; diff --git a/@kiva/kv-shop/src/oneTimeCheckout.ts b/@kiva/kv-shop/src/oneTimeCheckout.ts index b649601a..0c1c62c5 100644 --- a/@kiva/kv-shop/src/oneTimeCheckout.ts +++ b/@kiva/kv-shop/src/oneTimeCheckout.ts @@ -1,12 +1,15 @@ import type { ApolloClient } from '@apollo/client/core'; import { gql } from '@apollo/client/core'; +import { trackTransaction } from '@kiva/kv-analytics'; import numeral from 'numeral'; import type { DropInWrapper } from './useBraintreeDropIn'; import { pollForFinishedCheckout } from './checkoutStatus'; import { ShopError, parseShopError } from './shopError'; import { callShopMutation, callShopQuery } from './shopQueries'; import { validatePreCheckout } from './validatePreCheckout'; +import { wait } from './util/poll'; import getVisitorID from './util/visitorId'; +import { getCheckoutTrackingData } from './receipt'; interface CreditAmountNeededData { shop: { @@ -95,11 +98,16 @@ interface DepositCheckoutOptions { amount: string, } +interface DepositCheckoutResult { + paymentType: string, + mutation: Promise, +} + async function depositCheckout({ apollo, braintree, amount, -}: DepositCheckoutOptions) { +}: DepositCheckoutOptions): Promise { try { const paymentMethod = await braintree.requestPaymentMethod(); if (!paymentMethod) { @@ -109,23 +117,44 @@ async function depositCheckout({ ); } - const { nonce, deviceData } = paymentMethod; - // TODO: need to also track paymentType from above - return callShopMutation(apollo, { - mutation: depositCheckoutMutation, - variables: { - nonce, - amount, - savePaymentMethod: false, // save payment methods handled by braintree drop in UI - deviceData, - visitorId: getVisitorID(), - }, - }, 0); + const { nonce, deviceData, type } = paymentMethod; + return { + paymentType: type, + mutation: callShopMutation(apollo, { + mutation: depositCheckoutMutation, + variables: { + nonce, + amount, + savePaymentMethod: false, // save payment methods handled by braintree drop in UI + deviceData, + visitorId: getVisitorID(), + }, + }, 0), + }; } catch (e) { throw parseShopError(e); } } +async function trackSuccess( + apollo: ApolloClient, + checkoutId: string, + paymentType: string, +) { + // get transaction data + const transactionData = await getCheckoutTrackingData( + apollo, + checkoutId, + paymentType, + ); + + // track transaction + trackTransaction(transactionData); + + // wait long enough for tracking to complete + await wait(800); +} + export interface OneTimeCheckoutOptions { apollo: ApolloClient, braintree?: DropInWrapper, @@ -155,11 +184,19 @@ export async function executeOneTimeCheckout({ } // initiate async checkout - const data = creditRequired ? await depositCheckout({ - apollo, - braintree, - amount: creditNeeded, - }) : await creditCheckout(apollo); + let data: CheckoutData; + let paymentType = ''; + if (creditRequired) { + const checkoutResult = await depositCheckout({ + apollo, + braintree, + amount: creditNeeded, + }); + paymentType = checkoutResult.paymentType; + data = await checkoutResult.mutation; + } else { + data = await creditCheckout(apollo); + } const transactionId = data?.shop?.transactionId; // wait on checkout to complete @@ -174,9 +211,12 @@ export async function executeOneTimeCheckout({ throw parseShopError(result.errors[0]); } + // track success + const checkoutId = result.data?.checkoutStatus?.receipt?.checkoutId; + await trackSuccess(apollo, checkoutId, paymentType); + // TODO: redirect needs to handle challenge completion parameters // redirect to thanks page - const checkoutId = result.data?.checkoutStatus?.receipt?.checkoutId; window.location.href = `/checkout/post-purchase?kiva_transaction_id=${checkoutId}`; } diff --git a/@kiva/kv-shop/src/receipt.ts b/@kiva/kv-shop/src/receipt.ts new file mode 100644 index 00000000..59dbc756 --- /dev/null +++ b/@kiva/kv-shop/src/receipt.ts @@ -0,0 +1,183 @@ +/* eslint-disable no-underscore-dangle */ +import type { ApolloClient, ApolloQueryResult } from '@apollo/client/core'; +import type { TransactionData } from '@kiva/kv-analytics'; +import { gql } from '@apollo/client/core'; +import getVisitorID from './util/visitorId'; + +export async function getFTDStatus(apollo: ApolloClient) { + const result = await apollo.query({ + query: gql` + query ftdStatus { + my { + id + userAccount { + id + isFirstTimeDepositor + } + } + } + `, + }); + return result.data?.my?.userAccount?.isFirstTimeDepositor ?? false; +} + +interface ReceiptItem { + id: string, + price: string, + __typename: string, + isTip?: boolean, + isUserEdited?: boolean, +} + +interface ReceiptItemsData { + shop: { + id: string, + receipt: { + id: string, + items: { + totalCount: number, + values: ReceiptItem[], + } | null, + } | null, + } | null, +} + +export async function getReceiptItems(apollo: ApolloClient, checkoutId: string): Promise { + return new Promise((resolve, reject) => { + const limit = 100; + let offset = 0; + const observer = apollo.watchQuery({ + query: gql` + query receiptItems($checkoutId: String, $visitorId: String, $limit: Int, $offset: Int) { + shop { + id + receipt(checkoutId: $checkoutId, visitorId: $visitorId) { + id + items(limit: $limit, offset: $offset) { + totalCount + values { + id + price + __typename + + ... on Donation { + id + isTip + isUserEdited + } + } + } + } + } + } + `, + variables: { + checkoutId, + visitorId: getVisitorID(), + limit, + offset, + }, + }); + + let items: ReceiptItem[] = []; + const handleResult = async (result: ApolloQueryResult) => { + const total = result.data?.shop?.receipt?.items?.totalCount; + items = items.concat(result.data?.shop?.receipt?.items?.values); + if (total > offset + limit) { + offset += limit; + const nextResult = await observer.fetchMore({ + variables: { + offset, + }, + }); + try { + handleResult(nextResult); + } catch (e) { + reject(e); + } + } else { + resolve(items); + } + }; + + observer.subscribe({ + next: handleResult, + error: (error) => { + reject(error); + }, + }); + }); +} + +export async function getReceiptTotals(apollo: ApolloClient, checkoutId: string) { + const result = await apollo.query({ + query: gql` + query receiptTotals($checkoutId: Int, $visitorId: String) { + shop { + id + receipt(checkoutId: $checkoutId, visitorId: $visitorId) { + id + totals { + loanReservationTotal + donationTotal + kivaCardTotal + itemTotal + kivaCreditAppliedTotal + depositTotals { + depositTotal + } + } + } + } + } + `, + variables: { + checkoutId, + visitorId: getVisitorID(), + }, + }); + return result.data?.shop?.receipt?.totals; +} + +export async function getCheckoutTrackingData( + apollo: ApolloClient, + checkoutId: string, + paymentType: string, +): Promise { + const [isFTD, items, totals] = await Promise.all([ + getFTDStatus(apollo), + getReceiptItems(apollo, checkoutId), + getReceiptTotals(apollo, checkoutId), + ]); + + const loans = items.filter((item) => item.__typename === 'LoanReservation'); + const donations = items.filter((item) => item.__typename === 'Donation'); + const kivaCards = items.filter((item) => item.__typename === 'KivaCard'); + + return { + transactionId: checkoutId, + itemTotal: totals.itemTotal, + + // Loan reservations + loans, + loanCount: loans.length, + loanTotal: totals.loanReservationTotal, + + // Donations + donations: donations.map(({ id, price, __typename }) => ({ id, price, __typename })), + donationTotal: totals.donationTotal, + isTip: donations.every((donation) => donation.isTip), + isUserEdited: donations.some((donation) => donation.isUserEdited), + + // Kiva Cards + kivaCards, + kivaCardCount: kivaCards.length, + kivaCardTotal: totals.kivaCardTotal, + + // Credit & deposit + kivaCreditAppliedTotal: totals.kivaCreditAppliedTotal, + depositTotal: totals.depositTotals?.depositTotal ?? '0.00', + paymentType, + isFTD, + }; +} diff --git a/package-lock.json b/package-lock.json index bcf7a5de..bcfd4df9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2738,6 +2738,7 @@ "version": "1.5.2", "dependencies": { "@apollo/client": "^3.7.14", + "@kiva/kv-analytics": "^1.1.1", "@kiva/kv-components": "^3.48.1", "@types/braintree-web-drop-in": "^1.34.2", "braintree-web-drop-in": "^1.37.0", @@ -53261,6 +53262,7 @@ "version": "file:@kiva/kv-shop", "requires": { "@apollo/client": "^3.7.14", + "@kiva/kv-analytics": "*", "@kiva/kv-components": "^3.48.1", "@types/braintree-web-drop-in": "^1.34.2", "@typescript-eslint/eslint-plugin": "^5.59.7",