diff --git a/packages/core/src/cart/cart-action-creator.spec.ts b/packages/core/src/cart/cart-action-creator.spec.ts new file mode 100644 index 0000000000..80fa325b7b --- /dev/null +++ b/packages/core/src/cart/cart-action-creator.spec.ts @@ -0,0 +1,107 @@ +import { createRequestSender, RequestSender } from '@bigcommerce/request-sender'; +import { from, of } from 'rxjs'; +import { catchError, toArray } from 'rxjs/operators'; + +import { Cart } from '../cart'; +import CheckoutStore from '../checkout/checkout-store'; +import { getCheckoutStoreState } from '../checkout/checkouts.mock'; +import createCheckoutStore from '../checkout/create-checkout-store'; +import { getErrorResponse, getResponse } from '../common/http-request/responses.mock'; + +import CartActionCreator from './cart-action-creator'; +import { CartActionType } from './cart-actions'; +import CartRequestSender from './cart-request-sender'; +import { getCart } from './carts.mock'; +import { getGQLCartResponse, getGQLCurrencyResponse } from './gql-cart/mocks/gql-cart.mock'; + +describe('CartActionCreator', () => { + let cartActionCreator: CartActionCreator; + let requestSender: RequestSender; + let cartRequestSender: CartRequestSender; + let store: CheckoutStore; + let cart: Cart; + + beforeEach(() => { + cart = getCart(); + requestSender = createRequestSender(); + + cartRequestSender = new CartRequestSender(requestSender); + + store = createCheckoutStore(getCheckoutStoreState()); + + jest.spyOn(cartRequestSender, 'loadCart').mockReturnValue( + Promise.resolve(getResponse(getGQLCartResponse())), + ); + + jest.spyOn(cartRequestSender, 'loadCartCurrency').mockReturnValue( + Promise.resolve(getResponse(getGQLCurrencyResponse())), + ); + + cartActionCreator = new CartActionCreator(cartRequestSender); + }); + + it('emits action to notify loading progress', async () => { + const actions = await from(cartActionCreator.loadCart(cart.id)(store)) + .pipe(toArray()) + .toPromise(); + + expect(cartRequestSender.loadCart).toHaveBeenCalledWith(cart.id, undefined, undefined); + + expect(actions).toEqual( + expect.arrayContaining([ + { type: CartActionType.LoadCartRequested }, + { + type: CartActionType.LoadCartSucceeded, + payload: expect.objectContaining({ + id: cart.id, + currency: { + code: cart.currency.code, + name: cart.currency.name, + symbol: cart.currency.symbol, + decimalPlaces: cart.currency.decimalPlaces, + }, + lineItems: expect.objectContaining({ + physicalItems: cart.lineItems.physicalItems.map((item) => + expect.objectContaining({ + id: item.id, + variantId: item.variantId, + productId: item.productId, + sku: item.sku, + name: item.name, + url: item.url, + quantity: item.quantity, + isShippingRequired: item.isShippingRequired, + }), + ), + }), + }), + }, + ]), + ); + }); + + it('emits error action if unable to load cart', async () => { + jest.spyOn(cartRequestSender, 'loadCart').mockReturnValue( + Promise.reject(getErrorResponse()), + ); + + const errorHandler = jest.fn((action) => of(action)); + + const actions = await from(cartActionCreator.loadCart(cart.id)(store)) + .pipe(catchError(errorHandler), toArray()) + .toPromise(); + + expect(cartRequestSender.loadCart).toHaveBeenCalledWith(cart.id, undefined, undefined); + + expect(actions).toEqual( + expect.arrayContaining([ + { type: CartActionType.LoadCartRequested }, + { + type: CartActionType.LoadCartFailed, + error: true, + payload: getErrorResponse(), + }, + ]), + ); + }); +}); diff --git a/packages/core/src/cart/cart-action-creator.ts b/packages/core/src/cart/cart-action-creator.ts new file mode 100644 index 0000000000..eadcb586d0 --- /dev/null +++ b/packages/core/src/cart/cart-action-creator.ts @@ -0,0 +1,71 @@ +import { createAction, createErrorAction, ThunkAction } from '@bigcommerce/data-store'; +import { Response } from '@bigcommerce/request-sender'; +import { merge } from 'lodash'; +import { Observable, Observer } from 'rxjs'; + +import { RequestOptions } from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import { InternalCheckoutSelectors } from '../checkout'; +import { cachableAction } from '../common/data-store'; +import ActionOptions from '../common/data-store/action-options'; + +import Cart from './cart'; +import { CartActionType, LoadCartAction } from './cart-actions'; +import CartRequestSender from './cart-request-sender'; +import { GQLCartResponse, GQLCurrencyResponse, GQLRequestResponse, mapToCart } from './gql-cart'; + +export default class CartActionCreator { + constructor(private _cartRequestSender: CartRequestSender) {} + + @cachableAction + loadCart( + cartId: string, + options?: RequestOptions & ActionOptions, + ): ThunkAction { + return (store) => { + return new Observable((observer: Observer) => { + const state = store.getState(); + const gqlUrl = state.config.getGQLRequestUrl(); + + observer.next(createAction(CartActionType.LoadCartRequested, undefined)); + + this._cartRequestSender + .loadCart(cartId, gqlUrl, options) + .then((cartResponse) => { + return this._cartRequestSender + .loadCartCurrency( + cartResponse.body.data.site.cart.currencyCode, + gqlUrl, + options, + ) + .then((currencyResponse) => { + observer.next( + createAction( + CartActionType.LoadCartSucceeded, + this.transformToCartResponse( + merge(cartResponse, currencyResponse), + ), + ), + ); + observer.complete(); + }); + }) + .catch((response) => { + observer.error(createErrorAction(CartActionType.LoadCartFailed, response)); + }); + }); + }; + } + + private transformToCartResponse( + response: Response>, + ): Cart { + const { + body: { + data: { site }, + }, + } = response; + + return mapToCart(site); + } +} diff --git a/packages/core/src/cart/cart-actions.ts b/packages/core/src/cart/cart-actions.ts new file mode 100644 index 0000000000..c7e95a9b42 --- /dev/null +++ b/packages/core/src/cart/cart-actions.ts @@ -0,0 +1,26 @@ +import { Action } from '@bigcommerce/data-store'; + +import Cart from './cart'; + +export enum CartActionType { + LoadCartRequested = 'LOAD_CART_REQUESTED', + LoadCartSucceeded = 'LOAD_CART_SUCCEEDED', + LoadCartFailed = 'LOAD_CART_FAILED', +} + +export type LoadCartAction = + | LoadCartRequestedAction + | LoadCartSucceededAction + | LoadCartFailedAction; + +export interface LoadCartRequestedAction extends Action { + type: CartActionType.LoadCartRequested; +} + +export interface LoadCartSucceededAction extends Action { + type: CartActionType.LoadCartSucceeded; +} + +export interface LoadCartFailedAction extends Action { + type: CartActionType.LoadCartFailed; +} diff --git a/packages/core/src/cart/cart-reducer.ts b/packages/core/src/cart/cart-reducer.ts index e86545ebab..68bfeddf05 100644 --- a/packages/core/src/cart/cart-reducer.ts +++ b/packages/core/src/cart/cart-reducer.ts @@ -13,6 +13,7 @@ import { import { ConsignmentAction, ConsignmentActionType } from '../shipping'; import Cart from './cart'; +import { CartActionType, LoadCartAction } from './cart-actions'; import CartState, { CartErrorsState, CartStatusesState, DEFAULT_STATE } from './cart-state'; export default function cartReducer(state: CartState = DEFAULT_STATE, action: Action): CartState { @@ -32,7 +33,8 @@ function dataReducer( | CheckoutAction | ConsignmentAction | CouponAction - | GiftCertificateAction, + | GiftCertificateAction + | LoadCartAction, ): Cart | undefined { switch (action.type) { case BillingAddressActionType.UpdateBillingAddressSucceeded: @@ -48,6 +50,9 @@ function dataReducer( case GiftCertificateActionType.RemoveGiftCertificateSucceeded: return objectMerge(data, action.payload && action.payload.cart); + case CartActionType.LoadCartSucceeded: + return objectMerge(data, action.payload && action.payload); + default: return data; } diff --git a/packages/core/src/cart/cart-request-sender.spec.ts b/packages/core/src/cart/cart-request-sender.spec.ts index 2048571881..554779e319 100644 --- a/packages/core/src/cart/cart-request-sender.spec.ts +++ b/packages/core/src/cart/cart-request-sender.spec.ts @@ -7,6 +7,7 @@ import { import { CartSource } from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { GQL_REQUEST_URL } from '../common/gql-request'; import { ContentType, SDK_VERSION_HEADERS } from '../common/http-request'; import { getResponse } from '../common/http-request/responses.mock'; @@ -14,12 +15,18 @@ import BuyNowCartRequestBody from './buy-now-cart-request-body'; import Cart from './cart'; import CartRequestSender from './cart-request-sender'; import { getCart } from './carts.mock'; +import { GQLCartResponse, GQLCurrencyResponse, GQLRequestResponse } from './gql-cart'; +import getCartCurrencyQuery from './gql-cart/get-cart-currency-query'; +import getCartQuery from './gql-cart/get-cart-query'; +import { getGQLCartResponse, getGQLCurrencyResponse } from './gql-cart/mocks/gql-cart.mock'; describe('CartRequestSender', () => { let cart: Cart; let cartRequestSender: CartRequestSender; let requestSender: RequestSender; let response: Response; + let gqlResponse: Response>; + let gqlCurrencyResponse: Response>; beforeEach(() => { requestSender = createRequestSender(); @@ -75,4 +82,66 @@ describe('CartRequestSender', () => { }); }); }); + + describe('#loadCart', () => { + const cartId = '123123'; + const gqlUrl = 'https://test.com/graphql'; + + beforeEach(() => { + gqlResponse = getResponse(getGQLCartResponse()); + + jest.spyOn(requestSender, 'post').mockResolvedValue(gqlResponse); + }); + + it('get gql cart', async () => { + await cartRequestSender.loadCart(cartId); + + expect(requestSender.post).toHaveBeenCalledWith(GQL_REQUEST_URL, { + body: { + query: getCartQuery(cartId), + }, + }); + }); + + it('get gql cart with graphql url', async () => { + await cartRequestSender.loadCart(cartId, gqlUrl); + + expect(requestSender.post).toHaveBeenCalledWith('https://test.com/graphql', { + body: { + query: getCartQuery(cartId), + }, + }); + }); + }); + + describe('#loadCartCurrency', () => { + const currencyCode = 'USD'; + const gqlUrl = 'https://test.com/graphql'; + + beforeEach(() => { + gqlCurrencyResponse = getResponse(getGQLCurrencyResponse()); + + jest.spyOn(requestSender, 'post').mockResolvedValue(gqlCurrencyResponse); + }); + + it('get gql cart currency', async () => { + await cartRequestSender.loadCartCurrency(currencyCode); + + expect(requestSender.post).toHaveBeenCalledWith(GQL_REQUEST_URL, { + body: { + query: getCartCurrencyQuery(currencyCode), + }, + }); + }); + + it('get gql cart currency with host url', async () => { + await cartRequestSender.loadCartCurrency(currencyCode, gqlUrl); + + expect(requestSender.post).toHaveBeenCalledWith('https://test.com/graphql', { + body: { + query: getCartCurrencyQuery(currencyCode), + }, + }); + }); + }); }); diff --git a/packages/core/src/cart/cart-request-sender.ts b/packages/core/src/cart/cart-request-sender.ts index ad4e54de45..49a1c2c8f3 100644 --- a/packages/core/src/cart/cart-request-sender.ts +++ b/packages/core/src/cart/cart-request-sender.ts @@ -2,8 +2,19 @@ import { RequestSender, Response } from '@bigcommerce/request-sender'; import { BuyNowCartRequestBody, Cart } from '@bigcommerce/checkout-sdk/payment-integration-api'; +import { GQL_REQUEST_URL } from '../common/gql-request'; import { ContentType, RequestOptions, SDK_VERSION_HEADERS } from '../common/http-request'; +import { + GQLCartResponse, + GQLCurrencyResponse, + GQLRequestOptions, + GQLRequestResponse, +} from './gql-cart'; +import CartRetrievalError from './gql-cart/errors/cart-retrieval-error'; +import getCartCurrencyQuery from './gql-cart/get-cart-currency-query'; +import getCartQuery from './gql-cart/get-cart-query'; + export default class CartRequestSender { constructor(private _requestSender: RequestSender) {} @@ -19,4 +30,53 @@ export default class CartRequestSender { return this._requestSender.post(url, { body, headers, timeout }); } + + async loadCart(cartId: string, gqlUrl?: string, options?: RequestOptions) { + const url = gqlUrl ?? GQL_REQUEST_URL; + + const requestOptions: GQLRequestOptions = { + ...options, + body: { + query: getCartQuery(cartId), + }, + }; + + const response = await this._requestSender.post>(url, { + ...requestOptions, + }); + + if (!response.body.data.site.cart) { + throw new CartRetrievalError( + `Could not retrieve cart information by cartId: ${cartId}`, + ); + } + + return response; + } + + async loadCartCurrency(currencyCode: string, gqlUrl?: string, options?: RequestOptions) { + const url = gqlUrl ?? GQL_REQUEST_URL; + + const requestOptions: GQLRequestOptions = { + ...options, + body: { + query: getCartCurrencyQuery(currencyCode), + }, + }; + + const response = await this._requestSender.post>( + url, + { + ...requestOptions, + }, + ); + + if (!response.body.data.site.currency) { + throw new CartRetrievalError( + `Could not retrieve currency information by currencyCode: ${currencyCode}`, + ); + } + + return response; + } } diff --git a/packages/core/src/cart/gql-cart/errors/cart-retrieval-error.spec.ts b/packages/core/src/cart/gql-cart/errors/cart-retrieval-error.spec.ts new file mode 100644 index 0000000000..f603d0e26c --- /dev/null +++ b/packages/core/src/cart/gql-cart/errors/cart-retrieval-error.spec.ts @@ -0,0 +1,21 @@ +import CartRetrievalError from './cart-retrieval-error'; + +describe('init', () => { + it('sets type to cart_retrieval', () => { + const error = new CartRetrievalError(); + + expect(error.type).toBe('cart_retrieval'); + }); + + it('returns error name', () => { + const error = new CartRetrievalError(); + + expect(error.name).toBe('CartRetrievalError'); + }); + + it('sets the message as `body.title`', () => { + const error = new CartRetrievalError('test message'); + + expect(error.message).toBe('test message'); + }); +}); diff --git a/packages/core/src/cart/gql-cart/errors/cart-retrieval-error.ts b/packages/core/src/cart/gql-cart/errors/cart-retrieval-error.ts new file mode 100644 index 0000000000..f98ed8e269 --- /dev/null +++ b/packages/core/src/cart/gql-cart/errors/cart-retrieval-error.ts @@ -0,0 +1,10 @@ +import { StandardError } from '../../../common/error/errors'; + +export default class CartRetrievalError extends StandardError { + constructor(message?: string) { + super(message || 'Cart not available.'); + + this.name = 'CartRetrievalError'; + this.type = 'cart_retrieval'; + } +} diff --git a/packages/core/src/cart/gql-cart/get-cart-currency-query.ts b/packages/core/src/cart/gql-cart/get-cart-currency-query.ts new file mode 100644 index 0000000000..40197f7766 --- /dev/null +++ b/packages/core/src/cart/gql-cart/get-cart-currency-query.ts @@ -0,0 +1,18 @@ +const getCartCurrencyQuery = (currencyCode: string) => { + return ` + query Currency { + site { + currency(currencyCode: ${currencyCode}) { + display { + decimalPlaces + symbol + } + name + code + } + } + } + `; +}; + +export default getCartCurrencyQuery; diff --git a/packages/core/src/cart/gql-cart/get-cart-query.ts b/packages/core/src/cart/gql-cart/get-cart-query.ts new file mode 100644 index 0000000000..5872c323b9 --- /dev/null +++ b/packages/core/src/cart/gql-cart/get-cart-query.ts @@ -0,0 +1,203 @@ +const getCartQuery = (cartId: string) => { + return ` + query GetCartQuery { + site { + cart(entityId: "${cartId}") { + entityId + isTaxIncluded + currencyCode + id + updatedAt { + utc + } + createdAt { + utc + } + discounts { + entityId + discountedAmount { + currencyCode + value + } + } + baseAmount { + currencyCode + value + } + lineItems { + totalQuantity + customItems { + entityId + extendedListPrice { + value + } + listPrice { + value + } + name + quantity + sku + } + giftCertificates { + amount { + value + } + recipient { + email + name + } + sender { + email + name + } + theme + entityId + isTaxable + message + name + } + physicalItems { + name + brand + imageUrl + entityId + quantity + productEntityId + variantEntityId + couponAmount { + value + currencyCode + } + discountedAmount { + value + } + discounts { + discountedAmount { + value + } + entityId + } + extendedListPrice { + currencyCode + value + } + extendedSalePrice { + currencyCode + value + } + giftWrapping { + amount { + value + } + message + name + } + listPrice { + value + } + originalPrice { + value + } + salePrice { + value + } + sku + url + isShippingRequired + isTaxable + selectedOptions { + entityId + name + ... on CartSelectedMultipleChoiceOption { + value + valueEntityId + } + ... on CartSelectedCheckboxOption { + value + valueEntityId + } + ... on CartSelectedNumberFieldOption { + number + } + ... on CartSelectedMultiLineTextFieldOption { + text + } + ... on CartSelectedTextFieldOption { + text + } + ... on CartSelectedDateFieldOption { + date { + utc + } + } + } + } + digitalItems { + name + brand + imageUrl + entityId + quantity + productEntityId + variantEntityId + extendedListPrice { + currencyCode + value + } + extendedSalePrice { + currencyCode + value + } + selectedOptions { + entityId + name + ... on CartSelectedMultipleChoiceOption { + value + valueEntityId + } + ... on CartSelectedCheckboxOption { + value + valueEntityId + } + ... on CartSelectedNumberFieldOption { + number + } + ... on CartSelectedMultiLineTextFieldOption { + text + } + ... on CartSelectedTextFieldOption { + text + } + ... on CartSelectedDateFieldOption { + date { + utc + } + } + } + } + } + amount { + currencyCode + value + } + discountedAmount { + currencyCode + value + } + } + checkout(entityId: "${cartId}") { + coupons { + entityId + code + couponType + discountedAmount { + currencyCode + value + } + } + } + } + }`; +}; + +export default getCartQuery; diff --git a/packages/core/src/cart/gql-cart/gql-cart.ts b/packages/core/src/cart/gql-cart/gql-cart.ts new file mode 100644 index 0000000000..246f66d3bb --- /dev/null +++ b/packages/core/src/cart/gql-cart/gql-cart.ts @@ -0,0 +1,151 @@ +import { RequestOptions } from '@bigcommerce/request-sender'; + +interface BaseFieldFragment { + value: number; +} + +export interface GQLRequestOptions extends RequestOptions { + body: { + query: string; + }; +} + +export interface GQLCartLineItem { + name: string; + entityId: string; + quantity: number; + productEntityId: number; + brand: string; + couponAmount: { + value: number; + }; + discountedAmount: { + value: number; + }; + discounts: Array<{ + discountedAmount: { + value: number; + }; + entityId: string; + }>; + extendedListPrice: { + value: number; + }; + extendedSalePrice: { + value: number; + }; + imageUrl: string; + isTaxable: boolean; + listPrice: { + value: number; + }; + originalPrice: { + value: number; + }; + salePrice: { + value: number; + }; + sku: string; + url: string; + variantEntityId: number; + selectedOptions: Array<{ + value: string; + valueEntityId: number; + entityId: number; + name: string; + }>; +} + +interface GQLCartPhysicalItem extends GQLCartLineItem { + isShippingRequired: boolean; + giftWrapping?: { + amount: { + value: number; + }; + message: string; + name: string; + } | null; +} + +interface GQLCartDigitalItem extends GQLCartLineItem { + downloadFileUrls: string[]; + downloadPageUrl: string; + downloadSize: string; +} + +export interface GQLCartCustomItem { + entityId: string; + listPrice: BaseFieldFragment; + extendedListPrice: BaseFieldFragment; + name: string; + quantity: number; + sku: string; +} + +export interface GQLCartGiftCertificates { + amount: BaseFieldFragment; + name: string; + theme: string; + entityId: string | number; + isTaxable: boolean; + message: string; + sender: { + email: string; + name: string; + }; + recipient: { + email: string; + name: string; + }; +} + +export interface GQLCartLineItems { + physicalItems: GQLCartPhysicalItem[]; + digitalItems: GQLCartDigitalItem[]; + customItems: GQLCartCustomItem[]; + giftCertificates?: GQLCartGiftCertificates[]; +} + +export interface GQLCartResponse { + cart: { + amount: BaseFieldFragment; + baseAmount: BaseFieldFragment; + entityId: string; + id: string; + createdAt: { + utc: string; + }; + updatedAt: { + utc: string; + }; + discounts: Array<{ + discountedAmount: BaseFieldFragment; + entityId: string; + }>; + discountedAmount: BaseFieldFragment; + isTaxIncluded: boolean; + currencyCode: string; + lineItems: GQLCartLineItems; + }; + checkout: { + coupons: Array<{ + entityId: string; + code: string; + couponType: string; + discountedAmount: { + value: number; + }; + }>; + }; +} + +export interface GQLCurrencyResponse { + currency: { + display: { + decimalPlaces: number; + symbol: string; + }; + name: string; + code: string; + }; +} diff --git a/packages/core/src/cart/gql-cart/gql-request-response.ts b/packages/core/src/cart/gql-cart/gql-request-response.ts new file mode 100644 index 0000000000..9244d8445a --- /dev/null +++ b/packages/core/src/cart/gql-cart/gql-request-response.ts @@ -0,0 +1,5 @@ +export interface GQLRequestResponse { + data: { + site: T; + }; +} diff --git a/packages/core/src/cart/gql-cart/index.ts b/packages/core/src/cart/gql-cart/index.ts new file mode 100644 index 0000000000..fc31eb0265 --- /dev/null +++ b/packages/core/src/cart/gql-cart/index.ts @@ -0,0 +1,3 @@ +export { GQLCartResponse, GQLRequestOptions, GQLCurrencyResponse } from './gql-cart'; +export { default as mapToCart } from './map-to-cart'; +export { GQLRequestResponse } from './gql-request-response'; diff --git a/packages/core/src/cart/gql-cart/map-to-cart-line-item.spec.ts b/packages/core/src/cart/gql-cart/map-to-cart-line-item.spec.ts new file mode 100644 index 0000000000..8a1461aa71 --- /dev/null +++ b/packages/core/src/cart/gql-cart/map-to-cart-line-item.spec.ts @@ -0,0 +1,34 @@ +import { omit } from 'lodash'; + +import { getCart } from '../carts.mock'; +import { LineItem } from '../line-item'; + +import mapToLineItem from './map-to-cart-line-item'; +import { gqlCartLineItem } from './mocks/gql-cart.mock'; + +describe('mapToLineItem', () => { + let headlessLineItemResponse: LineItem | undefined; + + beforeEach(() => { + headlessLineItemResponse = mapToLineItem(gqlCartLineItem()); + }); + + it('maps to line item', () => { + const { + lineItems: { + physicalItems: [firstPhysicalItem], + }, + } = getCart(); + + // TODO:: data is not yet fully compatible due to lack of information + expect(headlessLineItemResponse).toEqual({ + // omits fields that do not exist to retrieve information via GQL + ...omit(firstPhysicalItem, ['categoryNames', 'categories', 'isShippingRequired']), + // default props that are set due lack of information + addedByPromotion: false, + comparisonPrice: 0, + extendedComparisonPrice: 0, + retailPrice: 0, + }); + }); +}); diff --git a/packages/core/src/cart/gql-cart/map-to-cart-line-item.ts b/packages/core/src/cart/gql-cart/map-to-cart-line-item.ts new file mode 100644 index 0000000000..f5afa87309 --- /dev/null +++ b/packages/core/src/cart/gql-cart/map-to-cart-line-item.ts @@ -0,0 +1,62 @@ +import { LineItem } from '../line-item'; + +import { GQLCartLineItem } from './gql-cart'; + +export default function mapToLineItem(lineItem: GQLCartLineItem): LineItem { + const { + entityId, + name, + quantity, + productEntityId, + brand, + couponAmount, + discountedAmount, + discounts, + extendedListPrice, + extendedSalePrice, + imageUrl, + isTaxable, + listPrice, + salePrice, + sku, + url, + variantEntityId, + selectedOptions, + } = lineItem; + + return { + id: entityId, + name, + quantity, + productId: productEntityId, + brand, + couponAmount: couponAmount.value, + discountAmount: discountedAmount.value, + discounts: discounts.map((discount) => ({ + discountedAmount: discount.discountedAmount.value, + // Info:: discount item does not have name field in response body when making request using REST API, but there is name in interface, for a while set name as entityID + name: discount.entityId, + })), + extendedListPrice: extendedListPrice.value, + extendedSalePrice: extendedSalePrice.value, + imageUrl, + isTaxable, + listPrice: listPrice.value, + salePrice: salePrice.value, + sku, + url, + variantId: variantEntityId, + options: selectedOptions?.map((option) => ({ + name: option.name, + nameId: option.entityId, + value: option.value, + valueId: option.valueEntityId, + })), + + // Info:: we do not have any information regarding to fields below in the GraphQL Storefront doc + addedByPromotion: false, + comparisonPrice: 0, + extendedComparisonPrice: 0, + retailPrice: 0, + }; +} diff --git a/packages/core/src/cart/gql-cart/map-to-cart-line-items.spec.ts b/packages/core/src/cart/gql-cart/map-to-cart-line-items.spec.ts new file mode 100644 index 0000000000..2a47a0d988 --- /dev/null +++ b/packages/core/src/cart/gql-cart/map-to-cart-line-items.spec.ts @@ -0,0 +1,54 @@ +import { omit } from 'lodash'; + +import { getCart } from '../carts.mock'; +import { LineItemMap } from '../index'; + +import mapToCartLineItems from './map-to-cart-line-items'; +import { getGQLCartResponse } from './mocks/gql-cart.mock'; + +describe('mapToCartLinesItem', () => { + let headlessCartLineItemsResponse: LineItemMap | undefined; + + beforeEach(() => { + const { + data: { + site: { cart }, + }, + } = getGQLCartResponse(); + + headlessCartLineItemsResponse = mapToCartLineItems( + cart?.lineItems ?? { + physicalItems: [], + digitalItems: [], + giftCertificates: [], + customItems: [], + }, + ); + }); + + it('maps to cart line items', () => { + const { + lineItems: { + physicalItems: [firstPhysicalItem], + }, + } = getCart(); + + // TODO:: data is not yet fully compatible due to lack of information + const physicalItem = { + // omits fields that do not exist to retrieve information via GQL + ...omit(firstPhysicalItem, ['categoryNames', 'categories']), + // default props that are set due lack of information + addedByPromotion: false, + comparisonPrice: 0, + extendedComparisonPrice: 0, + retailPrice: 0, + }; + + expect(headlessCartLineItemsResponse).toEqual({ + physicalItems: [physicalItem], + digitalItems: [], + giftCertificates: [], + customItems: [], + }); + }); +}); diff --git a/packages/core/src/cart/gql-cart/map-to-cart-line-items.ts b/packages/core/src/cart/gql-cart/map-to-cart-line-items.ts new file mode 100644 index 0000000000..ad91dff85b --- /dev/null +++ b/packages/core/src/cart/gql-cart/map-to-cart-line-items.ts @@ -0,0 +1,53 @@ +import { LineItemMap } from '../index'; + +import { GQLCartLineItems } from './gql-cart'; +import mapToLineItem from './map-to-cart-line-item'; + +export default function mapToCartLineItems(lineItems: GQLCartLineItems): LineItemMap { + const { + physicalItems = [], + digitalItems = [], + giftCertificates = [], + customItems = [], + } = lineItems; + + return { + physicalItems: physicalItems.map(({ isShippingRequired, giftWrapping, ...item }) => ({ + isShippingRequired, + giftWrapping: giftWrapping + ? { + message: giftWrapping.message, + name: giftWrapping.name, + amount: giftWrapping.amount.value, + } + : undefined, + ...mapToLineItem(item), + })), + digitalItems: digitalItems.map( + ({ downloadFileUrls = [], downloadPageUrl = '', downloadSize = '', ...item }) => ({ + downloadFileUrls, + downloadPageUrl, + downloadSize, + ...mapToLineItem(item), + }), + ), + giftCertificates: giftCertificates.map((item) => ({ + id: item.entityId, + name: item.name, + theme: item.theme, + amount: item.amount.value, + taxable: item.isTaxable, + sender: item.sender, + recipient: item.recipient, + message: item.message, + })), + customItems: customItems.map((item) => ({ + id: item.entityId, + listPrice: item.listPrice.value, + extendedListPrice: item.extendedListPrice.value, + name: item.name, + quantity: item.quantity, + sku: item.sku, + })), + }; +} diff --git a/packages/core/src/cart/gql-cart/map-to-cart.spec.ts b/packages/core/src/cart/gql-cart/map-to-cart.spec.ts new file mode 100644 index 0000000000..28ad25983e --- /dev/null +++ b/packages/core/src/cart/gql-cart/map-to-cart.spec.ts @@ -0,0 +1,73 @@ +import { merge } from 'lodash'; + +import { Cart } from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import { getCart } from '../carts.mock'; + +import mapToCart from './map-to-cart'; +import { getGQLCartResponse, getGQLCurrencyResponse } from './mocks/gql-cart.mock'; + +describe('mapToCart', () => { + let headlessCartResponse: Cart | undefined; + + beforeEach(() => { + headlessCartResponse = mapToCart( + merge(getGQLCartResponse().data.site, getGQLCurrencyResponse().data.site), + ); + }); + + it('maps to internal cart', () => { + const cart = getCart(); + + // TODO:: data is not yet fully compatible due to lack of information + expect(headlessCartResponse).toEqual( + expect.objectContaining({ + id: cart.id, + isTaxIncluded: cart.isTaxIncluded, + discountAmount: cart.discountAmount, + baseAmount: cart.baseAmount, + cartAmount: cart.cartAmount, + createdTime: cart.createdTime, + updatedTime: cart.updatedTime, + coupons: cart.coupons.map((item) => + expect.objectContaining({ + id: item.id, + code: item.code, + couponType: item.couponType, + discountedAmount: item.discountedAmount, + }), + ), + lineItems: expect.objectContaining({ + physicalItems: cart.lineItems.physicalItems.map((item) => + expect.objectContaining({ + id: item.id, + variantId: item.variantId, + productId: item.productId, + sku: item.sku, + name: item.name, + url: item.url, + quantity: item.quantity, + brand: item.brand, + isTaxable: item.isTaxable, + imageUrl: item.imageUrl, + discounts: item.discounts, + discountAmount: item.discountAmount, + couponAmount: item.couponAmount, + listPrice: item.listPrice, + salePrice: item.salePrice, + extendedListPrice: item.extendedListPrice, + extendedSalePrice: item.extendedSalePrice, + isShippingRequired: item.isShippingRequired, + options: expect.arrayContaining(item.options || []), + // retailPrice, comparisonPrice, extendedComparisonPrice, addedByPromotion are not exist + }), + ), + }), + discounts: cart.discounts, + currency: cart.currency, + // customerId is not exist + // email is not exist + }), + ); + }); +}); diff --git a/packages/core/src/cart/gql-cart/map-to-cart.ts b/packages/core/src/cart/gql-cart/map-to-cart.ts new file mode 100644 index 0000000000..35c00f627a --- /dev/null +++ b/packages/core/src/cart/gql-cart/map-to-cart.ts @@ -0,0 +1,43 @@ +import { Cart } from '@bigcommerce/checkout-sdk/payment-integration-api'; + +import mapToCartLineItems from './map-to-cart-line-items'; + +import { GQLCartResponse, GQLCurrencyResponse } from './'; + +export default function mapToCart(response: GQLCartResponse & GQLCurrencyResponse): Cart { + const { cart, checkout, currency } = response; + + return { + id: cart.entityId, + baseAmount: cart.baseAmount.value, + cartAmount: cart.amount.value, + discounts: cart.discounts.map((discount) => ({ + id: discount.entityId, + discountedAmount: discount.discountedAmount.value, + })), + isTaxIncluded: cart.isTaxIncluded, + lineItems: mapToCartLineItems(cart.lineItems), + currency: { + code: currency.code, + name: currency.name, + symbol: currency.display.symbol, + decimalPlaces: currency.display.decimalPlaces, + }, + createdTime: cart.createdAt.utc, + updatedTime: cart.updatedAt.utc, + discountAmount: cart.discountedAmount.value, + + coupons: checkout.coupons.map((item) => ({ + id: item.entityId, + code: item.code, + couponType: item.couponType, + discountedAmount: item.discountedAmount.value, + // Info:: there is no info about displayName field + displayName: '', + })), + // Info:: information about email field can be pulled from Billing Address or Shipping Address (https://developer.bigcommerce.com/docs/storefront/cart-checkout/guide/graphql-storefront#get-checkout) + email: '', + // Info:: there is no info about customerId field + customerId: 0, + }; +} diff --git a/packages/core/src/cart/gql-cart/mocks/gql-cart.mock.ts b/packages/core/src/cart/gql-cart/mocks/gql-cart.mock.ts new file mode 100644 index 0000000000..1ead9a46ba --- /dev/null +++ b/packages/core/src/cart/gql-cart/mocks/gql-cart.mock.ts @@ -0,0 +1,145 @@ +import { GQLCartLineItem, GQLCartResponse, GQLCurrencyResponse } from '../gql-cart'; +import { GQLRequestResponse } from '../gql-request-response'; + +export function gqlCartLineItem(): GQLCartLineItem { + return { + discounts: [], + brand: 'OFS', + couponAmount: { + value: 5, + }, + discountedAmount: { + value: 10, + }, + entityId: '666', + extendedListPrice: { + value: 200, + }, + extendedSalePrice: { + value: 190, + }, + imageUrl: '/images/canvas-laundry-cart.jpg', + isTaxable: true, + listPrice: { + value: 200, + }, + name: 'Canvas Laundry Cart', + originalPrice: { + value: 225, + }, + productEntityId: 103, + quantity: 1, + salePrice: { + value: 190, + }, + selectedOptions: [ + { + name: 'n', + entityId: 1, + value: 'v', + valueEntityId: 3, + }, + ], + sku: 'CLC', + url: '/canvas-laundry-cart/', + variantEntityId: 71, + }; +} + +export function getGQLCartResponse(): GQLRequestResponse { + return { + data: { + site: { + cart: { + amount: { + value: 190, + }, + baseAmount: { + value: 200, + }, + createdAt: { + utc: '2018-03-06T04:41:49+00:00', + }, + updatedAt: { + utc: '2018-03-07T03:44:51+00:00', + }, + currencyCode: 'USD', + discountedAmount: { + value: 10, + }, + discounts: [ + { + discountedAmount: { + value: 10, + }, + entityId: '12e11c8f-7dce-4da3-9413-b649533f8bad', + }, + ], + entityId: 'b20deef40f9699e48671bbc3fef6ca44dc80e3c7', + id: 'Q2FydDozOWE5NmQ2Yy1mNDRjLTQxZTItOTFlMy0yNTcxMDQ0Yzk1Njc=', + isTaxIncluded: false, + lineItems: { + customItems: [], + digitalItems: [], + physicalItems: [getPhysicalItem()], + }, + }, + checkout: { + coupons: [ + { + entityId: '1', + code: 'savebig2015', + couponType: 'percentage_discount', + discountedAmount: { + value: 5, + }, + }, + { + entityId: '4', + code: '279F507D817E3E7', + couponType: 'shipping_discount', + discountedAmount: { + value: 5, + }, + }, + ], + }, + }, + }, + }; +} + +export function getPhysicalItem(hasGiftWrapping?: false) { + return { + isShippingRequired: true, + ...(hasGiftWrapping + ? { + giftWrapping: { + name: 'gift', + message: 'message', + amount: { + value: 10, + }, + }, + } + : {}), + ...gqlCartLineItem(), + }; +} + +export function getGQLCurrencyResponse(): GQLRequestResponse { + return { + data: { + site: { + currency: { + display: { + decimalPlaces: 2, + symbol: '$', + }, + name: 'US Dollar', + code: 'USD', + }, + }, + }, + }; +} diff --git a/packages/core/src/cart/index.ts b/packages/core/src/cart/index.ts index 02e2f02cbf..f11cfc5c50 100644 --- a/packages/core/src/cart/index.ts +++ b/packages/core/src/cart/index.ts @@ -14,6 +14,7 @@ export { default as LineItemMap } from './line-item-map'; export { default as CartComparator } from './cart-comparator'; export { default as CartRequestSender } from './cart-request-sender'; export { default as cartReducer } from './cart-reducer'; +export { default as CartActionCreator } from './cart-action-creator'; export { default as CartSelector, CartSelectorFactory, diff --git a/packages/core/src/common/gql-request/gql-request-url.ts b/packages/core/src/common/gql-request/gql-request-url.ts new file mode 100644 index 0000000000..cbcd0a149e --- /dev/null +++ b/packages/core/src/common/gql-request/gql-request-url.ts @@ -0,0 +1 @@ +export const GQL_REQUEST_URL = `${window.location.origin}/wallet-buttons`; diff --git a/packages/core/src/common/gql-request/index.ts b/packages/core/src/common/gql-request/index.ts new file mode 100644 index 0000000000..b75b6d7043 --- /dev/null +++ b/packages/core/src/common/gql-request/index.ts @@ -0,0 +1 @@ +export { GQL_REQUEST_URL } from './gql-request-url'; diff --git a/packages/core/src/config/config-selector.ts b/packages/core/src/config/config-selector.ts index b1f94f4244..67f8120d85 100644 --- a/packages/core/src/config/config-selector.ts +++ b/packages/core/src/config/config-selector.ts @@ -16,6 +16,7 @@ export default interface ConfigSelector { getContextConfig(): ContextConfig | undefined; getExternalSource(): string | undefined; getHost(): string | undefined; + getGQLRequestUrl(): string | undefined; getLocale(): string | undefined; getVariantIdentificationToken(): string | undefined; getLoadError(): Error | undefined; @@ -90,6 +91,11 @@ export function createConfigSelectorFactory(): ConfigSelectorFactory { (data) => () => data, ); + const getGQLRequestUrl = createSelector( + (state: ConfigState) => state.meta?.gqlRequestUrl, + (data) => () => data, + ); + const getLocale = createSelector( (state: ConfigState) => state.meta?.locale, (data) => () => data, @@ -120,6 +126,7 @@ export function createConfigSelectorFactory(): ConfigSelectorFactory { getContextConfig: getContextConfig(state), getExternalSource: getExternalSource(state), getHost: getHost(state), + getGQLRequestUrl: getGQLRequestUrl(state), getLocale: getLocale(state), getVariantIdentificationToken: getVariantIdentificationToken(state), getLoadError: getLoadError(state), diff --git a/packages/core/src/config/config-state.ts b/packages/core/src/config/config-state.ts index 15ded51e82..336bc1b942 100644 --- a/packages/core/src/config/config-state.ts +++ b/packages/core/src/config/config-state.ts @@ -12,6 +12,7 @@ export interface ConfigMetaState { variantIdentificationToken?: string; host?: string; locale?: string; + gqlRequestUrl?: string; } export interface ConfigErrorsState {