diff --git a/.snyk b/.snyk index d72b2da740..0f011d33fc 100644 --- a/.snyk +++ b/.snyk @@ -4,6 +4,6 @@ version: v1.12.0 ignore: 'npm:chownr:20180731': - '*': - reason: Waiting on next-routes(https://github.com/fridays/next-routes) to fix this vulnerability + reason: This vulnerability only affects packages used in development, such as webpack, more details at https://github.com/isaacs/chownr/issues/14#issuecomment-421662375 expires: 2018-12-30T00:00:00.000Z patch: {} diff --git a/src/components/CheckoutActions/CheckoutActions.js b/src/components/CheckoutActions/CheckoutActions.js index 28443f1745..d959f0d95e 100644 --- a/src/components/CheckoutActions/CheckoutActions.js +++ b/src/components/CheckoutActions/CheckoutActions.js @@ -11,14 +11,26 @@ import withPlaceStripeOrder from "containers/order/withPlaceStripeOrder"; import Dialog from "@material-ui/core/Dialog"; import PageLoading from "components/PageLoading"; import { Router } from "routes"; +import track from "lib/tracking/track"; +import TRACKING from "lib/tracking/constants"; +import trackCheckout from "lib/tracking/trackCheckout"; +import trackCheckoutStep from "lib/tracking/trackCheckoutStep"; import { adaptAddressToFormFields, isShippingAddressSet } from "lib/utils/cartUtils"; +const { + CHECKOUT_STARTED, + CHECKOUT_STEP_COMPLETED, + CHECKOUT_STEP_VIEWED, + PAYMENT_INFO_ENTERED +} = TRACKING; + @withCart @withPlaceStripeOrder @inject("authStore") +@track() @observer export default class CheckoutActions extends Component { static propTypes = { @@ -43,13 +55,72 @@ export default class CheckoutActions extends Component { isPlacingOrder: false } - setShippingAddress = (address) => { + componentDidMount() { + const { cart } = this.props; + // Track start of checkout process + this.trackCheckoutStarted({ cart, action: CHECKOUT_STARTED }); + + const { checkout: { fulfillmentGroups } } = this.props.cart; + const hasShippingAddress = isShippingAddressSet(fulfillmentGroups); + // Track the first step, "Enter a shipping address" when the page renders, + // as it will be expanded by default, only record this event when the + // shipping address has not yet been set. + if (!hasShippingAddress) { + this.trackAction(this.buildData({ action: CHECKOUT_STEP_VIEWED, step: 1 })); + } + } + + @trackCheckout() + trackCheckoutStarted() {} + + @trackCheckoutStep() + trackAction() {} + + buildData = (data) => { + const { step, shipping_method = null, payment_method = null, action } = data; // eslint-disable-line camelcase + + return { + action, + payment_method, // eslint-disable-line camelcase + shipping_method, // eslint-disable-line camelcase + step + }; + } + + get shippingMethod() { + const { checkout: { fulfillmentGroups } } = this.props.cart; + const shippingMethod = fulfillmentGroups[0].selectedFulfillmentOption.fulfillmentMethod.displayName; + + return shippingMethod; + } + + get paymentMethod() { + const { stripeToken: { token: { card } } } = this.props.cartStore; + return card.brand; + } + + setShippingAddress = async (address) => { const { checkoutMutations: { onSetShippingAddress } } = this.props; - return onSetShippingAddress(address); + // Omit firstName, lastName props as they are not in AddressInput type + // The address form and GraphQL endpoint need to be made consistent + const { firstName, lastName, ...rest } = address; + const { data, error } = await onSetShippingAddress({ + fullName: `${address.firstName} ${address.lastName}`, + ...rest + }); + + + if (data && !error) { + // track successfully setting a shipping address + this.trackAction(this.buildData({ action: CHECKOUT_STEP_COMPLETED, step: 1 })); + + // The next step will automatically be expanded, so lets track that + this.trackAction(this.buildData({ action: CHECKOUT_STEP_VIEWED, step: 2 })); + } } - setShippingMethod = (shippingMethod) => { + setShippingMethod = async (shippingMethod) => { const { checkoutMutations: { onSetFulfillmentOption } } = this.props; const { checkout: { fulfillmentGroups } } = this.props.cart; const fulfillmentOption = { @@ -57,7 +128,24 @@ export default class CheckoutActions extends Component { fulfillmentMethodId: shippingMethod.selectedFulfillmentOption.fulfillmentMethod._id }; - return onSetFulfillmentOption(fulfillmentOption); + const { data, error } = await onSetFulfillmentOption(fulfillmentOption); + if (data && !error) { + // track successfully setting a shipping method + this.trackAction({ + step: 2, + shipping_method: this.shippingMethod, // eslint-disable-line camelcase + payment_method: null, // eslint-disable-line camelcase + action: CHECKOUT_STEP_COMPLETED + }); + + // The next step will automatically be expanded, so lets track that + this.trackAction({ + step: 3, + shipping_method: this.shippingMethod, // eslint-disable-line camelcase + payment_method: null, // eslint-disable-line camelcase + action: CHECKOUT_STEP_VIEWED + }); + } } setPaymentMethod = (stripeToken) => { @@ -65,6 +153,22 @@ export default class CheckoutActions extends Component { // Store stripe token in MobX store cartStore.setStripeToken(stripeToken); + + // Track successfully setting a payment method + this.trackAction({ + step: 3, + shipping_method: this.shippingMethod, // eslint-disable-line camelcase + payment_method: this.paymentMethod, // eslint-disable-line camelcase + action: PAYMENT_INFO_ENTERED + }); + + // The next step will automatically be expanded, so lets track that + this.trackAction({ + step: 4, + shipping_method: this.shippingMethod, // eslint-disable-line camelcase + payment_method: this.paymentMethod, // eslint-disable-line camelcase + action: CHECKOUT_STEP_VIEWED + }); } buildOrder = async () => { @@ -111,6 +215,13 @@ export default class CheckoutActions extends Component { if (data && !error) { const { placeOrderWithStripeCardPayment: { orders, token } } = data; + this.trackAction({ + step: 4, + shipping_method: this.shippingMethod, // eslint-disable-line camelcase + payment_method: this.paymentMethod, // eslint-disable-line camelcase + action: CHECKOUT_STEP_COMPLETED + }); + // Clear anonymous cart if (!authStore.isAuthenticated) { cartStore.clearAnonymousCartCredentials(); diff --git a/src/containers/cart/fragments.gql b/src/containers/cart/fragments.gql index 40c9f881cd..fd6d49c258 100644 --- a/src/containers/cart/fragments.gql +++ b/src/containers/cart/fragments.gql @@ -69,9 +69,11 @@ fragment CartCommon on Cart { displayAmount } itemTotal { + amount displayAmount } taxTotal { + amount displayAmount } total { diff --git a/src/containers/cart/withCart.js b/src/containers/cart/withCart.js index e2a7322dcb..b045b2f4fd 100644 --- a/src/containers/cart/withCart.js +++ b/src/containers/cart/withCart.js @@ -320,10 +320,10 @@ export default function withCart(Component) { * @param {Function} mutation An Apollo mutation function * @return {undefined} No return */ - handleSetFulfillmentOption = async ({ fulfillmentGroupId, fulfillmentMethodId }) => { + handleSetFulfillmentOption = ({ fulfillmentGroupId, fulfillmentMethodId }) => { const { client: apolloClient } = this.props; - await apolloClient.mutate({ + return apolloClient.mutate({ mutation: setFulfillmentOptionCartMutation, variables: { input: { @@ -345,7 +345,7 @@ export default function withCart(Component) { handleSetShippingAddress = async (address) => { const { client: apolloClient } = this.props; - const result = await apolloClient.mutate({ + const response = await apolloClient.mutate({ mutation: setShippingAddressCartMutation, variables: { input: { @@ -356,8 +356,10 @@ export default function withCart(Component) { }); // Update fulfillment options for current cart - const { data: { setShippingAddressOnCart: { cart } } } = result; - await this.handleUpdateFulfillmentOptionsForGroup(cart.checkout.fulfillmentGroups[0]._id); + const { data: { setShippingAddressOnCart: { cart } } } = response; + this.handleUpdateFulfillmentOptionsForGroup(cart.checkout.fulfillmentGroups[0]._id); + + return response; } render() { diff --git a/src/lib/tracking/constants.js b/src/lib/tracking/constants.js index 2e28dd82e0..7a5782064f 100644 --- a/src/lib/tracking/constants.js +++ b/src/lib/tracking/constants.js @@ -1,6 +1,12 @@ export default { CART_VIEWED: "Cart Viewed", - PRODUCT_VIEWED: "Product Viewed", + CHECKOUT_STARTED: "Checkout Started", + CHECKOUT_STEP_VIEWED: "Checkout Step Viewed", + CHECKOUT_STEP_COMPLETED: "Checkout Step Completed", + ORDER_COMPLETED: "Order Completed", + ORDER_UPDATED: "Order Updated", + PAYMENT_INFO_ENTERED: "Payment Info Entered", PRODUCT_ADDED: "Product Added", - PRODUCT_REMOVED: "Product Removed" + PRODUCT_REMOVED: "Product Removed", + PRODUCT_VIEWED: "Product Viewed" }; diff --git a/src/lib/tracking/trackCheckout.js b/src/lib/tracking/trackCheckout.js new file mode 100644 index 0000000000..7575dafb6c --- /dev/null +++ b/src/lib/tracking/trackCheckout.js @@ -0,0 +1,30 @@ +import track from "./track"; +import getCartItemTrackingData from "./utils/getCartItemTrackingData"; + +/** + * trackCheckout HOC tracks the "Checkout Started" event + * @name trackCheckout + * @param {Object} options options to supply to tracking HOC + * @returns {React.Component} - component + */ +export default (options) => + // eslint-disable-next-line no-unused-vars + track(({ router }, state, functionArgs) => { + const { cart, action } = (functionArgs && functionArgs[0]) || []; + const { checkout: { summary }, items, shop } = cart; + const products = []; + + items.forEach((item) => { + products.push(getCartItemTrackingData(item)); + }); + + return { + action, + value: summary.itemTotal.amount, + revenue: summary.itemTotal.amount, + shipping: summary.fulfillmentTotal, + tax: summary.taxTotal, + currency: shop.currency.code, + products + }; + }, options); diff --git a/src/lib/tracking/trackCheckoutStep.js b/src/lib/tracking/trackCheckoutStep.js new file mode 100644 index 0000000000..23298b9264 --- /dev/null +++ b/src/lib/tracking/trackCheckoutStep.js @@ -0,0 +1,26 @@ +import track from "./track"; + +/** + * trackCheckoutStep HOC tracks the "Checkout Step Viewed | Completed" events + * @name trackCheckoutStep + * @param {Object} options options to supply to tracking HOC + * @returns {React.Component} - component + */ +export default (options) => + // eslint-disable-next-line no-unused-vars + track(({ router }, state, functionArgs) => { + const { + action, + payment_method, // eslint-disable-line camelcase + shipping_method, // eslint-disable-line camelcase + step + } = (functionArgs && functionArgs[0]) || []; + + return { + action, + payment_method, // eslint-disable-line camelcase + shipping_method, // eslint-disable-line camelcase + step + + }; + }, options); diff --git a/src/pages/checkout.js b/src/pages/checkout.js index 7d6e0321c2..1ad4bda9df 100644 --- a/src/pages/checkout.js +++ b/src/pages/checkout.js @@ -18,6 +18,7 @@ import LockIcon from "mdi-material-ui/Lock"; import withCart from "containers/cart/withCart"; import Link from "components/Link"; import CheckoutSummary from "components/CheckoutSummary"; +import PageLoading from "components/PageLoading"; const styles = (theme) => ({ checkoutActions: { @@ -171,18 +172,22 @@ class Checkout extends Component { * @return {undefined} */ handleRouteChange = () => { - const { cart, router: { asPath } } = this.props; + const { cart } = this.props; // Skipping if the `cart` is not available if (!cart) return; - if (hasIdentityCheck(cart) && asPath === "/cart/login") { + if (hasIdentityCheck(cart) && this.pagePath === "/cart/login") { Router.replaceRoute("/cart/checkout", {}, { shallow: true }); - } else if (!hasIdentityCheck(cart) && asPath === "/cart/checkout") { + } else if (!hasIdentityCheck(cart) && this.asPath === "/cart/checkout") { Router.replaceRoute("/cart/login", {}, { shallow: true }); } }; handleCartEmptyClick = () => Router.pushRoute("/"); + get pagePath() { + return this.props.router.asPath; + } + /** * * @name hasIdentity @@ -305,6 +310,7 @@ class Checkout extends Component { const hasAccount = !!cart.account; const displayEmail = (hasAccount && Array.isArray(cart.account.emailRecords) && cart.account.emailRecords[0].address) || cart.email; + return (
@@ -339,6 +345,9 @@ class Checkout extends Component { } render() { + const { isLoading, cart } = this.props; + if (isLoading || !cart) return ; + return ( {this.renderCheckoutHead()}