Tech refinement - Add offer Combo/Picker to the cart

Table of Contents


More details here: Promo code at checkout page X config offer type


PoC working example

  • Important: Please disregard the delay when adding the offer. This is a PoC example. This needs to be improved in the final solution.

Combo working example.mp4

 

Tasks breakdown

  • Main file to be changed: src/state/loyalty/hooks/use-redeem-promo-code-checkout.ts

  • PoC branch: poc/apply-multiple-offer-types-checkout

    • The only needed changes will be at:

      • src/state/loyalty/hooks/use-redeem-promo-code-checkout.ts

      • src/hooks/menu/use-product-price.ts

Task 1 - Create a new method responsible to add other types of offer

  • Get the whole Picker/Combo from Config Offers incentives

    • Option 1: adjust and extend the config-offer.graphql query to get all the Combo/Picker

    • Option 2: with the return id from the Config Offers incentives item, make two new queries (new method) to get the item data:

      • If combo:

        • GetComboDocument

        • GetComboAvailabilityDocument

      • If Picker:

        • GetPickerDocument

        • GetPickerAvailabilityDocument

      • One option is to use the Promise.allto run the two queries and then merge the results of the queries using the mergefrom lodash. This solution is based on what the menu method queryMenuData (src/state/menu-options/legacy-menu-options.tsx) does. We can also transform this function into a utils one to reuse.

  • Use ProductWizardUtils.ComputeSelectedOption to transform the itens correctly. This is important for Picker. The method will convert the Picker to a regular item

  • Use the ProductWizardUtils.SetDefaultSelections to get the default selections

  • Use the transformMenuObjectToCartItem to convert the Combo/Picker (the data that will come from Option1 or Option 2)

  • Update the Redux state using the dispatch and the actions.loyalty.applyOffer

  • Use the totalPrice method (src/hooks/menu/use-product-price.ts) to calculate the correct price ----> We had a problem here and I was able to fix the code (suggestion on the PoC code example below). The value always needs to be correct (see PoC code example for more details and tips)

    • If we have a vendor config the value should be from there

    • If we don’t have a vendor config the value should be the offer price (offer config content)

    • If we don’t have any value at all then we need to look at the vendor config for the item

  • Adjust the loading state to only finish when we in fact add the new item into the cart

  • Use the upsertCart to add the transformed item to the cart

  • We need also to ensure

    • If the offer from incentives is type Item, Swap or Offer Discount we need to return an invalid promo code error. We validated this with @Lopes da Costa, Valentina and she said that this types of offer are not supported today (they come from the legacy US code)

 

PoC code example and mentions

  • The code below should not be the final solution

  • The objective of the PoC is to prove an idea. I didn't make the code semantic or beautiful. This will be a responsibility for the dev

  • We should create new methods and reuse the code at maximum (see Suggestion of implementation below)

PoC code is inside this collapse:

  • use-redeem-promo-code-checkout.ts path of solution changes:

// this is a method from /src/state/menu-options/legacy-menu-options.tsx const getItemIdAndType = useCallback((id: string) => { const [type, ...idSplit] = id.split('-'); const itemID = idSplit.join('-'); return { type, itemID }; }, []); // this is a method from /src/state/menu-options/legacy-menu-options.tsx const queryMenuData = useCallback( async ( menuItemId: string, menuItemType: string, dataQuery: DocumentNode, availabilityQuery: DocumentNode ) => { const { type } = getItemIdAndType(`${menuItemType}-` + menuItemId); const result = await Promise.all([ client.query({ fetchPolicy: 'no-cache', context: { uri }, query: dataQuery, variables: { id: menuItemId, }, }), client.query({ fetchPolicy: 'no-cache', context: { uri }, query: availabilityQuery, variables: { id: menuItemId, }, }), ]); const { data: rawData, loading } = result.reduce( (finalResponse, res) => { const capitalizedType = type[0].toUpperCase() + type.slice(1); const data = res?.data; return { data: data?.[capitalizedType] ? [...finalResponse.data, data[capitalizedType]] : finalResponse.data, loading: res.loading || finalResponse.loading, }; }, { data: [], loading: false } ); const [uiData, availability] = rawData; const eitinha = merge(uiData, availability); return eitinha; }, [client, getItemIdAndType, uri] ); // this method was adjusted const applyOfferAtCheckout = useCallback( async (offer: LoyaltyOffer, standardOffersLimit: number) => { try { if (appliedOffers?.length >= standardOffersLimit) { removeFromCart({ cartId: appliedOffers[0].cartId }); } if (isDiscountLoyaltyOffer(offer)) { dispatch(actions.loyalty.setCmsOffers([offer])); dispatch(actions.loyalty.setSelectedOffer(offer)); dispatch(actions.loyalty.setPersonalizedOffers([offer])); dispatch(actions.loyalty.unshiftPersonalizedOffer(offer)); dispatch(actions.loyalty.setAppliedOffers([offer])); // Discount offers should not show menu item details dispatch( actions.loyalty.applyOffer({ id: offer.loyaltyEngineId, type: OfferType.PERSONALIZED, isStackable: offer.isStackable, isSurprise: isSurpriseOffer(offer), cmsId: offer._id, cartId: 'discount-offer', }) ); } if (!isDiscountLoyaltyOffer(offer) && offer?.incentives?.length && offer?.incentives[0]) { // Dispatching to select the offer. The legacy flow only does this. The other dispatches // aren't necessary. dispatch(actions.loyalty.setSelectedOffer(offer)); // getting only the first incentive item for PoC purposes. This needs to be fixed at task 3 const offerItemIncentive = offer.incentives[0]; const offerItemType = offerItemIncentive._type; // Swap items don't have an id. We need to ensure this to not get a Typescript error const offerItemId = offerItemIncentive.__typename === 'Combo' || offerItemIncentive.__typename === 'Picker' ? offerItemIncentive._id : ''; // We need to check what document we'll need to use (Combo/Picker) const itemQueryDoc = offerItemIncentive.__typename === 'Combo' ? GetComboDocument : GetPickerDocument; const itemAvailabilityQueryDoc = offerItemIncentive.__typename === 'Combo' ? GetComboAvailabilityDocument : GetPickerAvailabilityDocument; // Get all the needed menu data const offerItemData = await queryMenuData( offerItemId, offerItemType, itemQueryDoc, itemAvailabilityQueryDoc ); // Compute the selected option as the menu page does. This is important because // for Picker items the item will be converted to Item type const computedOfferItem = ProductWizardUtils.ComputeSelectedOption(offerItemData); // Set default selections as the menu page does. This is important. Without this // the sub-items will not be shown const defaultSelections = ProductWizardUtils.SetDefaultSelections({ menuObject: computedOfferItem, }); // We need to convert the menu object from the query to a cart item valid structure. This // is important. We can't use a Sanity menu object directly const { mainEntry, upsellEntries, modifiersInjected } = transformMenuObjectToCartItem( computedOfferItem, priceForItemOptionModifier, pricingFunction, priceForComboSlotSelection, { quantity: 1, // The offer should be 1:1 always. This could will be iterable (Task 3) modifierSelections: transformUserSelectionsModifierToModifierSelection(defaultSelections), pickerSelections: defaultSelections.pickerAspects, comboSlotSelections: transformUserSelectionsComboToComboSlotSelections( defaultSelections.comboSlot, computedOfferItem as ICombo ), location, existingCartId: editingCartEntry?.cartId, } ); // We need to pass new params to totalPrice method to avoid problems with the price calculator // All price-related problems will have relation with this method const isOfferBenefit = incentiveIsOfferBenefit(offer); const offerPrice = totalPrice(modifiersInjected, computedOfferItem, isOfferBenefit, offer); // Dispatching to apply the offer dispatch( actions.loyalty.applyOffer({ id: offer.loyaltyEngineId, type: (offer as LoyaltyAppliedOffer).type, cartId: mainEntry?.cartId, isStackable: offer.isStackable, isSurprise: isSurpriseOffer(offer), cmsId: offer._id, }) ); dispatch(actions.loyalty.setSelectedOffer(null)); // Adds the item on the cart upsertCart([ { ...mainEntry, price: offerPrice, offerVendorConfigs: offer.vendorConfigs, ...(mainEntry && { type: mapEntryTypeToOfferType(mainEntry.type) }), }, ...upsellEntries, ]); } queryLoyaltyUserStandardOffers({ loyaltyId: user?.loyaltyId || '' }); refreshLoyaltyUserStandardOffers(); } catch (e: any) { trackEvent(createRbiApplyPromoCodeEvent(offer.loyaltyEngineId!, 'Failed', e.message)); } }, [ appliedOffers, dispatch, editingCartEntry?.cartId, location, priceForComboSlotSelection, priceForItemOptionModifier, pricingFunction, queryLoyaltyUserStandardOffers, queryMenuData, refreshLoyaltyUserStandardOffers, removeFromCart, totalPrice, trackEvent, upsertCart, user?.loyaltyId, ] );

 

Tips to solve the problem related to the price calculation (only Picker has problem with the PoC code):

Option 1:

Changes to totalPrice method to calculate correctly the price (suggestion to try)

  • PS: this problem is not 100% solved but this can be a path to solution

  • File: /src/hooks/menu/use-product-price.ts

  • Extend this method to receive two new optional props (suggestion. Feel free to name as you want):

    • selectedProduct: an specific product to be calculated (without the relationship with the select product from menu as we are not in the menu)

    • isOfferBenefit: here we can pass directly to the method if our implementation is an offer or not. The legacy code for this is also tied to the menu flow (as we are not in the menu)

    • selectedOffer: from what I detected the Redux is not getting the correct status for the selector. One option is to pass directly through the method the desired offer to be calculated. This appears to solve the problem

    • As the props are optional this will not cause any problem to the legacy code

Option 2:

To avoid the “Redux timming” we can separate better our methods and only use the upsertCart if we get with success the value from selectors.loyalty.selectSelectedOffer. As this state from the Redux is the same used at the totalPrice method perhaps this will allow the correct calculation without the need to change the totalPrice implementation.

 

Suggestion of implementation

  • I extracted part of the logic (showed in the PoC code) from the useProductCart hook (frontend/src/components/product-detail/use-product-cart.ts) in the addToOrder and handleOffers methods. We can create new methods for isolating the desired logics on that hook and reuse them in our implementation. This will improve the reuse of code and its maintainability.

  • I also extracted some part of the logic from legacy-menu-options.tsx file. This should be an utils or something like that, and then, we can reuse this piece of code in more than one place

 

Problems to be fixed

  • Sometimes, when we enter the cart page for the first time, we can get an error about invalid items in the cart. As we have our useEffect running all the time, I think that this is part of the problem. We need to ensure that nothing will be running at the wrong time

  • When we add a Picker item, the price can be wrong (without considering the offer vendorConfig information). The best way to know if the offer item was added correctly is to compare what happens if we add the same offer through the legacy flow (/offers page). If something on the item (price, sub-items, etc) is wrong, then we have a problem!

 

About the item validation

The checkout page already has validations to prevent and invalid item to be used (for example if I tried to use a promo code that has an invalid item for my selected store):

image-20240207-224243.png
Invalid offer for a selected store

After some seconds the page will also show this modal:

This legacy validations are important and we always need to ensure:

  • An offer item can only be used if the store returns that this is a valid/available item. As we are using the availability query we can know if the item is invalid too, if necessary

  • To test this we can use a menu item that is not available at a specific restaurant, for example

 

DOD-LIKE

  • Validate the behavior with manual tests

  • Adjust and create new unit tests for our hook

 

Task 2 - Adjust the hook to respect the reuse logic behavior

The main point here is to keep the following behaviors:

  • If the user tried to add an already-added offer, he should receive the error message below the promo code input, as we do for the Offer Discount type

  • If the user tried to add an already redeemed offer, we need to get the information from the user offer available list (as we do for the Offer Discount type)

  • If the user applies an offer into the cart and goes back to the /offers page the current offer should be selected

  • If the user adds the same offer again we don’t want to add the same Combo/Picker item to the cart again. If necessary we need to check the cartEntries (we can get that from useOrderContext) to know if our offer cart item is already added

  • Tip: we always need to compare and ensure that our page is “connected” with the /offers page and we also need to ensure that we have the same behaviors of the legacy flow

 

Problems to be fixed

  • applyOfferPromoCode: for Picker/Combo offer we can’t set the setAppliedOfferPromoCode. We need to add a condition here. He don’t want to show the discount offer card as showed below:

Task 3 - Adjust the code to deal with more than one Combo/Picker incentives

 

The user has the ability to add more than one Combo/Picker on Sanity. With that in mind, we need to ensure that our logic will be good enough to deal with an iterable list of incentives.

  • This needs to work with the reuse logic developed on Task 2

  • Add new unit tests to cover this kind of scenario