diff --git a/.env-cmdrc b/.env-cmdrc index 60fdf05f15f..aeb0f17cde2 100644 --- a/.env-cmdrc +++ b/.env-cmdrc @@ -32,6 +32,10 @@ "CX_EPD_VISUALIZATION": "true", "CX_BASE_URL": "https://api.cp96avkh5f-integrati1-d1-public.model-t.cc.commerce.ondemand.com" }, + "opf": { + "CX_OPF": "true", + "CX_BASE_URL": "https://api.cp96avkh5f-integrati2-s1-public.model-t.cc.commerce.ondemand.com" + }, "cpq": { "CX_BASE_URL": "https://api.cg79x9wuu9-eccommerc1-s1-public.model-t.myhybris.cloud/", "CX_B2B": "true", @@ -55,7 +59,7 @@ "CX_REQUESTED_DELIVERY_DATE": "true", "CX_PDF_INVOICES": "true" }, - "omf":{ + "omf": { "CX_BASE_URL": "https://api.cg79x9wuu9-eccommerc1-s9-public.model-t.myhybris.cloud", "CX_OMF": "true" }, @@ -71,22 +75,22 @@ "CX_BASE_URL": "https://api.cg79x9wuu9-eccommerc1-s5-public.model-t.myhybris.cloud", "CX_PDF_INVOICES": "true" }, - "my-account-v2":{ + "my-account-v2": { "CX_BASE_URL": "https://api.cg79x9wuu9-eccommerc1-s5-public.model-t.myhybris.cloud", "CX_PDF_INVOICES": "true", "CX_MY_ACCOUNT_V2": "true" }, - "cdp":{ + "cdp": { "CX_CDP": "true", "CX_BASE_URL": "https://api.cg79x9wuu9-eccommerc1-s5-public.model-t.myhybris.cloud", "CX_PDF_INVOICES": "true", "CX_MY_ACCOUNT_V2": "true" }, - "opps":{ + "opps": { "CX_BASE_URL": "https://api.cg79x9wuu9-eccommerc1-s5-public.model-t.myhybris.cloud", "CX_OPPS": "true" }, - "s4-service":{ + "s4-service": { "CX_BASE_URL": "https://api.cg79x9wuu9-eccommerc1-s8-public.model-t.myhybris.cloud", "CX_S4_SERVICE": "true", "CX_B2B": "true" diff --git a/core-libs/setup/tsconfig.spec.json b/core-libs/setup/tsconfig.spec.json index cc1fdb41f4f..88548e99c31 100644 --- a/core-libs/setup/tsconfig.spec.json +++ b/core-libs/setup/tsconfig.spec.json @@ -615,6 +615,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/extra-webpack.config.js b/extra-webpack.config.js index 07e9ee31c3e..00beacece3d 100644 --- a/extra-webpack.config.js +++ b/extra-webpack.config.js @@ -72,6 +72,7 @@ module.exports = { 'feature-libs/pickup-in-store' ), '@spartacus/s4om': path.join(__dirname, 'integration-libs/s4om'), + '@spartacus/opf': path.join(__dirname, 'integration-libs/opf'), '@spartacus/s4-service': path.join(__dirname, 'integration-libs/s4-service'), '@spartacus/omf': path.join(__dirname, 'integration-libs/omf'), }, diff --git a/feature-libs/asm/tsconfig.schematics.json b/feature-libs/asm/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/feature-libs/asm/tsconfig.schematics.json +++ b/feature-libs/asm/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/cart/base/core/cart-base-core.module.ts b/feature-libs/cart/base/core/cart-base-core.module.ts index 819130a8d86..f6331de578d 100644 --- a/feature-libs/cart/base/core/cart-base-core.module.ts +++ b/feature-libs/cart/base/core/cart-base-core.module.ts @@ -7,6 +7,7 @@ import { NgModule } from '@angular/core'; import { HttpErrorHandler } from '@spartacus/core'; import { CartPersistenceModule } from './cart-persistence.module'; +import { CartAccessCodeConnector, CartGuestUserConnector } from './connectors'; import { CartConnector } from './connectors/cart/cart.connector'; import { CartEntryConnector } from './connectors/entry/cart-entry.connector'; import { CartValidationConnector } from './connectors/validation/cart-validation.connector'; @@ -30,6 +31,8 @@ import { MultiCartStoreModule } from './store/multi-cart-store.module'; CartEntryConnector, CartVoucherConnector, CartValidationConnector, + CartAccessCodeConnector, + CartGuestUserConnector, ...facadeProviders, { provide: HttpErrorHandler, diff --git a/feature-libs/cart/base/core/connectors/access-code/cart-access-code.adapter.ts b/feature-libs/cart/base/core/connectors/access-code/cart-access-code.adapter.ts new file mode 100644 index 00000000000..e146a947306 --- /dev/null +++ b/feature-libs/cart/base/core/connectors/access-code/cart-access-code.adapter.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Observable } from 'rxjs'; + +export abstract class CartAccessCodeAdapter { + /** + * Abstract method used to generate access code for a specific cart id. + * + * @param {string} userId + * @param {string} cartId + * + */ + abstract getCartAccessCode( + userId: string, + cartId: string + ): Observable; +} diff --git a/feature-libs/cart/base/core/connectors/access-code/cart-access-code.connector.spec.ts b/feature-libs/cart/base/core/connectors/access-code/cart-access-code.connector.spec.ts new file mode 100644 index 00000000000..57f77dd407c --- /dev/null +++ b/feature-libs/cart/base/core/connectors/access-code/cart-access-code.connector.spec.ts @@ -0,0 +1,39 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { CartAccessCodeAdapter } from './cart-access-code.adapter'; +import { CartAccessCodeConnector } from './cart-access-code.connector'; +import createSpy = jasmine.createSpy; + +class MockCartAccessCodeAdapter implements CartAccessCodeAdapter { + getCartAccessCode = createSpy().and.returnValue(of({})); +} + +describe('CartAccessCodeConnector', () => { + let service: CartAccessCodeConnector; + let adapter: CartAccessCodeAdapter; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + CartAccessCodeConnector, + { + provide: CartAccessCodeAdapter, + useClass: MockCartAccessCodeAdapter, + }, + ], + }); + + service = TestBed.inject(CartAccessCodeConnector); + adapter = TestBed.inject(CartAccessCodeAdapter); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call adapter', () => { + service.getCartAccessCode('user1', 'cart1').pipe(take(1)).subscribe(); + expect(adapter.getCartAccessCode).toHaveBeenCalledWith('user1', 'cart1'); + }); +}); diff --git a/feature-libs/cart/base/core/connectors/access-code/cart-access-code.connector.ts b/feature-libs/cart/base/core/connectors/access-code/cart-access-code.connector.ts new file mode 100644 index 00000000000..16f503263f1 --- /dev/null +++ b/feature-libs/cart/base/core/connectors/access-code/cart-access-code.connector.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { CartAccessCodeAdapter } from './cart-access-code.adapter'; + +@Injectable() +export class CartAccessCodeConnector { + constructor(protected adapter: CartAccessCodeAdapter) {} + + public getCartAccessCode( + userId: string, + cartId: string + ): Observable { + return this.adapter.getCartAccessCode(userId, cartId); + } +} diff --git a/feature-libs/cart/base/core/connectors/access-code/converters.ts b/feature-libs/cart/base/core/connectors/access-code/converters.ts new file mode 100644 index 00000000000..09cf89c349b --- /dev/null +++ b/feature-libs/cart/base/core/connectors/access-code/converters.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InjectionToken } from '@angular/core'; +import { Converter } from '@spartacus/core'; + +export const CART_ACCESS_CODE_NORMALIZER = new InjectionToken< + Converter +>('CartAccessCodeNormalizer'); diff --git a/feature-libs/cart/base/core/connectors/access-code/index.ts b/feature-libs/cart/base/core/connectors/access-code/index.ts new file mode 100644 index 00000000000..c9a9e82e045 --- /dev/null +++ b/feature-libs/cart/base/core/connectors/access-code/index.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './cart-access-code.adapter'; +export * from './cart-access-code.connector'; +export * from './converters'; diff --git a/feature-libs/cart/base/core/connectors/guest-user/cart-guest-user.adapter.ts b/feature-libs/cart/base/core/connectors/guest-user/cart-guest-user.adapter.ts new file mode 100644 index 00000000000..870b4c42da6 --- /dev/null +++ b/feature-libs/cart/base/core/connectors/guest-user/cart-guest-user.adapter.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CartGuestUser } from '@spartacus/cart/base/root'; +import { Observable } from 'rxjs'; + +export abstract class CartGuestUserAdapter { + /** + * Abstract method used to create a guest user, and assigns the user to the cart. + * + * @param {string} userId + * @param {string} cartId + * @param {CartGuestUser} guestUserDetails + * + */ + abstract createCartGuestUser( + userId: string, + cartId: string, + guestUserDetails?: CartGuestUser + ): Observable; + + /** + * Abstract method used to update a guest user, and assigns the user to the cart. + * + * @param {string} userId + * @param {string} cartId + * @param {CartGuestUser} guestUserDetails + * + */ + abstract updateCartGuestUser( + userId: string, + cartId: string, + guestUserDetails: CartGuestUser + ): Observable; +} diff --git a/feature-libs/cart/base/core/connectors/guest-user/cart-guest-user.connector.spec.ts b/feature-libs/cart/base/core/connectors/guest-user/cart-guest-user.connector.spec.ts new file mode 100644 index 00000000000..4f5d2724809 --- /dev/null +++ b/feature-libs/cart/base/core/connectors/guest-user/cart-guest-user.connector.spec.ts @@ -0,0 +1,55 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { CartGuestUserAdapter } from './cart-guest-user.adapter'; +import { CartGuestUserConnector } from './cart-guest-user.connector'; + +import createSpy = jasmine.createSpy; + +class MockCartGuestUserAdapter implements CartGuestUserAdapter { + createCartGuestUser = createSpy().and.returnValue(of({})); + updateCartGuestUser = createSpy().and.returnValue(of({})); +} + +describe('CartGuestUserConnector', () => { + let service: CartGuestUserConnector; + let adapter: CartGuestUserAdapter; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + CartGuestUserConnector, + { + provide: CartGuestUserAdapter, + useClass: MockCartGuestUserAdapter, + }, + ], + }); + + service = TestBed.inject(CartGuestUserConnector); + adapter = TestBed.inject(CartGuestUserAdapter); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('createCartGuestUser should call adapter', () => { + service.createCartGuestUser('userId', 'cartId').subscribe(); + expect(adapter.createCartGuestUser).toHaveBeenCalledWith( + 'userId', + 'cartId', + undefined + ); + }); + + it('updateCartGuestUser should call adapter', () => { + service + .updateCartGuestUser('userId', 'cartId', { email: 'test@sap.com' }) + .subscribe(); + expect(adapter.updateCartGuestUser).toHaveBeenCalledWith( + 'userId', + 'cartId', + { email: 'test@sap.com' } + ); + }); +}); diff --git a/feature-libs/cart/base/core/connectors/guest-user/cart-guest-user.connector.ts b/feature-libs/cart/base/core/connectors/guest-user/cart-guest-user.connector.ts new file mode 100644 index 00000000000..01e9794f855 --- /dev/null +++ b/feature-libs/cart/base/core/connectors/guest-user/cart-guest-user.connector.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { CartGuestUser } from '@spartacus/cart/base/root'; +import { Observable } from 'rxjs'; +import { CartGuestUserAdapter } from './cart-guest-user.adapter'; + +@Injectable() +export class CartGuestUserConnector { + constructor(protected adapter: CartGuestUserAdapter) {} + + public createCartGuestUser( + userId: string, + cartId: string, + guestUserDetails?: CartGuestUser + ): Observable { + return this.adapter.createCartGuestUser(userId, cartId, guestUserDetails); + } + + public updateCartGuestUser( + userId: string, + cartId: string, + guestUserDetails: CartGuestUser + ): Observable { + return this.adapter.updateCartGuestUser(userId, cartId, guestUserDetails); + } +} diff --git a/feature-libs/cart/base/core/connectors/guest-user/converters.ts b/feature-libs/cart/base/core/connectors/guest-user/converters.ts new file mode 100644 index 00000000000..2ae939b25f4 --- /dev/null +++ b/feature-libs/cart/base/core/connectors/guest-user/converters.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InjectionToken } from '@angular/core'; +import { CartGuestUser } from '@spartacus/cart/base/root'; +import { Converter } from '@spartacus/core'; + +export const CART_GUEST_USER_NORMALIZER = new InjectionToken< + Converter +>('CartGuestUserNormalizer'); diff --git a/feature-libs/cart/base/core/connectors/guest-user/index.ts b/feature-libs/cart/base/core/connectors/guest-user/index.ts new file mode 100644 index 00000000000..c3e1fedf014 --- /dev/null +++ b/feature-libs/cart/base/core/connectors/guest-user/index.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './cart-guest-user.adapter'; +export * from './cart-guest-user.connector'; +export * from './converters'; diff --git a/feature-libs/cart/base/core/connectors/index.ts b/feature-libs/cart/base/core/connectors/index.ts index fd5bc6be342..4661823997a 100644 --- a/feature-libs/cart/base/core/connectors/index.ts +++ b/feature-libs/cart/base/core/connectors/index.ts @@ -4,7 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from './access-code/index'; export * from './cart/index'; export * from './entry/index'; +export * from './guest-user/index'; export * from './validation/index'; export * from './voucher/index'; diff --git a/feature-libs/cart/base/core/facade/cart-access-code.service.spec.ts b/feature-libs/cart/base/core/facade/cart-access-code.service.spec.ts new file mode 100644 index 00000000000..0bed474c0db --- /dev/null +++ b/feature-libs/cart/base/core/facade/cart-access-code.service.spec.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { CartAccessCodeService } from './cart-access-code.service'; +import { CommandService, QueryService } from '@spartacus/core'; +import { CartAccessCodeConnector } from '../connectors'; +import { of } from 'rxjs'; +import createSpy = jasmine.createSpy; + +describe('CartAccessCodeService', () => { + let service: CartAccessCodeService; + let commandService: CommandService; + let cartAccessCodeConnector: CartAccessCodeConnector; + + beforeEach(() => { + const commandServiceMock = { + create: createSpy().and.callFake((fn: any) => ({ + execute: fn, + })), + }; + + const cartAccessCodeConnectorMock = { + getCartAccessCode: createSpy().and.returnValue(of('mockAccessCode')), + }; + + TestBed.configureTestingModule({ + providers: [ + CartAccessCodeService, + { provide: CommandService, useValue: commandServiceMock }, + { + provide: CartAccessCodeConnector, + useValue: cartAccessCodeConnectorMock, + }, + QueryService, + ], + }); + + service = TestBed.inject(CartAccessCodeService); + commandService = TestBed.inject(CommandService); + cartAccessCodeConnector = TestBed.inject(CartAccessCodeConnector); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call CartAccessCodeConnector.getCartAccessCode when getCartAccessCode is executed', () => { + const userId = 'testUserId'; + const cartId = 'testCartId'; + + service.getCartAccessCode(userId, cartId).subscribe((result) => { + expect(result).toBe('mockAccessCode'); + }); + + expect(cartAccessCodeConnector.getCartAccessCode).toHaveBeenCalledWith( + userId, + cartId + ); + expect(commandService.create).toHaveBeenCalled(); + }); +}); diff --git a/feature-libs/cart/base/core/facade/cart-access-code.service.ts b/feature-libs/cart/base/core/facade/cart-access-code.service.ts new file mode 100644 index 00000000000..4b5971be49c --- /dev/null +++ b/feature-libs/cart/base/core/facade/cart-access-code.service.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { CartAccessCodeFacade } from '@spartacus/cart/base/root'; +import { Command, CommandService, QueryService } from '@spartacus/core'; +import { Observable } from 'rxjs'; +import { CartAccessCodeConnector } from '../connectors'; + +@Injectable() +export class CartAccessCodeService implements CartAccessCodeFacade { + protected getCartAccessCodeCommand: Command< + { + userId: string; + cartId: string; + }, + string | undefined + > = this.commandService.create(({ userId, cartId }) => + this.cartAccessCodeConnector.getCartAccessCode(userId, cartId) + ); + + constructor( + protected queryService: QueryService, + protected commandService: CommandService, + protected cartAccessCodeConnector: CartAccessCodeConnector + ) {} + + getCartAccessCode( + userId: string, + cartId: string + ): Observable { + return this.getCartAccessCodeCommand.execute({ userId, cartId }); + } +} diff --git a/feature-libs/cart/base/core/facade/cart-guest-user.service.spec.ts b/feature-libs/cart/base/core/facade/cart-guest-user.service.spec.ts new file mode 100644 index 00000000000..60214ac2217 --- /dev/null +++ b/feature-libs/cart/base/core/facade/cart-guest-user.service.spec.ts @@ -0,0 +1,86 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { CartGuestUserConnector } from '../connectors'; +import { CartGuestUserService } from './cart-guest-user.service'; + +import createSpy = jasmine.createSpy; + +class MockCartCartGuestUserConnector + implements Partial +{ + createCartGuestUser = createSpy().and.callFake(() => of({})); + updateCartGuestUser = createSpy().and.callFake(() => of({})); +} + +const userId = 'userId'; +const cartId = 'cartId'; + +describe('CartGuestUserService', () => { + let service: CartGuestUserService; + let connector: CartGuestUserConnector; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + CartGuestUserService, + { + provide: CartGuestUserConnector, + useClass: MockCartCartGuestUserConnector, + }, + ], + }); + + service = TestBed.inject(CartGuestUserService); + connector = TestBed.inject(CartGuestUserConnector); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should execute command for create cart guest user', () => { + let result; + service + .createCartGuestUser(userId, cartId) + .subscribe((data) => { + result = data; + }) + .unsubscribe(); + + expect(result).toEqual({}); + }); + + it('should call connector with passed params to create cart guest user', () => { + service.createCartGuestUser(userId, cartId); + expect(connector.createCartGuestUser).toHaveBeenCalledWith( + userId, + cartId, + undefined + ); + }); + + it('should execute command for update cart guest user', () => { + let result; + service + .updateCartGuestUser(userId, cartId, { email: 'test@sap.com' }) + .subscribe((data) => { + result = data; + }) + .unsubscribe(); + + expect(result).toEqual({}); + }); + + it('should call connector with passed params to update cart guest user', () => { + service.updateCartGuestUser(userId, cartId, { email: 'test@sap.com' }); + expect(connector.updateCartGuestUser).toHaveBeenCalledWith(userId, cartId, { + email: 'test@sap.com', + }); + }); +}); diff --git a/feature-libs/cart/base/core/facade/cart-guest-user.service.ts b/feature-libs/cart/base/core/facade/cart-guest-user.service.ts new file mode 100644 index 00000000000..17e91766edc --- /dev/null +++ b/feature-libs/cart/base/core/facade/cart-guest-user.service.ts @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { inject, Injectable } from '@angular/core'; +import { CartGuestUser, CartGuestUserFacade } from '@spartacus/cart/base/root'; +import { Command, CommandService, QueryService } from '@spartacus/core'; +import { Observable } from 'rxjs'; +import { CartGuestUserConnector } from '../connectors'; + +@Injectable() +export class CartGuestUserService implements CartGuestUserFacade { + protected queryService = inject(QueryService); + protected commandService = inject(CommandService); + protected cartGuestUserConnector = inject(CartGuestUserConnector); + + protected createCartGuestUserCommand: Command< + { + userId: string; + cartId: string; + guestUserDetails?: CartGuestUser; + }, + CartGuestUser + > = this.commandService.create(({ userId, cartId, guestUserDetails }) => + this.cartGuestUserConnector.createCartGuestUser( + userId, + cartId, + guestUserDetails + ) + ); + + protected updateCartGuestUserCommand: Command< + { + userId: string; + cartId: string; + guestUserDetails: CartGuestUser; + }, + CartGuestUser + > = this.commandService.create(({ userId, cartId, guestUserDetails }) => + this.cartGuestUserConnector.updateCartGuestUser( + userId, + cartId, + guestUserDetails + ) + ); + + createCartGuestUser( + userId: string, + cartId: string, + guestUserDetails?: CartGuestUser + ): Observable { + return this.createCartGuestUserCommand.execute({ + userId, + cartId, + guestUserDetails, + }); + } + + updateCartGuestUser( + userId: string, + cartId: string, + guestUserDetails: CartGuestUser + ): Observable { + return this.updateCartGuestUserCommand.execute({ + userId, + cartId, + guestUserDetails, + }); + } +} diff --git a/feature-libs/cart/base/core/facade/facade-providers.ts b/feature-libs/cart/base/core/facade/facade-providers.ts index 77e4aac113e..4a03b5e2a4a 100644 --- a/feature-libs/cart/base/core/facade/facade-providers.ts +++ b/feature-libs/cart/base/core/facade/facade-providers.ts @@ -7,12 +7,16 @@ import { Provider } from '@angular/core'; import { ActiveCartFacade, + CartAccessCodeFacade, + CartGuestUserFacade, CartValidationFacade, CartVoucherFacade, MultiCartFacade, SelectiveCartFacade, } from '@spartacus/cart/base/root'; import { ActiveCartService } from './active-cart.service'; +import { CartAccessCodeService } from './cart-access-code.service'; +import { CartGuestUserService } from './cart-guest-user.service'; import { CartValidationService } from './cart-validation.service'; import { CartVoucherService } from './cart-voucher.service'; import { MultiCartService } from './multi-cart.service'; @@ -44,4 +48,14 @@ export const facadeProviders: Provider[] = [ provide: CartValidationFacade, useExisting: CartValidationService, }, + CartAccessCodeService, + { + provide: CartAccessCodeFacade, + useExisting: CartAccessCodeService, + }, + CartGuestUserService, + { + provide: CartGuestUserFacade, + useExisting: CartGuestUserService, + }, ]; diff --git a/feature-libs/cart/base/core/facade/index.ts b/feature-libs/cart/base/core/facade/index.ts index a5647d3d2d4..355b231c89b 100644 --- a/feature-libs/cart/base/core/facade/index.ts +++ b/feature-libs/cart/base/core/facade/index.ts @@ -5,6 +5,8 @@ */ export * from './active-cart.service'; +export * from './cart-access-code.service'; +export * from './cart-guest-user.service'; export * from './cart-validation.service'; export * from './cart-voucher.service'; export * from './multi-cart.service'; diff --git a/feature-libs/cart/base/occ/adapters/index.ts b/feature-libs/cart/base/occ/adapters/index.ts index 86d6626f2d8..627a45ee9d4 100644 --- a/feature-libs/cart/base/occ/adapters/index.ts +++ b/feature-libs/cart/base/occ/adapters/index.ts @@ -5,7 +5,9 @@ */ export * from './converters/index'; +export * from './occ-cart-access-code.adapter'; export * from './occ-cart-entry.adapter'; +export * from './occ-cart-guest-user.adapter'; +export * from './occ-cart-validation.adapter'; export * from './occ-cart-voucher.adapter'; export * from './occ-cart.adapter'; -export * from './occ-cart-validation.adapter'; diff --git a/feature-libs/cart/base/occ/adapters/occ-cart-access-code.adapter.spec.ts b/feature-libs/cart/base/occ/adapters/occ-cart-access-code.adapter.spec.ts new file mode 100644 index 00000000000..b172ad3a879 --- /dev/null +++ b/feature-libs/cart/base/occ/adapters/occ-cart-access-code.adapter.spec.ts @@ -0,0 +1,178 @@ +import { + HttpClient, + HttpErrorResponse, + HttpHeaders, +} from '@angular/common/http'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { CART_ACCESS_CODE_NORMALIZER } from '@spartacus/cart/base/core'; +import { + BaseOccUrlProperties, + ConverterService, + DynamicAttributes, + HttpErrorModel, + LoggerService, + OccEndpointsService, + normalizeHttpError, +} from '@spartacus/core'; +import { defer, of, throwError } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { OccCartAccessCodeAdapter } from './occ-cart-access-code.adapter'; + +const mockResult = 'mockCartAccessCodeToken'; + +class MockLoggerService implements Partial { + log(): void {} + warn(): void {} + error(): void {} + info(): void {} + debug(): void {} +} + +class MockOccEndpointsService implements Partial { + buildUrl( + endpoint: string, + _attributes?: DynamicAttributes, + _propertiesToOmit?: BaseOccUrlProperties + ) { + return this.getEndpoint(endpoint); + } + getEndpoint(endpoint: string) { + if (!endpoint.startsWith('/')) { + endpoint = '/' + endpoint; + } + return endpoint; + } +} + +const mockUserId = 'userId'; +const mockCartId = 'cartId'; + +const mock500Error = new HttpErrorResponse({ + error: 'error', + headers: new HttpHeaders().set('xxx', 'xxx'), + status: 500, + statusText: 'Unknown error', + url: '/xxx', +}); + +const mockNormalized500Error = normalizeHttpError( + mock500Error, + new MockLoggerService() +); + +describe(`OccCartAccessCodeAdapter`, () => { + let service: OccCartAccessCodeAdapter; + let httpMock: HttpTestingController; + let converter: ConverterService; + let occEndpointsService: OccEndpointsService; + let httpClient: HttpClient; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + OccCartAccessCodeAdapter, + { + provide: OccEndpointsService, + useClass: MockOccEndpointsService, + }, + { + provide: LoggerService, + useClass: MockLoggerService, + }, + ], + }); + + service = TestBed.inject(OccCartAccessCodeAdapter); + httpMock = TestBed.inject(HttpTestingController); + httpClient = TestBed.inject(HttpClient); + converter = TestBed.inject(ConverterService); + occEndpointsService = TestBed.inject(OccEndpointsService); + spyOn(converter, 'convert').and.callThrough(); + spyOn(converter, 'pipeable').and.callThrough(); + spyOn(occEndpointsService, 'buildUrl').and.callThrough(); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe(`getCartAccessCode`, () => { + it(`should get access code for a cart`, (done) => { + service + .getCartAccessCode(mockUserId, mockCartId) + .pipe(take(1)) + .subscribe((result: string | undefined) => { + expect(result).toEqual(mockResult); + done(); + }); + + const url = service['getCartAccessCodeEndpoint'](mockUserId, mockCartId); + const mockReq = httpMock.expectOne(url); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + mockReq.flush(mockResult); + expect(converter.pipeable).toHaveBeenCalledWith( + CART_ACCESS_CODE_NORMALIZER + ); + }); + + describe(`back-off`, () => { + it(`should successfully backOff on 500 error and recover after the 2nd retry`, fakeAsync(() => { + let calledTimes = -1; + + spyOn(httpClient, 'post').and.returnValue( + defer(() => { + calledTimes++; + if (calledTimes === 2) { + return of(mockResult); + } + return throwError(() => mock500Error); + }) + ); + + let result: string | undefined; + const subscription = service + .getCartAccessCode(mockUserId, mockCartId) + .pipe(take(1)) + .subscribe((res) => (result = res)); + + // 1*1*300 = 300 + tick(300); + expect(result).toEqual(undefined); + + // 2*2*300 = 1200 + tick(1200); + + expect(result).toEqual(mockResult); + subscription.unsubscribe(); + })); + + it(`should unsuccessfully backOff on 500 error`, fakeAsync(() => { + spyOn(httpClient, 'post').and.returnValue( + throwError(() => mock500Error) + ); + + let result: HttpErrorModel | undefined; + const subscription = service + .getCartAccessCode(mockUserId, mockCartId) + .subscribe({ error: (err) => (result = err) }); + + tick(4200); + + expect(result).toEqual(mockNormalized500Error); + + subscription.unsubscribe(); + })); + }); + }); +}); diff --git a/feature-libs/cart/base/occ/adapters/occ-cart-access-code.adapter.ts b/feature-libs/cart/base/occ/adapters/occ-cart-access-code.adapter.ts new file mode 100644 index 00000000000..3bd14ef5037 --- /dev/null +++ b/feature-libs/cart/base/occ/adapters/occ-cart-access-code.adapter.ts @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { + CART_ACCESS_CODE_NORMALIZER, + CartAccessCodeAdapter, +} from '@spartacus/cart/base/core'; +import { + ConverterService, + LoggerService, + OccEndpointsService, + backOff, + isServerError, + normalizeHttpError, +} from '@spartacus/core'; +import { Observable, catchError } from 'rxjs'; + +@Injectable() +export class OccCartAccessCodeAdapter implements CartAccessCodeAdapter { + protected logger = inject(LoggerService); + + constructor( + protected http: HttpClient, + protected occEndpointsService: OccEndpointsService, + protected converterService: ConverterService + ) {} + + getCartAccessCode( + userId: string, + cartId: string + ): Observable { + return this.http + .post< + string | undefined + >(this.getCartAccessCodeEndpoint(userId, cartId), null) + .pipe( + catchError((error) => { + throw normalizeHttpError(error, this.logger); + }), + backOff({ + shouldRetry: isServerError, + maxTries: 2, + }), + this.converterService.pipeable(CART_ACCESS_CODE_NORMALIZER) + ); + } + + protected getCartAccessCodeEndpoint(userId: string, cartId: string): string { + return this.occEndpointsService.buildUrl('cartAccessCode', { + urlParams: { + userId, + cartId, + }, + }); + } +} diff --git a/feature-libs/cart/base/occ/adapters/occ-cart-guest-user.adapter.spec.ts b/feature-libs/cart/base/occ/adapters/occ-cart-guest-user.adapter.spec.ts new file mode 100644 index 00000000000..bce4dead4d6 --- /dev/null +++ b/feature-libs/cart/base/occ/adapters/occ-cart-guest-user.adapter.spec.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { + BaseOccUrlProperties, + ConverterService, + DynamicAttributes, + OccEndpointsService, +} from '@spartacus/core'; +import { OccCartGuestUserAdapter } from './occ-cart-guest-user.adapter'; + +const userId = 'userId'; +const cartId = 'cartId'; + +class MockOccEndpointsService { + buildUrl( + endpoint: string, + _attributes?: DynamicAttributes, + _propertiesToOmit?: BaseOccUrlProperties + ) { + return this.getEndpoint(endpoint); + } + getEndpoint(url: string) { + return url; + } +} + +const endpointName = 'cartGuestUser'; + +describe('OccCartGuestUserAdapter', () => { + let occCartGuestUserAdapter: OccCartGuestUserAdapter; + let httpMock: HttpTestingController; + let converterService: ConverterService; + let occEndpointService: OccEndpointsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + OccCartGuestUserAdapter, + { provide: OccEndpointsService, useClass: MockOccEndpointsService }, + ], + }); + + occCartGuestUserAdapter = TestBed.inject(OccCartGuestUserAdapter); + httpMock = TestBed.inject(HttpTestingController); + converterService = TestBed.inject(ConverterService); + occEndpointService = TestBed.inject(OccEndpointsService); + + spyOn(converterService, 'pipeable').and.callThrough(); + spyOn(occEndpointService, 'buildUrl').and.callThrough(); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe('create a cart guest user', () => { + it('should be able to create a new guest user for a given cart id', () => { + occCartGuestUserAdapter.createCartGuestUser(userId, cartId).subscribe(); + + const mockReq = httpMock.expectOne((req) => { + return req.method === 'POST' && req.url === endpointName; + }); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(occEndpointService.buildUrl).toHaveBeenCalledWith(endpointName, { + urlParams: { userId, cartId }, + }); + }); + }); + + describe('update a cart guest user', () => { + it('should be able to update a guest user for a given cart id', () => { + occCartGuestUserAdapter + .updateCartGuestUser(userId, cartId, { email: 'test@sap.com' }) + .subscribe(); + + const mockReq = httpMock.expectOne((req) => { + return req.method === 'PATCH' && req.url === endpointName; + }); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + expect(occEndpointService.buildUrl).toHaveBeenCalledWith(endpointName, { + urlParams: { userId, cartId }, + }); + }); + }); +}); diff --git a/feature-libs/cart/base/occ/adapters/occ-cart-guest-user.adapter.ts b/feature-libs/cart/base/occ/adapters/occ-cart-guest-user.adapter.ts new file mode 100644 index 00000000000..19c4290c263 --- /dev/null +++ b/feature-libs/cart/base/occ/adapters/occ-cart-guest-user.adapter.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { + CART_GUEST_USER_NORMALIZER, + CartGuestUserAdapter, +} from '@spartacus/cart/base/core'; +import { CartGuestUser } from '@spartacus/cart/base/root'; +import { + ConverterService, + InterceptorUtil, + LoggerService, + OccEndpointsService, + USE_CLIENT_TOKEN, + backOff, + isServerError, + tryNormalizeHttpError, +} from '@spartacus/core'; +import { Observable, catchError } from 'rxjs'; + +const CONTENT_TYPE_JSON_HEADER = { 'Content-Type': 'application/json' }; + +@Injectable() +export class OccCartGuestUserAdapter implements CartGuestUserAdapter { + protected logger = inject(LoggerService); + protected http = inject(HttpClient); + protected occEndpointsService = inject(OccEndpointsService); + protected converterService = inject(ConverterService); + + protected getRequestHeaders(): HttpHeaders { + return InterceptorUtil.createHeader( + USE_CLIENT_TOKEN, + true, + new HttpHeaders({ + ...CONTENT_TYPE_JSON_HEADER, + }) + ); + } + + createCartGuestUser( + userId: string, + cartId: string, + guestUserDetails?: CartGuestUser + ): Observable { + return this.http + .post( + this.getCartGuestUserEndpoint(userId, cartId), + guestUserDetails, + { headers: this.getRequestHeaders() } + ) + .pipe( + catchError((error) => { + throw tryNormalizeHttpError(error, this.logger); + }), + backOff({ + shouldRetry: isServerError, + maxTries: 2, + }), + this.converterService.pipeable(CART_GUEST_USER_NORMALIZER) + ); + } + + updateCartGuestUser( + userId: string, + cartId: string, + guestUserDetails: CartGuestUser + ): Observable { + return this.http + .patch( + this.getCartGuestUserEndpoint(userId, cartId), + guestUserDetails, + { headers: this.getRequestHeaders() } + ) + .pipe( + catchError((error) => { + throw tryNormalizeHttpError(error, this.logger); + }), + backOff({ + shouldRetry: isServerError, + maxTries: 2, + }), + this.converterService.pipeable(CART_GUEST_USER_NORMALIZER) + ); + } + + protected getCartGuestUserEndpoint(userId: string, cartId: string): string { + return this.occEndpointsService.buildUrl('cartGuestUser', { + urlParams: { + userId, + cartId, + }, + }); + } +} diff --git a/feature-libs/cart/base/occ/cart-base-occ.module.ts b/feature-libs/cart/base/occ/cart-base-occ.module.ts index 2bd35931405..9b286f609d0 100644 --- a/feature-libs/cart/base/occ/cart-base-occ.module.ts +++ b/feature-libs/cart/base/occ/cart-base-occ.module.ts @@ -7,8 +7,10 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { + CartAccessCodeAdapter, CartAdapter, CartEntryAdapter, + CartGuestUserAdapter, CartValidationAdapter, CartVoucherAdapter, } from '@spartacus/cart/base/core'; @@ -19,7 +21,9 @@ import { import { provideDefaultConfigFactory } from '@spartacus/core'; import { OccCartNormalizer } from './adapters/converters/occ-cart-normalizer'; import { OrderEntryPromotionsNormalizer } from './adapters/converters/order-entry-promotions-normalizer'; +import { OccCartAccessCodeAdapter } from './adapters/occ-cart-access-code.adapter'; import { OccCartEntryAdapter } from './adapters/occ-cart-entry.adapter'; +import { OccCartGuestUserAdapter } from './adapters/occ-cart-guest-user.adapter'; import { OccCartValidationAdapter } from './adapters/occ-cart-validation.adapter'; import { OccCartVoucherAdapter } from './adapters/occ-cart-voucher.adapter'; import { OccCartAdapter } from './adapters/occ-cart.adapter'; @@ -55,6 +59,14 @@ import { defaultOccCartConfigFactory } from './config/default-occ-cart-config-fa provide: CartValidationAdapter, useClass: OccCartValidationAdapter, }, + { + provide: CartAccessCodeAdapter, + useClass: OccCartAccessCodeAdapter, + }, + { + provide: CartGuestUserAdapter, + useClass: OccCartGuestUserAdapter, + }, ], }) export class CartBaseOccModule {} diff --git a/feature-libs/cart/base/occ/config/default-occ-cart-config-factory.ts b/feature-libs/cart/base/occ/config/default-occ-cart-config-factory.ts index 9843ea64eea..4d4d56a1e5d 100644 --- a/feature-libs/cart/base/occ/config/default-occ-cart-config-factory.ts +++ b/feature-libs/cart/base/occ/config/default-occ-cart-config-factory.ts @@ -4,8 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { FeatureToggles, OccConfig } from '@spartacus/core'; import { inject } from '@angular/core'; +import { FeatureToggles, OccConfig } from '@spartacus/core'; export function defaultOccCartConfigFactory(): OccConfig { const featureToggles = inject(FeatureToggles); @@ -32,6 +32,8 @@ export function defaultOccCartConfigFactory(): OccConfig { ? '/users/${userId}/carts/${cartId}/save' : '/users/${userId}/carts/${cartId}/save?saveCartName=${saveCartName}&saveCartDescription=${saveCartDescription}', validate: 'users/${userId}/carts/${cartId}/validate?fields=DEFAULT', + cartAccessCode: 'users/${userId}/carts/${cartId}/accessCode', + cartGuestUser: 'users/${userId}/carts/${cartId}/guestuser', /* eslint-enable */ }, }, diff --git a/feature-libs/cart/base/occ/model/occ-cart-endpoints.model.ts b/feature-libs/cart/base/occ/model/occ-cart-endpoints.model.ts index be9ac832e3a..7b3e564b05d 100644 --- a/feature-libs/cart/base/occ/model/occ-cart-endpoints.model.ts +++ b/feature-libs/cart/base/occ/model/occ-cart-endpoints.model.ts @@ -72,6 +72,14 @@ export interface CartOccEndpoints { * Get cart validation results */ validate?: string | OccEndpoint; + /** + * Generates an access code for a cart + */ + cartAccessCode?: string | OccEndpoint; + /** + * Creates a guest user, and assigns the user to the cart + */ + cartGuestUser?: string | OccEndpoint; } declare module '@spartacus/core' { diff --git a/feature-libs/cart/base/root/facade/cart-access-code.facade.ts b/feature-libs/cart/base/root/facade/cart-access-code.facade.ts new file mode 100644 index 00000000000..454ed7cc071 --- /dev/null +++ b/feature-libs/cart/base/root/facade/cart-access-code.facade.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { facadeFactory } from '@spartacus/core'; +import { Observable } from 'rxjs'; +import { CART_BASE_CORE_FEATURE } from '../feature-name'; + +@Injectable({ + providedIn: 'root', + useFactory: () => + facadeFactory({ + facade: CartAccessCodeFacade, + feature: CART_BASE_CORE_FEATURE, + methods: ['getCartAccessCode'], + }), +}) +export abstract class CartAccessCodeFacade { + /** + * Generates an access code for a specified cart. + */ + abstract getCartAccessCode( + userId: string, + cartId: string + ): Observable; +} diff --git a/feature-libs/cart/base/root/facade/cart-guest-user.facade.ts b/feature-libs/cart/base/root/facade/cart-guest-user.facade.ts new file mode 100644 index 00000000000..9c50089cbd9 --- /dev/null +++ b/feature-libs/cart/base/root/facade/cart-guest-user.facade.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { facadeFactory } from '@spartacus/core'; +import { Observable } from 'rxjs'; +import { CART_BASE_CORE_FEATURE } from '../feature-name'; +import { CartGuestUser } from '../models'; + +@Injectable({ + providedIn: 'root', + useFactory: () => + facadeFactory({ + facade: CartGuestUserFacade, + feature: CART_BASE_CORE_FEATURE, + methods: ['createCartGuestUser', 'updateCartGuestUser'], + }), +}) +export abstract class CartGuestUserFacade { + /** + * Creates guest user and assigns user to the cart. + */ + abstract createCartGuestUser( + userId: string, + cartId: string, + guestUserDetails?: CartGuestUser + ): Observable; + + /** + * Updates guest user details. + */ + abstract updateCartGuestUser( + userId: string, + cartId: string, + guestUserDetails: CartGuestUser + ): Observable; +} diff --git a/feature-libs/cart/base/root/facade/index.ts b/feature-libs/cart/base/root/facade/index.ts index e8b7d38b1de..28d18375471 100644 --- a/feature-libs/cart/base/root/facade/index.ts +++ b/feature-libs/cart/base/root/facade/index.ts @@ -5,6 +5,8 @@ */ export * from './active-cart.facade'; +export * from './cart-access-code.facade'; +export * from './cart-guest-user.facade'; export * from './cart-validation.facade'; export * from './cart-voucher.facade'; export * from './multi-cart.facade'; diff --git a/feature-libs/cart/base/root/models/cart-guest-user.model.ts b/feature-libs/cart/base/root/models/cart-guest-user.model.ts new file mode 100644 index 00000000000..ea28ddb9758 --- /dev/null +++ b/feature-libs/cart/base/root/models/cart-guest-user.model.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface CartGuestUser { + name?: string; + uid?: string; + email?: string; +} diff --git a/feature-libs/cart/base/root/models/cart.model.ts b/feature-libs/cart/base/root/models/cart.model.ts index 14f814735ee..0ed2703f36a 100644 --- a/feature-libs/cart/base/root/models/cart.model.ts +++ b/feature-libs/cart/base/root/models/cart.model.ts @@ -80,6 +80,8 @@ export interface Cart { orderDiscounts?: Price; paymentInfo?: PaymentDetails; paymentType?: PaymentType; + paymentAddress?: Address; + sapBillingAddress?: Address; pickupItemsQuantity?: number; pickupOrderGroups?: PickupOrderEntryGroup[]; potentialOrderPromotions?: PromotionResult[]; diff --git a/feature-libs/cart/base/root/models/index.ts b/feature-libs/cart/base/root/models/index.ts index c3d982a4312..b3b28ed6986 100644 --- a/feature-libs/cart/base/root/models/index.ts +++ b/feature-libs/cart/base/root/models/index.ts @@ -4,9 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from './augmented.model'; +export * from './cart-guest-user.model'; export * from './cart-item-context.model'; export * from './cart-outlets.model'; export * from './cart.model'; export * from './import-export.model'; export * from './import-to-cart.model'; -export * from './augmented.model'; diff --git a/feature-libs/cart/tsconfig.schematics.json b/feature-libs/cart/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/feature-libs/cart/tsconfig.schematics.json +++ b/feature-libs/cart/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/checkout/b2b/components/checkout-delivery-address/checkout-delivery-address.component.spec.ts b/feature-libs/checkout/b2b/components/checkout-delivery-address/checkout-delivery-address.component.spec.ts index 97ff6354f0e..31c8a5923c9 100644 --- a/feature-libs/checkout/b2b/components/checkout-delivery-address/checkout-delivery-address.component.spec.ts +++ b/feature-libs/checkout/b2b/components/checkout-delivery-address/checkout-delivery-address.component.spec.ts @@ -7,7 +7,10 @@ import { CheckoutCostCenterFacade, CheckoutPaymentTypeFacade, } from '@spartacus/checkout/b2b/root'; -import { CheckoutStepService } from '@spartacus/checkout/base/components'; +import { + CheckoutFlowOrchestratorService, + CheckoutStepService, +} from '@spartacus/checkout/base/components'; import { CheckoutDeliveryAddressFacade, CheckoutDeliveryModesFacade, @@ -45,6 +48,12 @@ class MockCheckoutDeliveryAddressFacade ); } +class MockCheckoutFlowOrchestratorService + implements Partial +{ + getCheckoutFlow = createSpy(); +} + class MockCheckoutStepService implements Partial { next = createSpy(); back = createSpy(); @@ -195,6 +204,10 @@ describe('B2BCheckoutDeliveryAddressComponent', () => { provide: CheckoutDeliveryModesFacade, useClass: MockCheckoutDeliveryModesFacade, }, + { + provide: CheckoutFlowOrchestratorService, + useClass: MockCheckoutFlowOrchestratorService, + }, ], }) .overrideComponent(B2BCheckoutDeliveryAddressComponent, { diff --git a/feature-libs/checkout/base/components/checkout-delivery-address/checkout-delivery-address.component.spec.ts b/feature-libs/checkout/base/components/checkout-delivery-address/checkout-delivery-address.component.spec.ts index a485620f520..782e22c6dba 100644 --- a/feature-libs/checkout/base/components/checkout-delivery-address/checkout-delivery-address.component.spec.ts +++ b/feature-libs/checkout/base/components/checkout-delivery-address/checkout-delivery-address.component.spec.ts @@ -17,6 +17,7 @@ import { } from '@spartacus/core'; import { Card } from '@spartacus/storefront'; import { EMPTY, of } from 'rxjs'; +import { CheckoutFlowOrchestratorService } from '../services/checkout-flow-orchestrator.service'; import { CheckoutStepService } from '../services/checkout-step.service'; import { CheckoutDeliveryAddressComponent } from './checkout-delivery-address.component'; import createSpy = jasmine.createSpy; @@ -47,6 +48,12 @@ class MockCheckoutStepService implements Partial { getBackBntText = createSpy().and.returnValue('common.back'); } +class MockCheckoutFlowOrchestratorService + implements Partial +{ + getCheckoutFlow = createSpy(); +} + class MockGlobalMessageService implements Partial { add = createSpy(); } @@ -168,6 +175,10 @@ describe('CheckoutDeliveryAddressComponent', () => { features: { level: '6.3' }, }, }, + { + provide: CheckoutFlowOrchestratorService, + useClass: MockCheckoutFlowOrchestratorService, + }, { provide: FeatureConfigService, useClass: MockFeatureConfigService, diff --git a/feature-libs/checkout/base/components/guards/checkout.guard.ts b/feature-libs/checkout/base/components/guards/checkout.guard.ts index e334ae28213..dc89b250969 100644 --- a/feature-libs/checkout/base/components/guards/checkout.guard.ts +++ b/feature-libs/checkout/base/components/guards/checkout.guard.ts @@ -23,7 +23,7 @@ export class CheckoutGuard { this.checkoutStepService.steps$.pipe( map((steps) => { return this.router.parseUrl( - this.routingConfigService.getRouteConfig(steps[0].routeName) + this.routingConfigService.getRouteConfig(steps[0]?.routeName) ?.paths?.[0] as string ); }) diff --git a/feature-libs/checkout/base/components/services/checkout-config.service.spec.ts b/feature-libs/checkout/base/components/services/checkout-config.service.spec.ts index 95dc77390bc..e4f89bfca7b 100644 --- a/feature-libs/checkout/base/components/services/checkout-config.service.spec.ts +++ b/feature-libs/checkout/base/components/services/checkout-config.service.spec.ts @@ -1,10 +1,12 @@ import { TestBed } from '@angular/core/testing'; import { CheckoutConfig, + CheckoutFlow, DeliveryModePreferences, } from '@spartacus/checkout/base/root'; import { defaultCheckoutConfig } from '../../root/config/default-checkout-config'; import { CheckoutConfigService } from './checkout-config.service'; +import { CheckoutFlowOrchestratorService } from './checkout-flow-orchestrator.service'; const mockCheckoutConfig: CheckoutConfig = JSON.parse( JSON.stringify(defaultCheckoutConfig) @@ -21,6 +23,14 @@ const [freeMode, standardMode, premiumMode] = [ { deliveryCost: { value: 3 }, code: PREMIUM_CODE }, ]; +class MockCheckoutFlowOrchestratorService + implements Partial +{ + getCheckoutFlow(): CheckoutFlow | undefined { + return {}; + } +} + describe('CheckoutConfigService', () => { let service: CheckoutConfigService; @@ -29,10 +39,14 @@ describe('CheckoutConfigService', () => { providers: [ CheckoutConfigService, { provide: mockCheckoutConfig, useClass: CheckoutConfig }, + { + provide: CheckoutFlowOrchestratorService, + useClass: MockCheckoutFlowOrchestratorService, + }, ], }); - service = new CheckoutConfigService(mockCheckoutConfig); + service = TestBed.inject(CheckoutConfigService); }); it('should be created', () => { diff --git a/feature-libs/checkout/base/components/services/checkout-config.service.ts b/feature-libs/checkout/base/components/services/checkout-config.service.ts index 7e10d199182..8c3ea8b3e83 100644 --- a/feature-libs/checkout/base/components/services/checkout-config.service.ts +++ b/feature-libs/checkout/base/components/services/checkout-config.service.ts @@ -4,23 +4,37 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { DeliveryMode } from '@spartacus/cart/base/root'; import { CheckoutConfig, DeliveryModePreferences, } from '@spartacus/checkout/base/root'; +import { CheckoutFlowOrchestratorService } from './checkout-flow-orchestrator.service'; @Injectable({ providedIn: 'root', }) export class CheckoutConfigService { + protected checkoutFlowOrchestratorService = inject( + CheckoutFlowOrchestratorService + ); + private express: boolean = this.checkoutConfig.checkout?.express ?? false; private guest: boolean = this.checkoutConfig.checkout?.guest ?? false; private defaultDeliveryMode: Array = this.checkoutConfig.checkout?.defaultDeliveryMode || []; - constructor(private checkoutConfig: CheckoutConfig) {} + protected checkoutFlow = + this.checkoutFlowOrchestratorService?.getCheckoutFlow(); + + constructor(private checkoutConfig: CheckoutConfig) { + if (this.checkoutFlowOrchestratorService) { + this.express = this.checkoutFlow?.express ?? false; + this.guest = this.checkoutFlow?.guest ?? false; + this.defaultDeliveryMode = this.checkoutFlow?.defaultDeliveryMode || []; + } + } protected compareDeliveryCost( deliveryMode1: DeliveryMode, @@ -77,6 +91,10 @@ export class CheckoutConfigService { } shouldUseAddressSavedInCart(): boolean { + if (this.checkoutFlowOrchestratorService) { + return !!this.checkoutFlow?.guestUseSavedAddress; + } + return !!this.checkoutConfig?.checkout?.guestUseSavedAddress; } diff --git a/feature-libs/checkout/base/components/services/checkout-flow-orchestrator.service.spec.ts b/feature-libs/checkout/base/components/services/checkout-flow-orchestrator.service.spec.ts new file mode 100644 index 00000000000..0cd5757e988 --- /dev/null +++ b/feature-libs/checkout/base/components/services/checkout-flow-orchestrator.service.spec.ts @@ -0,0 +1,68 @@ +import { TestBed } from '@angular/core/testing'; +import { StoreModule } from '@ngrx/store'; +import { BaseSiteService } from '@spartacus/core'; +import { of } from 'rxjs'; +import { CheckoutConfig } from '../../root/config'; +import { CheckoutFlowOrchestratorService } from './checkout-flow-orchestrator.service'; + +const mockFlowName = 'test-flow'; + +const mockCheckoutConfig: CheckoutConfig = { + checkout: { + flows: { + [mockFlowName]: { + steps: [], + guest: false, + }, + }, + }, +}; + +describe('CheckoutFlowOrchestratorService', () => { + let service: CheckoutFlowOrchestratorService; + let baseSiteService: BaseSiteService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({})], + providers: [CheckoutFlowOrchestratorService, BaseSiteService], + }); + + baseSiteService = TestBed.inject(BaseSiteService); + service = new CheckoutFlowOrchestratorService( + mockCheckoutConfig, + baseSiteService + ); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should get payment provider', () => { + const mockPaymentProvider = 'MockPaymentProvider'; + spyOn(baseSiteService, 'get').and.returnValue( + of({ baseStore: { paymentProvider: mockPaymentProvider } }) + ); + + service.getPaymentProvider().subscribe((paymentProvider) => { + expect(paymentProvider).toEqual(mockPaymentProvider); + }); + }); + + it('should get checkout flow when payment provider is defined', () => { + service['paymentProviderName'] = mockFlowName; + + const checkoutFlow = service.getCheckoutFlow(); + expect(checkoutFlow).toEqual( + mockCheckoutConfig.checkout?.flows?.[mockFlowName] + ); + }); + + it('should fallback to the default config when payment provider is undefined', () => { + service['paymentProviderName'] = undefined; + + const checkoutFlow = service.getCheckoutFlow(); + expect(checkoutFlow).toBeDefined(); + }); +}); diff --git a/feature-libs/checkout/base/components/services/checkout-flow-orchestrator.service.ts b/feature-libs/checkout/base/components/services/checkout-flow-orchestrator.service.ts new file mode 100644 index 00000000000..46f568dc540 --- /dev/null +++ b/feature-libs/checkout/base/components/services/checkout-flow-orchestrator.service.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { CheckoutConfig, CheckoutFlow } from '@spartacus/checkout/base/root'; +import { BaseSiteService } from '@spartacus/core'; +import { Observable } from 'rxjs'; +import { map, take } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root', +}) +export class CheckoutFlowOrchestratorService { + constructor( + protected checkoutConfig: CheckoutConfig, + protected baseSiteService: BaseSiteService + ) { + this.getPaymentProvider().subscribe((paymentProvider) => { + this.paymentProviderName = paymentProvider; + }); + } + + protected paymentProviderName: string | undefined = undefined; + + getPaymentProvider(): Observable { + return this.baseSiteService.get().pipe( + take(1), + map((baseSite) => baseSite?.baseStore?.paymentProvider) + ); + } + + getCheckoutFlow(): CheckoutFlow | undefined { + if (this.paymentProviderName) { + const flow = + this.checkoutConfig.checkout?.flows?.[this.paymentProviderName]; + if (flow) { + return flow; + } + } + return this.checkoutConfig.checkout; + } +} diff --git a/feature-libs/checkout/base/components/services/checkout-step.service.spec.ts b/feature-libs/checkout/base/components/services/checkout-step.service.spec.ts index 12c40d2f692..652ff9624f5 100644 --- a/feature-libs/checkout/base/components/services/checkout-step.service.spec.ts +++ b/feature-libs/checkout/base/components/services/checkout-step.service.spec.ts @@ -11,6 +11,7 @@ import { RoutingService, } from '@spartacus/core'; import { of } from 'rxjs'; +import { CheckoutFlowOrchestratorService } from './checkout-flow-orchestrator.service'; import { CheckoutStepService } from './checkout-step.service'; import createSpy = jasmine.createSpy; @@ -65,30 +66,35 @@ class MockRoutingService implements Partial { ); } +class MockCheckoutFlowOrchestratorService + implements Partial +{ + getCheckoutFlow() { + return checkoutConfig.checkout; + } +} + describe('CheckoutStepService', () => { let service: CheckoutStepService; let routingService: RoutingService; - let routingConfigService: RoutingConfigService; beforeEach(() => { TestBed.configureTestingModule({ providers: [ CheckoutStepService, { provide: RoutingService, useClass: MockRoutingService }, + { provide: CheckoutConfig, useValue: checkoutConfig }, { provide: RoutingConfigService, useClass: MockRoutingConfigService }, + { + provide: CheckoutFlowOrchestratorService, + useClass: MockCheckoutFlowOrchestratorService, + }, ], }); + TestBed.inject(CheckoutConfig); routingService = TestBed.inject(RoutingService as Type); - routingConfigService = TestBed.inject( - RoutingConfigService as Type - ); - - service = new CheckoutStepService( - routingService, - checkoutConfig, - routingConfigService - ); + service = TestBed.inject(CheckoutStepService as Type); }); it('should be created', () => { diff --git a/feature-libs/checkout/base/components/services/checkout-step.service.ts b/feature-libs/checkout/base/components/services/checkout-step.service.ts index 53e4095ec24..c3b3ce84ff5 100644 --- a/feature-libs/checkout/base/components/services/checkout-step.service.ts +++ b/feature-libs/checkout/base/components/services/checkout-step.service.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { CheckoutConfig, @@ -14,11 +14,16 @@ import { import { RoutingConfigService, RoutingService } from '@spartacus/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; +import { CheckoutFlowOrchestratorService } from './checkout-flow-orchestrator.service'; @Injectable({ providedIn: 'root', }) export class CheckoutStepService { + protected checkoutFlowOrchestratorService = inject( + CheckoutFlowOrchestratorService + ); + // initial enabled steps allSteps: CheckoutStep[]; @@ -36,7 +41,7 @@ export class CheckoutStepService { let activeIndex: number = 0; steps.forEach((step, index) => { const routeUrl = `/${ - this.routingConfigService.getRouteConfig(step.routeName) + this.routingConfigService.getRouteConfig(step?.routeName) ?.paths?.[0] }`; if (routeUrl === activeStepUrl) { @@ -81,7 +86,14 @@ export class CheckoutStepService { } resetSteps(): void { - this.allSteps = (this.checkoutConfig.checkout?.steps ?? []) + let steps = this.checkoutConfig.checkout?.steps ?? []; + + if (this.checkoutFlowOrchestratorService) { + steps = + this.checkoutFlowOrchestratorService.getCheckoutFlow()?.steps ?? []; + } + + this.allSteps = steps .filter((step) => !step.disabled) .map((checkoutStep) => Object.assign({}, checkoutStep)); this.steps$.next(this.allSteps); diff --git a/feature-libs/checkout/base/components/services/index.ts b/feature-libs/checkout/base/components/services/index.ts index 14e9252add6..2c0e8ff3e5b 100644 --- a/feature-libs/checkout/base/components/services/index.ts +++ b/feature-libs/checkout/base/components/services/index.ts @@ -5,5 +5,6 @@ */ export * from './checkout-config.service'; +export * from './checkout-flow-orchestrator.service'; export * from './checkout-step.service'; export * from './express-checkout.service'; diff --git a/feature-libs/checkout/base/core/checkout-core.module.ts b/feature-libs/checkout/base/core/checkout-core.module.ts index 4bea49574a7..d70552c09a3 100644 --- a/feature-libs/checkout/base/core/checkout-core.module.ts +++ b/feature-libs/checkout/base/core/checkout-core.module.ts @@ -6,6 +6,7 @@ import { NgModule } from '@angular/core'; import { PageMetaResolver } from '@spartacus/core'; +import { CheckoutBillingAddressConnector } from './connectors/checkout-billing-address/checkout-billing-address.connector'; import { CheckoutDeliveryAddressConnector } from './connectors/checkout-delivery-address/checkout-delivery-address.connector'; import { CheckoutDeliveryModesConnector } from './connectors/checkout-delivery-modes/checkout-delivery-modes.connector'; import { CheckoutPaymentConnector } from './connectors/checkout-payment/checkout-payment.connector'; @@ -17,6 +18,7 @@ import { CheckoutPageMetaResolver } from './services/checkout-page-meta.resolver providers: [ ...facadeProviders, CheckoutDeliveryAddressConnector, + CheckoutBillingAddressConnector, CheckoutDeliveryModesConnector, CheckoutPaymentConnector, CheckoutConnector, diff --git a/feature-libs/checkout/base/core/connectors/checkout-billing-address/checkout-billing-address.adapter.ts b/feature-libs/checkout/base/core/connectors/checkout-billing-address/checkout-billing-address.adapter.ts new file mode 100644 index 00000000000..a3845f3d64a --- /dev/null +++ b/feature-libs/checkout/base/core/connectors/checkout-billing-address/checkout-billing-address.adapter.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Address } from '@spartacus/core'; +import { Observable } from 'rxjs'; + +export abstract class CheckoutBillingAddressAdapter { + /** + * Abstract method used to set address for billing + * + * @param userId + * @param cartId + * @param addressId + */ + abstract setBillingAddress( + userId: string, + cartId: string, + address: Address + ): Observable; +} diff --git a/feature-libs/checkout/base/core/connectors/checkout-billing-address/checkout-billing-address.connector.spec.ts b/feature-libs/checkout/base/core/connectors/checkout-billing-address/checkout-billing-address.connector.spec.ts new file mode 100644 index 00000000000..7b74a360c75 --- /dev/null +++ b/feature-libs/checkout/base/core/connectors/checkout-billing-address/checkout-billing-address.connector.spec.ts @@ -0,0 +1,46 @@ +import { TestBed } from '@angular/core/testing'; +import { Address } from '@spartacus/core'; +import { of } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { CheckoutBillingAddressAdapter } from './checkout-billing-address.adapter'; +import { CheckoutBillingAddressConnector } from './checkout-billing-address.connector'; +import createSpy = jasmine.createSpy; + +class MockCheckoutBillingAddressAdapter + implements CheckoutBillingAddressAdapter +{ + setBillingAddress = createSpy().and.returnValue(of({})); +} + +describe('CheckoutBillingAddressConnector', () => { + let service: CheckoutBillingAddressConnector; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + CheckoutBillingAddressConnector, + { + provide: CheckoutBillingAddressAdapter, + useClass: MockCheckoutBillingAddressAdapter, + }, + ], + }); + + service = TestBed.inject(CheckoutBillingAddressConnector); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('setAddress should call adapter', () => { + const adapter = TestBed.inject(CheckoutBillingAddressAdapter); + service + .setBillingAddress('1', '2', { town: 'Berlin' } as Address) + .pipe(take(1)) + .subscribe(); + expect(adapter.setBillingAddress).toHaveBeenCalledWith('1', '2', { + town: 'Berlin', + }); + }); +}); diff --git a/feature-libs/checkout/base/core/connectors/checkout-billing-address/checkout-billing-address.connector.ts b/feature-libs/checkout/base/core/connectors/checkout-billing-address/checkout-billing-address.connector.ts new file mode 100644 index 00000000000..d8522255708 --- /dev/null +++ b/feature-libs/checkout/base/core/connectors/checkout-billing-address/checkout-billing-address.connector.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { Address } from '@spartacus/core'; +import { Observable } from 'rxjs'; +import { CheckoutBillingAddressAdapter } from './checkout-billing-address.adapter'; + +@Injectable() +export class CheckoutBillingAddressConnector { + constructor(protected adapter: CheckoutBillingAddressAdapter) {} + + public setBillingAddress( + userId: string, + cartId: string, + address: Address + ): Observable { + return this.adapter.setBillingAddress(userId, cartId, address); + } +} diff --git a/feature-libs/checkout/base/core/connectors/checkout-billing-address/index.ts b/feature-libs/checkout/base/core/connectors/checkout-billing-address/index.ts new file mode 100644 index 00000000000..83e34d1fd10 --- /dev/null +++ b/feature-libs/checkout/base/core/connectors/checkout-billing-address/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './checkout-billing-address.adapter'; diff --git a/feature-libs/checkout/base/core/connectors/index.ts b/feature-libs/checkout/base/core/connectors/index.ts index f0d401d4ce1..1993503da69 100644 --- a/feature-libs/checkout/base/core/connectors/index.ts +++ b/feature-libs/checkout/base/core/connectors/index.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from './checkout-billing-address/index'; export * from './checkout-delivery-address/index'; export * from './checkout-delivery-modes/index'; export * from './checkout-payment/index'; diff --git a/feature-libs/checkout/base/core/facade/checkout-billing-address.service.spec.ts b/feature-libs/checkout/base/core/facade/checkout-billing-address.service.spec.ts new file mode 100644 index 00000000000..27ecba5b27d --- /dev/null +++ b/feature-libs/checkout/base/core/facade/checkout-billing-address.service.spec.ts @@ -0,0 +1,102 @@ +import { inject, TestBed } from '@angular/core/testing'; +import { ActiveCartFacade } from '@spartacus/cart/base/root'; +import { CheckoutQueryFacade } from '@spartacus/checkout/base/root'; +import { Address, OCC_USER_ID_CURRENT, UserIdService } from '@spartacus/core'; +import { of } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { CheckoutBillingAddressConnector } from '../connectors/checkout-billing-address/checkout-billing-address.connector'; +import { CheckoutBillingAddressService } from './checkout-billing-address.service'; +import createSpy = jasmine.createSpy; + +const mockUserId = OCC_USER_ID_CURRENT; +const mockCartId = 'cartID'; +const mockAddress: Partial
= { + id: 'test-address-id', +}; + +class MockActiveCartService implements Partial { + takeActiveCartId = createSpy().and.returnValue(of(mockCartId)); + isGuestCart = createSpy().and.returnValue(of(false)); +} + +class MockUserIdService implements Partial { + takeUserId = createSpy().and.returnValue(of(mockUserId)); +} + +class MockCheckoutBillingAddressConnector + implements Partial +{ + setBillingAddress = createSpy().and.returnValue(of('setAddress')); +} + +class MockCheckoutQueryFacade implements Partial { + getCheckoutDetailsState = createSpy().and.returnValue( + of({ loading: false, error: false, data: undefined }) + ); +} + +describe(`CheckoutBillingAddressService`, () => { + let service: CheckoutBillingAddressService; + let connector: CheckoutBillingAddressConnector; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + CheckoutBillingAddressService, + { provide: ActiveCartFacade, useClass: MockActiveCartService }, + { provide: UserIdService, useClass: MockUserIdService }, + { + provide: CheckoutBillingAddressConnector, + useClass: MockCheckoutBillingAddressConnector, + }, + { provide: CheckoutQueryFacade, useClass: MockCheckoutQueryFacade }, + ], + }); + + service = TestBed.inject(CheckoutBillingAddressService); + connector = TestBed.inject(CheckoutBillingAddressConnector); + }); + + it(`should inject CheckoutBillingAddressService`, inject( + [CheckoutBillingAddressService], + (checkoutBillingAddressService: CheckoutBillingAddressService) => { + expect(checkoutBillingAddressService).toBeTruthy(); + } + )); + + describe(`setBillingAddress`, () => { + it(`should throw an error if the address is not present`, (done) => { + service + .setBillingAddress(undefined as unknown as Address) + .pipe(take(1)) + .subscribe({ + error: (error) => { + expect(error).toEqual(new Error('Checkout conditions not met')); + done(); + }, + }); + }); + + it(`should throw an error if the address object is empty`, (done) => { + service + .setBillingAddress({}) + .pipe(take(1)) + .subscribe({ + error: (error) => { + expect(error).toEqual(new Error('Checkout conditions not met')); + done(); + }, + }); + }); + + it(`should call checkoutBillingConnector.setAddress`, () => { + service.setBillingAddress(mockAddress); + + expect(connector.setBillingAddress).toHaveBeenCalledWith( + mockUserId, + mockCartId, + mockAddress + ); + }); + }); +}); diff --git a/feature-libs/checkout/base/core/facade/checkout-billing-address.service.ts b/feature-libs/checkout/base/core/facade/checkout-billing-address.service.ts new file mode 100644 index 00000000000..1c529b3c714 --- /dev/null +++ b/feature-libs/checkout/base/core/facade/checkout-billing-address.service.ts @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { ActiveCartFacade } from '@spartacus/cart/base/root'; +import { + CheckoutBillingAddressFacade, + CheckoutQueryFacade, +} from '@spartacus/checkout/base/root'; +import { + Address, + CommandService, + CommandStrategy, + OCC_USER_ID_ANONYMOUS, + UserIdService, +} from '@spartacus/core'; +import { combineLatest, Observable } from 'rxjs'; +import { map, switchMap, take } from 'rxjs/operators'; +import { CheckoutBillingAddressConnector } from '../connectors/checkout-billing-address/checkout-billing-address.connector'; + +@Injectable() +export class CheckoutBillingAddressService + implements CheckoutBillingAddressFacade +{ + protected setBillingAddressCommand = this.commandService.create
( + (address) => + this.checkoutPreconditions().pipe( + switchMap(([userId, cartId]) => { + if (!address || !Object.keys(address)?.length) { + throw new Error('Checkout conditions not met'); + } + return this.checkoutBillingAddressConnector.setBillingAddress( + userId, + cartId, + address + ); + }) + ), + { + strategy: CommandStrategy.CancelPrevious, + } + ); + + constructor( + protected activeCartFacade: ActiveCartFacade, + protected userIdService: UserIdService, + protected commandService: CommandService, + protected checkoutBillingAddressConnector: CheckoutBillingAddressConnector, + protected checkoutQueryFacade: CheckoutQueryFacade + ) {} + + /** + * Performs the necessary checkout preconditions. + */ + protected checkoutPreconditions(): Observable<[string, string]> { + return combineLatest([ + this.userIdService.takeUserId(), + this.activeCartFacade.takeActiveCartId(), + this.activeCartFacade.isGuestCart(), + ]).pipe( + take(1), + map(([userId, cartId, isGuestCart]) => { + if ( + !userId || + !cartId || + (userId === OCC_USER_ID_ANONYMOUS && !isGuestCart) + ) { + throw new Error('Checkout conditions not met'); + } + return [userId, cartId]; + }) + ); + } + + setBillingAddress(address: Address): Observable { + return this.setBillingAddressCommand.execute(address); + } +} diff --git a/feature-libs/checkout/base/core/facade/facade-providers.ts b/feature-libs/checkout/base/core/facade/facade-providers.ts index 0a14731112e..cc042e0bb5f 100644 --- a/feature-libs/checkout/base/core/facade/facade-providers.ts +++ b/feature-libs/checkout/base/core/facade/facade-providers.ts @@ -6,11 +6,13 @@ import { Provider } from '@angular/core'; import { + CheckoutBillingAddressFacade, CheckoutDeliveryAddressFacade, CheckoutDeliveryModesFacade, CheckoutPaymentFacade, CheckoutQueryFacade, } from '@spartacus/checkout/base/root'; +import { CheckoutBillingAddressService } from './checkout-billing-address.service'; import { CheckoutDeliveryAddressService } from './checkout-delivery-address.service'; import { CheckoutDeliveryModesService } from './checkout-delivery-modes.service'; import { CheckoutPaymentService } from './checkout-payment.service'; @@ -37,4 +39,9 @@ export const facadeProviders: Provider[] = [ provide: CheckoutQueryFacade, useExisting: CheckoutQueryService, }, + CheckoutBillingAddressService, + { + provide: CheckoutBillingAddressFacade, + useExisting: CheckoutBillingAddressService, + }, ]; diff --git a/feature-libs/checkout/base/core/facade/index.ts b/feature-libs/checkout/base/core/facade/index.ts index 0de8696f7b0..29462b52200 100644 --- a/feature-libs/checkout/base/core/facade/index.ts +++ b/feature-libs/checkout/base/core/facade/index.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from './checkout-billing-address.service'; export * from './checkout-delivery-address.service'; export * from './checkout-delivery-modes.service'; export * from './checkout-payment.service'; diff --git a/feature-libs/checkout/base/occ/adapters/index.ts b/feature-libs/checkout/base/occ/adapters/index.ts index b791f3518cf..68b805e10f7 100644 --- a/feature-libs/checkout/base/occ/adapters/index.ts +++ b/feature-libs/checkout/base/occ/adapters/index.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from './occ-checkout-billing-address.adapter'; export * from './occ-checkout-delivery-address.adapter'; export * from './occ-checkout-delivery-modes.adapter'; export * from './occ-checkout-payment.adapter'; diff --git a/feature-libs/checkout/base/occ/adapters/occ-checkout-billing-address.adapter.spec.ts b/feature-libs/checkout/base/occ/adapters/occ-checkout-billing-address.adapter.spec.ts new file mode 100644 index 00000000000..1a87f43b381 --- /dev/null +++ b/feature-libs/checkout/base/occ/adapters/occ-checkout-billing-address.adapter.spec.ts @@ -0,0 +1,166 @@ +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { Cart } from '@spartacus/cart/base/root'; +import { + Address, + ConverterService, + HttpErrorModel, + LoggerService, + OccConfig, + OccEndpoints, + normalizeHttpError, +} from '@spartacus/core'; +import { defer, of, throwError } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { OccCheckoutBillingAddressAdapter } from './occ-checkout-billing-address.adapter'; + +const userId = '123'; +const cartId = '456'; +const cartData: Partial = { + store: 'electronics', + guid: '1212121', +}; + +const MockOccModuleConfig: OccConfig = { + backend: { + occ: { + baseUrl: '', + prefix: '', + endpoints: { + setBillingAddress: 'users/${userId}/carts/${cartId}/addresses/billing', + } as OccEndpoints, + }, + }, + context: { + baseSite: [''], + }, +}; + +const mockJaloError = new HttpErrorResponse({ + error: { + errors: [ + { + message: 'The application has encountered an error', + type: 'JaloObjectNoLongerValidError', + }, + ], + }, +}); + +describe(`OccCheckoutBillingAddressAdapter`, () => { + let service: OccCheckoutBillingAddressAdapter; + let httpClient: HttpClient; + let httpMock: HttpTestingController; + let converter: ConverterService; + let logger: LoggerService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + OccCheckoutBillingAddressAdapter, + { provide: OccConfig, useValue: MockOccModuleConfig }, + ], + }); + service = TestBed.inject(OccCheckoutBillingAddressAdapter); + httpClient = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + converter = TestBed.inject(ConverterService); + logger = TestBed.inject(LoggerService); + + spyOn(converter, 'pipeable').and.callThrough(); + spyOn(converter, 'pipeableMany').and.callThrough(); + spyOn(converter, 'convert').and.callThrough(); + }); + + afterEach(() => { + httpMock.verify(); + }); + + describe(`setAddress`, () => { + const address: Address = { country: 'Poland' } as Address; + + it(`should set address for cart for given user id, cart id and address id`, (done) => { + service + .setBillingAddress(userId, cartId, address) + .pipe(take(1)) + .subscribe((result) => { + expect(result).toEqual(cartData); + done(); + }); + + const mockReq = httpMock.expectOne((req) => { + return ( + req.method === 'PUT' && + req.url === `users/${userId}/carts/${cartId}/addresses/billing` + ); + }); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + mockReq.flush(cartData); + }); + + describe(`back-off`, () => { + it(`should unsuccessfully backOff on Jalo error`, fakeAsync(() => { + spyOn(httpClient, 'put').and.returnValue(throwError(mockJaloError)); + + let result: HttpErrorModel | undefined; + const subscription = service + .setBillingAddress(userId, cartId, address) + .pipe(take(1)) + .subscribe({ error: (err) => (result = err) }); + + tick(4200); + + const mockNormalizedJaloError = normalizeHttpError( + mockJaloError, + logger + ); + expect(result).toEqual(mockNormalizedJaloError); + + subscription.unsubscribe(); + })); + + it(`should successfully backOff on Jalo error and recover after the 2nd retry`, fakeAsync(() => { + let calledTimes = -1; + + spyOn(httpClient, 'put').and.returnValue( + defer(() => { + calledTimes++; + if (calledTimes === 3) { + return of(cartData); + } + return throwError(mockJaloError); + }) + ); + + let result: unknown; + const subscription = service + .setBillingAddress(userId, cartId, address) + .pipe(take(1)) + .subscribe((res) => { + result = res; + }); + + // 1*1*300 = 300 + tick(300); + expect(result).toEqual(undefined); + + // 2*2*300 = 1200 + tick(1200); + expect(result).toEqual(undefined); + + // 3*3*300 = 2700 + tick(2700); + + expect(result).toEqual(cartData); + subscription.unsubscribe(); + })); + }); + }); +}); diff --git a/feature-libs/checkout/base/occ/adapters/occ-checkout-billing-address.adapter.ts b/feature-libs/checkout/base/occ/adapters/occ-checkout-billing-address.adapter.ts new file mode 100644 index 00000000000..3c5a4a866a8 --- /dev/null +++ b/feature-libs/checkout/base/occ/adapters/occ-checkout-billing-address.adapter.ts @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { CheckoutBillingAddressAdapter } from '@spartacus/checkout/base/core'; +import { + Address, + backOff, + ConverterService, + isJaloError, + LoggerService, + normalizeHttpError, + OccEndpointsService, +} from '@spartacus/core'; +import { Observable, throwError } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +@Injectable() +export class OccCheckoutBillingAddressAdapter + implements CheckoutBillingAddressAdapter +{ + protected logger = inject(LoggerService); + + constructor( + protected http: HttpClient, + protected occEndpoints: OccEndpointsService, + protected converter: ConverterService + ) {} + + public setBillingAddress( + userId: string, + cartId: string, + address: Address + ): Observable { + return this.http + .put(this.getSetBillingAddressEndpoint(userId, cartId), address) + .pipe( + catchError((error) => + throwError(normalizeHttpError(error, this.logger)) + ), + backOff({ + shouldRetry: isJaloError, + }) + ); + } + + protected getSetBillingAddressEndpoint( + userId: string, + cartId: string + ): string { + return this.occEndpoints.buildUrl('setBillingAddress', { + urlParams: { userId, cartId }, + }); + } +} diff --git a/feature-libs/checkout/base/occ/checkout-occ.module.ts b/feature-libs/checkout/base/occ/checkout-occ.module.ts index d6abda46f84..acfec5ed93a 100644 --- a/feature-libs/checkout/base/occ/checkout-occ.module.ts +++ b/feature-libs/checkout/base/occ/checkout-occ.module.ts @@ -8,11 +8,13 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { CheckoutAdapter, + CheckoutBillingAddressAdapter, CheckoutDeliveryAddressAdapter, CheckoutDeliveryModesAdapter, CheckoutPaymentAdapter, } from '@spartacus/checkout/base/core'; import { provideDefaultConfig } from '@spartacus/core'; +import { OccCheckoutBillingAddressAdapter } from './adapters/occ-checkout-billing-address.adapter'; import { OccCheckoutDeliveryAddressAdapter } from './adapters/occ-checkout-delivery-address.adapter'; import { OccCheckoutDeliveryModesAdapter } from './adapters/occ-checkout-delivery-modes.adapter'; import { OccCheckoutPaymentAdapter } from './adapters/occ-checkout-payment.adapter'; @@ -39,6 +41,10 @@ import { defaultOccCheckoutConfig } from './config/default-occ-checkout-config'; provide: CheckoutPaymentAdapter, useClass: OccCheckoutPaymentAdapter, }, + { + provide: CheckoutBillingAddressAdapter, + useClass: OccCheckoutBillingAddressAdapter, + }, ], }) export class CheckoutOccModule {} diff --git a/feature-libs/checkout/base/occ/config/default-occ-checkout-config.ts b/feature-libs/checkout/base/occ/config/default-occ-checkout-config.ts index 543b9d102e7..5c9825a80c6 100644 --- a/feature-libs/checkout/base/occ/config/default-occ-checkout-config.ts +++ b/feature-libs/checkout/base/occ/config/default-occ-checkout-config.ts @@ -28,6 +28,7 @@ export const defaultOccCheckoutConfig: OccConfig = { 'users/${userId}/carts/${cartId}/payment/sop/response', getCheckoutDetails: 'users/${userId}/carts/${cartId}?fields=deliveryAddress(FULL),deliveryMode(FULL),paymentInfo(FULL)', + setBillingAddress: 'users/${userId}/carts/${cartId}/addresses/billing', }, }, }, diff --git a/feature-libs/checkout/base/occ/model/occ-checkout-endpoints.model.ts b/feature-libs/checkout/base/occ/model/occ-checkout-endpoints.model.ts index 466d886172d..099402aea25 100644 --- a/feature-libs/checkout/base/occ/model/occ-checkout-endpoints.model.ts +++ b/feature-libs/checkout/base/occ/model/occ-checkout-endpoints.model.ts @@ -55,6 +55,10 @@ export interface CheckoutOccEndpoints { *Endpoint for get all delivery modes for the current store and delivery address. */ deliveryModes?: string | OccEndpoint; + /** + * Endpoint for set billing address to cart + */ + setBillingAddress?: string | OccEndpoint; } declare module '@spartacus/core' { diff --git a/feature-libs/checkout/base/root/config/checkout-config.ts b/feature-libs/checkout/base/root/config/checkout-config.ts index 1f4bb6d2ec4..65336682995 100644 --- a/feature-libs/checkout/base/root/config/checkout-config.ts +++ b/feature-libs/checkout/base/root/config/checkout-config.ts @@ -6,6 +6,7 @@ import { Injectable } from '@angular/core'; import { Config } from '@spartacus/core'; +import { CheckoutFlow } from '../model/checkout-flow.model'; import { CheckoutStep } from '../model/checkout-step.model'; export enum DeliveryModePreferences { @@ -40,6 +41,12 @@ export abstract class CheckoutConfig { * Use delivery address saved in cart for pre-filling delivery address form. */ guestUseSavedAddress?: boolean; + /** + * Determine multiple flows that can be used during the checkout process. + */ + flows?: { + [key: string]: CheckoutFlow; + }; }; } diff --git a/feature-libs/checkout/base/root/facade/checkout-billing-address.facade.ts b/feature-libs/checkout/base/root/facade/checkout-billing-address.facade.ts new file mode 100644 index 00000000000..d8c20c56f26 --- /dev/null +++ b/feature-libs/checkout/base/root/facade/checkout-billing-address.facade.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { Address, facadeFactory } from '@spartacus/core'; +import { Observable } from 'rxjs'; +import { CHECKOUT_CORE_FEATURE } from '../feature-name'; + +@Injectable({ + providedIn: 'root', + useFactory: () => + facadeFactory({ + facade: CheckoutBillingAddressFacade, + feature: CHECKOUT_CORE_FEATURE, + methods: ['setBillingAddress'], + // TODO:#deprecation-checkout - remove once we remove ngrx + async: true, + }), +}) +export abstract class CheckoutBillingAddressFacade { + /** + * Sets the billing address to the cart + */ + abstract setBillingAddress(address: Address): Observable; +} diff --git a/feature-libs/checkout/base/root/facade/index.ts b/feature-libs/checkout/base/root/facade/index.ts index 65ae45c88fe..270f3516dce 100644 --- a/feature-libs/checkout/base/root/facade/index.ts +++ b/feature-libs/checkout/base/root/facade/index.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from './checkout-billing-address.facade'; export * from './checkout-delivery-address.facade'; export * from './checkout-delivery-modes.facade'; export * from './checkout-payment.facade'; diff --git a/feature-libs/checkout/base/root/model/checkout-flow.model.ts b/feature-libs/checkout/base/root/model/checkout-flow.model.ts new file mode 100644 index 00000000000..93c2b7cb542 --- /dev/null +++ b/feature-libs/checkout/base/root/model/checkout-flow.model.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DeliveryModePreferences } from '../config/checkout-config'; +import { CheckoutStep } from './checkout-step.model'; + +export interface CheckoutFlow { + /** + * Set checkout steps as ordered array of pages. + */ + steps?: Array; + /** + * Allow for express checkout when default shipping method and payment method are available. + */ + express?: boolean; + /** + * Default delivery mode for i.a. express checkout. Set preferences in order with general preferences (eg. DeliveryModePreferences.LEAST_EXPENSIVE) or specific delivery codes. + */ + defaultDeliveryMode?: Array; + /** + * Allow for guest checkout. + */ + guest?: boolean; + /** + * Use delivery address saved in cart for pre-filling delivery address form. + */ + guestUseSavedAddress?: boolean; +} diff --git a/feature-libs/checkout/base/root/model/index.ts b/feature-libs/checkout/base/root/model/index.ts index 26f1b44fa97..57afd96a5e9 100644 --- a/feature-libs/checkout/base/root/model/index.ts +++ b/feature-libs/checkout/base/root/model/index.ts @@ -4,5 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ +export * from './checkout-flow.model'; export * from './checkout-state.model'; export * from './checkout-step.model'; diff --git a/feature-libs/checkout/tsconfig.schematics.json b/feature-libs/checkout/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/feature-libs/checkout/tsconfig.schematics.json +++ b/feature-libs/checkout/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/customer-ticketing/tsconfig.schematics.json b/feature-libs/customer-ticketing/tsconfig.schematics.json index 6983407651f..2ca78695831 100644 --- a/feature-libs/customer-ticketing/tsconfig.schematics.json +++ b/feature-libs/customer-ticketing/tsconfig.schematics.json @@ -625,6 +625,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/estimated-delivery-date/tsconfig.schematics.json b/feature-libs/estimated-delivery-date/tsconfig.schematics.json index 6983407651f..2ca78695831 100644 --- a/feature-libs/estimated-delivery-date/tsconfig.schematics.json +++ b/feature-libs/estimated-delivery-date/tsconfig.schematics.json @@ -625,6 +625,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/order/core/connectors/order.adapter.ts b/feature-libs/order/core/connectors/order.adapter.ts index 76cd84ea5cb..39fc327de07 100644 --- a/feature-libs/order/core/connectors/order.adapter.ts +++ b/feature-libs/order/core/connectors/order.adapter.ts @@ -20,4 +20,17 @@ export abstract class OrderAdapter { cartId: string, termsChecked: boolean ): Observable; + + /** + * Abstract method used to place an order with previous payment authorization. + * + * @param userId The `userId` for given user + * @param cartId The `cartId` for cart used for placing order + * @param termsChecked The `boolean value` whether the terms were accepted or not + */ + abstract placePaymentAuthorizedOrder( + userId: string, + cartId: string, + termsChecked: boolean + ): Observable; } diff --git a/feature-libs/order/core/connectors/order.connector.spec.ts b/feature-libs/order/core/connectors/order.connector.spec.ts index 0ed188291c2..5f01bede5c3 100644 --- a/feature-libs/order/core/connectors/order.connector.spec.ts +++ b/feature-libs/order/core/connectors/order.connector.spec.ts @@ -11,6 +11,11 @@ class MockOrderAdapter implements Partial { (userId: string, cartId: string, termsChecked: boolean) => of(`placedOrder-${userId}-${cartId}-${termsChecked}`) ); + placePaymentAuthorizedOrder = createSpy( + 'OrderAdapter.placePaymentAuthorizedOrder' + ).and.callFake((userId: string, cartId: string, termsChecked: boolean) => + of(`placePaymentAuthorizedOrder-${userId}-${cartId}-${termsChecked}`) + ); } describe('OrderConnector', () => { @@ -42,4 +47,18 @@ describe('OrderConnector', () => { expect(result).toBe('placedOrder-user1-cart1-true'); expect(adapter.placeOrder).toHaveBeenCalledWith('user1', 'cart1', true); }); + + it('placePaymentAuthorizedOrder should call adapter', () => { + let result; + service + .placePaymentAuthorizedOrder('user1', 'cart1', true) + .pipe(take(1)) + .subscribe((res) => (result = res)); + expect(result).toBe('placePaymentAuthorizedOrder-user1-cart1-true'); + expect(adapter.placePaymentAuthorizedOrder).toHaveBeenCalledWith( + 'user1', + 'cart1', + true + ); + }); }); diff --git a/feature-libs/order/core/connectors/order.connector.ts b/feature-libs/order/core/connectors/order.connector.ts index 1a39efa0390..fbbb205f5c2 100644 --- a/feature-libs/order/core/connectors/order.connector.ts +++ b/feature-libs/order/core/connectors/order.connector.ts @@ -20,4 +20,16 @@ export class OrderConnector { ): Observable { return this.adapter.placeOrder(userId, cartId, termsChecked); } + + public placePaymentAuthorizedOrder( + userId: string, + cartId: string, + termsChecked: boolean + ): Observable { + return this.adapter.placePaymentAuthorizedOrder( + userId, + cartId, + termsChecked + ); + } } diff --git a/feature-libs/order/core/facade/order.service.spec.ts b/feature-libs/order/core/facade/order.service.spec.ts index 403183a878b..e2f566923d0 100644 --- a/feature-libs/order/core/facade/order.service.spec.ts +++ b/feature-libs/order/core/facade/order.service.spec.ts @@ -28,6 +28,7 @@ class MockUserIdService implements Partial { class MockOrderConnector implements Partial { placeOrder = createSpy().and.returnValue(of(mockOrder)); + placePaymentAuthorizedOrder = createSpy().and.returnValue(of(mockOrder)); } class MockEventService implements Partial { @@ -92,6 +93,32 @@ describe(`OrderService`, () => { }); }); + describe(`placePaymentAuthorizedOrder`, () => { + it(`should call orderConnector.placePaymentAuthorizedOrder`, () => { + service.placePaymentAuthorizedOrder(termsChecked); + + expect(connector.placePaymentAuthorizedOrder).toHaveBeenCalledWith( + mockUserId, + mockCartId, + termsChecked + ); + }); + + it(`should dispatch OrderPlacedEvent`, () => { + service.placePaymentAuthorizedOrder(termsChecked); + + expect(eventService.dispatch).toHaveBeenCalledWith( + { + order: mockOrder, + userId: mockUserId, + cartId: mockCartId, + cartCode: mockCartId, + }, + OrderPlacedEvent + ); + }); + }); + describe(`getOrderDetails`, () => { it(`should return falsy when there's no order`, (done) => { service diff --git a/feature-libs/order/core/facade/order.service.ts b/feature-libs/order/core/facade/order.service.ts index 2e5d5fd58fd..5d5c96b25bb 100644 --- a/feature-libs/order/core/facade/order.service.ts +++ b/feature-libs/order/core/facade/order.service.ts @@ -53,6 +53,38 @@ export class OrderService implements OrderFacade { } ); + protected placePaymentAuthorizedOrderCommand: Command = + this.commandService.create( + (payload) => + this.checkoutPreconditions().pipe( + switchMap(([userId, cartId]) => + this.orderConnector + .placePaymentAuthorizedOrder(userId, cartId, payload) + .pipe( + tap((order) => { + this.setPlacedOrder(order); + this.eventService.dispatch( + { + order, + userId, + cartId, + /** + * As we know the cart is not anonymous (precondition checked), + * we can safely use the cartId, which is actually the cart.code. + */ + cartCode: cartId, + }, + OrderPlacedEvent + ); + }) + ) + ) + ), + { + strategy: CommandStrategy.CancelPrevious, + } + ); + constructor( protected activeCartFacade: ActiveCartFacade, protected userIdService: UserIdService, @@ -88,6 +120,10 @@ export class OrderService implements OrderFacade { return this.placeOrderCommand.execute(termsChecked); } + placePaymentAuthorizedOrder(termsChecked: boolean): Observable { + return this.placePaymentAuthorizedOrderCommand.execute(termsChecked); + } + getOrderDetails(): Observable { return this.placedOrder$.asObservable(); } diff --git a/feature-libs/order/occ/adapters/occ-order.adapter.spec.ts b/feature-libs/order/occ/adapters/occ-order.adapter.spec.ts index 8bff4b793bf..5037a97ca65 100644 --- a/feature-libs/order/occ/adapters/occ-order.adapter.spec.ts +++ b/feature-libs/order/occ/adapters/occ-order.adapter.spec.ts @@ -24,6 +24,8 @@ const MockOccModuleConfig: OccConfig = { prefix: '', endpoints: { placeOrder: 'users/${userId}/orders?fields=FULL', + placePaymentAuthorizedOrder: + 'users/${userId}/orders/paymentAuthorizedOrderPlacement?fields=FULL', } as OccEndpoints, }, }, @@ -190,4 +192,102 @@ describe('OccOrderAdapter', () => { })); }); }); + + describe(`placePaymentAuthorizedOrder`, () => { + it(`should be able to place order after the payment was authorized`, (done) => { + service + .placePaymentAuthorizedOrder(userId, cartId, termsChecked) + .pipe(take(1)) + .subscribe((result) => { + expect(result).toEqual(orderData); + done(); + }); + + const mockReq = httpMock.expectOne((req) => { + return ( + req.method === 'POST' && + req.url === + `users/${userId}/orders/paymentAuthorizedOrderPlacement?fields=FULL&cartId=${cartId}&termsChecked=${termsChecked}` + ); + }); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + mockReq.flush(orderData); + }); + + it(`should use converter`, (done) => { + service + .placePaymentAuthorizedOrder(userId, cartId, termsChecked) + .pipe(take(1)) + .subscribe(() => { + done(); + }); + httpMock + .expectOne( + (req) => + req.method === 'POST' && + req.url === + `users/${userId}/orders/paymentAuthorizedOrderPlacement?fields=FULL&cartId=${cartId}&termsChecked=${termsChecked}` + ) + .flush({}); + expect(converter.pipeable).toHaveBeenCalledWith(ORDER_NORMALIZER); + }); + + describe(`back-off`, () => { + it(`should unsuccessfully backOff on Jalo error`, fakeAsync(() => { + spyOn(httpClient, 'post').and.returnValue( + throwError(() => mockJaloError) + ); + + let result: HttpErrorModel | undefined; + const subscription = service + .placePaymentAuthorizedOrder(userId, cartId, termsChecked) + .pipe(take(1)) + .subscribe({ error: (err) => (result = err) }); + + tick(4200); + + expect(result).toEqual(mockNormalizedJaloError); + + subscription.unsubscribe(); + })); + + it(`should successfully backOff on Jalo error and recover after the 2nd retry`, fakeAsync(() => { + let calledTimes = -1; + + spyOn(httpClient, 'post').and.returnValue( + defer(() => { + calledTimes++; + if (calledTimes === 3) { + return of({}); + } + return throwError(() => mockJaloError); + }) + ); + + let result: Order | undefined; + const subscription = service + .placePaymentAuthorizedOrder(userId, cartId, termsChecked) + .pipe(take(1)) + .subscribe((res) => { + result = res; + }); + + // 1*1*300 = 300 + tick(300); + expect(result).toEqual(undefined); + + // 2*2*300 = 1200 + tick(1200); + expect(result).toEqual(undefined); + + // 3*3*300 = 2700 + tick(2700); + + expect(result).toEqual({}); + subscription.unsubscribe(); + })); + }); + }); }); diff --git a/feature-libs/order/occ/adapters/occ-order.adapter.ts b/feature-libs/order/occ/adapters/occ-order.adapter.ts index 3cb3771e19e..1caea053bee 100644 --- a/feature-libs/order/occ/adapters/occ-order.adapter.ts +++ b/feature-libs/order/occ/adapters/occ-order.adapter.ts @@ -8,6 +8,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { ConverterService, + DEFAULT_SERVER_ERROR_RETRIES_COUNT, InterceptorUtil, LoggerService, OCC_USER_ID_ANONYMOUS, @@ -16,6 +17,7 @@ import { USE_CLIENT_TOKEN, backOff, isJaloError, + isServerError, normalizeHttpError, } from '@spartacus/core'; import { OrderAdapter } from '@spartacus/order/core'; @@ -63,6 +65,44 @@ export class OccOrderAdapter implements OrderAdapter { ); } + public placePaymentAuthorizedOrder( + userId: string, + cartId: string, + termsChecked: boolean + ): Observable { + let headers = new HttpHeaders({ + 'Content-Type': 'application/x-www-form-urlencoded', + }); + + if (userId === OCC_USER_ID_ANONYMOUS) { + headers = InterceptorUtil.createHeader(USE_CLIENT_TOKEN, true, headers); + } + + return this.http + .post( + this.getPlacePaymentAuthorizedOrderEndpoint( + userId, + cartId, + termsChecked.toString() + ), + {}, + { headers } + ) + .pipe( + catchError((error) => { + throw normalizeHttpError(error, this.logger); + }), + backOff({ + shouldRetry: isJaloError, + }), + backOff({ + shouldRetry: isServerError, + maxTries: DEFAULT_SERVER_ERROR_RETRIES_COUNT, + }), + this.converter.pipeable(ORDER_NORMALIZER) + ); + } + protected getPlaceOrderEndpoint( userId: string, cartId: string, @@ -73,4 +113,15 @@ export class OccOrderAdapter implements OrderAdapter { queryParams: { cartId, termsChecked }, }); } + + protected getPlacePaymentAuthorizedOrderEndpoint( + userId: string, + cartId: string, + termsChecked: string + ): string { + return this.occEndpoints.buildUrl('placePaymentAuthorizedOrder', { + urlParams: { userId }, + queryParams: { cartId, termsChecked }, + }); + } } diff --git a/feature-libs/order/occ/config/default-occ-order-config.ts b/feature-libs/order/occ/config/default-occ-order-config.ts index 5deaefc614a..bae68bb6b35 100644 --- a/feature-libs/order/occ/config/default-occ-order-config.ts +++ b/feature-libs/order/occ/config/default-occ-order-config.ts @@ -37,6 +37,8 @@ export const defaultOccOrderConfig: OccConfig = { /** placing an order endpoints start **/ placeOrder: 'users/${userId}/orders?fields=FULL', + placePaymentAuthorizedOrder: + 'users/${userId}/orders/paymentAuthorizedOrderPlacement?fields=FULL', /** placing an order endpoints end **/ }, }, diff --git a/feature-libs/order/occ/model/occ-order-endpoints.model.ts b/feature-libs/order/occ/model/occ-order-endpoints.model.ts index 6fe0832ab3d..5a27c1eded5 100644 --- a/feature-libs/order/occ/model/occ-order-endpoints.model.ts +++ b/feature-libs/order/occ/model/occ-order-endpoints.model.ts @@ -73,6 +73,10 @@ export interface OrderOccEndpoints { * Endpoint for place order */ placeOrder?: string | OccEndpoint; + /** + * Endpoint for place order after the payment authorization + */ + placePaymentAuthorizedOrder?: string | OccEndpoint; /** * Endpoint for scheduling a replenishment order */ diff --git a/feature-libs/order/root/facade/order.facade.ts b/feature-libs/order/root/facade/order.facade.ts index 7ba5d08a22a..8a437160779 100644 --- a/feature-libs/order/root/facade/order.facade.ts +++ b/feature-libs/order/root/facade/order.facade.ts @@ -22,6 +22,7 @@ import { Order } from '../model/order.model'; 'clearPlacedOrder', 'setPlacedOrder', 'placeOrder', + 'placePaymentAuthorizedOrder', 'getPickupEntries', 'getDeliveryEntries', ], @@ -44,6 +45,12 @@ export abstract class OrderFacade { * Places an order */ abstract placeOrder(termsChecked: boolean): Observable; + /** + * Places an order after the payment was authorized + */ + abstract placePaymentAuthorizedOrder( + termsChecked: boolean + ): Observable; /** * Return order's pickup entries */ diff --git a/feature-libs/order/tsconfig.schematics.json b/feature-libs/order/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/feature-libs/order/tsconfig.schematics.json +++ b/feature-libs/order/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/organization/tsconfig.schematics.json b/feature-libs/organization/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/feature-libs/organization/tsconfig.schematics.json +++ b/feature-libs/organization/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/pdf-invoices/tsconfig.schematics.json b/feature-libs/pdf-invoices/tsconfig.schematics.json index 6983407651f..2ca78695831 100644 --- a/feature-libs/pdf-invoices/tsconfig.schematics.json +++ b/feature-libs/pdf-invoices/tsconfig.schematics.json @@ -625,6 +625,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/pickup-in-store/tsconfig.schematics.json b/feature-libs/pickup-in-store/tsconfig.schematics.json index 711f58bf7a7..710df6b10a2 100644 --- a/feature-libs/pickup-in-store/tsconfig.schematics.json +++ b/feature-libs/pickup-in-store/tsconfig.schematics.json @@ -628,6 +628,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/product-configurator/tsconfig.schematics.json b/feature-libs/product-configurator/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/feature-libs/product-configurator/tsconfig.schematics.json +++ b/feature-libs/product-configurator/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/product-multi-dimensional/tsconfig.schematics.json b/feature-libs/product-multi-dimensional/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/feature-libs/product-multi-dimensional/tsconfig.schematics.json +++ b/feature-libs/product-multi-dimensional/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/product/tsconfig.schematics.json b/feature-libs/product/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/feature-libs/product/tsconfig.schematics.json +++ b/feature-libs/product/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/qualtrics/tsconfig.schematics.json b/feature-libs/qualtrics/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/feature-libs/qualtrics/tsconfig.schematics.json +++ b/feature-libs/qualtrics/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/quote/tsconfig.schematics.json b/feature-libs/quote/tsconfig.schematics.json index 711f58bf7a7..710df6b10a2 100644 --- a/feature-libs/quote/tsconfig.schematics.json +++ b/feature-libs/quote/tsconfig.schematics.json @@ -628,6 +628,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/requested-delivery-date/tsconfig.schematics.json b/feature-libs/requested-delivery-date/tsconfig.schematics.json index 6983407651f..2ca78695831 100644 --- a/feature-libs/requested-delivery-date/tsconfig.schematics.json +++ b/feature-libs/requested-delivery-date/tsconfig.schematics.json @@ -625,6 +625,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/smartedit/tsconfig.schematics.json b/feature-libs/smartedit/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/feature-libs/smartedit/tsconfig.schematics.json +++ b/feature-libs/smartedit/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/storefinder/tsconfig.schematics.json b/feature-libs/storefinder/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/feature-libs/storefinder/tsconfig.schematics.json +++ b/feature-libs/storefinder/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/tracking/tsconfig.schematics.json b/feature-libs/tracking/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/feature-libs/tracking/tsconfig.schematics.json +++ b/feature-libs/tracking/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/feature-libs/user/profile/components/address-book/address-form/address-form.component.ts b/feature-libs/user/profile/components/address-book/address-form/address-form.component.ts index 92d9a80b85d..3b71cc8245a 100644 --- a/feature-libs/user/profile/components/address-book/address-form/address-form.component.ts +++ b/feature-libs/user/profile/components/address-book/address-form/address-form.component.ts @@ -71,6 +71,9 @@ export class AddressFormComponent implements OnInit, OnDestroy { @Input() showCancelBtn = true; + @Input() + countries: Observable; + @Output() submitAddress = new EventEmitter(); @@ -110,14 +113,16 @@ export class AddressFormComponent implements OnInit, OnDestroy { ) {} ngOnInit() { - // Fetching countries - this.countries$ = this.userAddressService.getDeliveryCountries().pipe( - tap((countries: Country[]) => { - if (Object.keys(countries).length === 0) { - this.userAddressService.loadDeliveryCountries(); - } - }) - ); + // Fetching countries if no data stream was provided + this.countries$ = + this.countries || + this.userAddressService.getDeliveryCountries().pipe( + tap((countries: Country[]) => { + if (Object.keys(countries).length === 0) { + this.userAddressService.loadDeliveryCountries(); + } + }) + ); // Fetching titles this.titles$ = this.getTitles(); diff --git a/feature-libs/user/tsconfig.schematics.json b/feature-libs/user/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/feature-libs/user/tsconfig.schematics.json +++ b/feature-libs/user/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/integration-libs/cdc/tsconfig.schematics.json b/integration-libs/cdc/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/integration-libs/cdc/tsconfig.schematics.json +++ b/integration-libs/cdc/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/integration-libs/cdp/tsconfig.schematics.json b/integration-libs/cdp/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/integration-libs/cdp/tsconfig.schematics.json +++ b/integration-libs/cdp/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/integration-libs/cds/tsconfig.schematics.json b/integration-libs/cds/tsconfig.schematics.json index 74f88127740..6b1dcb79d55 100644 --- a/integration-libs/cds/tsconfig.schematics.json +++ b/integration-libs/cds/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/integration-libs/cpq-quote/tsconfig.schematics.json b/integration-libs/cpq-quote/tsconfig.schematics.json index 6983407651f..2ca78695831 100644 --- a/integration-libs/cpq-quote/tsconfig.schematics.json +++ b/integration-libs/cpq-quote/tsconfig.schematics.json @@ -625,6 +625,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/integration-libs/digital-payments/tsconfig.schematics.json b/integration-libs/digital-payments/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/integration-libs/digital-payments/tsconfig.schematics.json +++ b/integration-libs/digital-payments/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/integration-libs/epd-visualization/tsconfig.schematics.json b/integration-libs/epd-visualization/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/integration-libs/epd-visualization/tsconfig.schematics.json +++ b/integration-libs/epd-visualization/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/integration-libs/omf/tsconfig.schematics.json b/integration-libs/omf/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/integration-libs/omf/tsconfig.schematics.json +++ b/integration-libs/omf/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/integration-libs/opf/README.md b/integration-libs/opf/README.md new file mode 100644 index 00000000000..23a7ff0af52 --- /dev/null +++ b/integration-libs/opf/README.md @@ -0,0 +1,7 @@ +# Spartacus Open-Payment-Framework integration + +Spartacus' Open-Payment-Framework (OPF) library integrates the SAP Commerce Open Payment Framework, delivered as an enrichment to the SAP Commerce Cloud payment toolkit. + +It can be added to the existing Spartacus application by running `ng add @spartacus/opf`. For more information about Spartacus schematics, visit the [official Spartacus schematics documentation page](https://sap.github.io/spartacus-docs/schematics/). + +For more information, see [Spartacus](https://github.com/SAP/spartacus). diff --git a/integration-libs/opf/_index.scss b/integration-libs/opf/_index.scss new file mode 100644 index 00000000000..197db044e00 --- /dev/null +++ b/integration-libs/opf/_index.scss @@ -0,0 +1,35 @@ +@import '@spartacus/styles/scss/core'; +@import '@spartacus/checkout'; +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; +@import 'bootstrap/scss/_mixins'; +@import './base/styles/index'; +@import './checkout/styles/index'; +@import './cta/styles/index'; +@import './quick-buy/styles/index'; + +$opf-components-allowlist: cx-opf-checkout-payment-and-review, + cx-opf-checkout-payments, cx-opf-checkout-billing-address-form, + cx-opf-checkout-payment-wrapper, cx-opf-checkout-terms-and-conditions-alert, + cx-opf-error-modal, cx-opf-cta-element, cx-opf-google-pay, cx-opf-apple-pay, + cx-opf-quick-buy-buttons !default; + +$skipComponentStyles: () !default; + +@each $selector in $opf-components-allowlist { + #{$selector} { + // skip selectors if they're added to the $skipComponentStyles list + @if (index($skipComponentStyles, $selector) == null) { + @extend %#{$selector} !optional; + } + } +} + +// add body specific selectors +body { + @each $selector in $opf-components-allowlist { + @if (index($skipComponentStyles, $selector) == null) { + @extend %#{$selector}__body !optional; + } + } +} diff --git a/integration-libs/opf/base/components/ng-package.json b/integration-libs/opf/base/components/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/base/components/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/base/components/opf-base-components.module.ts b/integration-libs/opf/base/components/opf-base-components.module.ts new file mode 100644 index 00000000000..3e1b27f101e --- /dev/null +++ b/integration-libs/opf/base/components/opf-base-components.module.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfErrorModalModule } from './opf-error-modal'; + +@NgModule({ + imports: [OpfErrorModalModule], + providers: [], +}) +export class OpfBaseComponentsModule {} diff --git a/integration-libs/opf/base/components/opf-error-modal/default-opf-error-modal.layout.config.ts b/integration-libs/opf/base/components/opf-error-modal/default-opf-error-modal.layout.config.ts new file mode 100644 index 00000000000..f1231ae94eb --- /dev/null +++ b/integration-libs/opf/base/components/opf-error-modal/default-opf-error-modal.layout.config.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DIALOG_TYPE, LayoutConfig } from '@spartacus/storefront'; +import { OpfErrorModalComponent } from './opf-error-modal.component'; + +export const defaultOpfErrorModalLayoutConfig: LayoutConfig = { + launch: { + OPF_ERROR: { + inline: true, + component: OpfErrorModalComponent, + dialogType: DIALOG_TYPE.POPOVER_CENTER_BACKDROP, + }, + }, +}; diff --git a/integration-libs/opf/base/components/opf-error-modal/index.ts b/integration-libs/opf/base/components/opf-error-modal/index.ts new file mode 100644 index 00000000000..1fb0417bdfe --- /dev/null +++ b/integration-libs/opf/base/components/opf-error-modal/index.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './default-opf-error-modal.layout.config'; +export * from './opf-error-modal.component'; +export * from './opf-error-modal.module'; +export * from './opf-error-modal.service'; diff --git a/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.component.html b/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.component.html new file mode 100644 index 00000000000..221f5865191 --- /dev/null +++ b/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.component.html @@ -0,0 +1,23 @@ + +
+
+ + +
+
+
diff --git a/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.component.spec.ts b/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.component.spec.ts new file mode 100644 index 00000000000..c224fe9c6ce --- /dev/null +++ b/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.component.spec.ts @@ -0,0 +1,91 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { I18nTestingModule } from '@spartacus/core'; +import { ErrorDialogOptions } from '@spartacus/opf/base/root'; +import { + KeyboardFocusTestingModule, + LaunchDialogService, +} from '@spartacus/storefront'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { OpfErrorModalComponent } from './opf-error-modal.component'; +import { OpfErrorModalService } from './opf-error-modal.service'; + +const dialogClose$ = new BehaviorSubject(''); +let mockDialogOptions: ErrorDialogOptions = { + messageString: 'Opf Test Message', + confirmString: 'Opf Test Confirm', +}; +class MockLaunchDialogService implements Partial { + get data$(): Observable | undefined { + return of(mockDialogOptions); + } + get dialogClose() { + return dialogClose$.asObservable(); + } + + closeDialog() {} +} + +class MockOpfErrorModalService implements Partial { + getMessageAndConfirmTranslations(dialogOptions: ErrorDialogOptions) { + return of({ + message: dialogOptions.messageString, + confirm: dialogOptions.confirmString, + }); + } +} + +describe('OpfErrorModalComponent', () => { + let component: OpfErrorModalComponent; + let fixture: ComponentFixture; + let launchDialogService: LaunchDialogService; + let opfErrorModalService: OpfErrorModalService; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [I18nTestingModule, KeyboardFocusTestingModule], + declarations: [OpfErrorModalComponent], + providers: [ + { provide: OpfErrorModalService, useClass: MockOpfErrorModalService }, + { + provide: LaunchDialogService, + useClass: MockLaunchDialogService, + }, + ], + }); + + fixture = TestBed.createComponent(OpfErrorModalComponent); + component = fixture.componentInstance; + launchDialogService = TestBed.inject(LaunchDialogService); + opfErrorModalService = TestBed.inject(OpfErrorModalService); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should close dialogue when modal is dismissed', () => { + spyOn(launchDialogService, 'closeDialog').and.callThrough(); + component.dismissModal('opf test'); + + expect(launchDialogService.closeDialog).toHaveBeenCalled(); + }); + + it('should closeModal when user click outside', () => { + const el = fixture.debugElement.nativeElement; + spyOn(component, 'dismissModal'); + + el.click(); + expect(component.dismissModal).toHaveBeenCalledWith('Backdrop click'); + }); + + it('should call the transalation service when component is init', () => { + spyOn( + opfErrorModalService, + 'getMessageAndConfirmTranslations' + ).and.callThrough(); + component.ngOnInit(); + fixture.detectChanges(); + expect( + opfErrorModalService.getMessageAndConfirmTranslations + ).toHaveBeenCalled(); + }); +}); diff --git a/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.component.ts b/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.component.ts new file mode 100644 index 00000000000..e4c14df2603 --- /dev/null +++ b/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.component.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + HostListener, + OnInit, +} from '@angular/core'; +import { ErrorDialogOptions } from '@spartacus/opf/base/root'; +import { FocusConfig, LaunchDialogService } from '@spartacus/storefront'; +import { Observable, timer } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { OpfErrorModalService } from './opf-error-modal.service'; + +@Component({ + selector: 'cx-opf-error-modal', + templateUrl: './opf-error-modal.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpfErrorModalComponent implements OnInit { + focusConfig: FocusConfig = { + trap: true, + block: true, + autofocus: 'button', + focusOnEscape: true, + }; + + errorDialogOptions$: Observable<{ message: string; confirm: string }>; + + @HostListener('click', ['$event']) + handleClick(event: UIEvent): void { + if ((event.target as any).tagName === this.el.nativeElement.tagName) { + this.dismissModal('Backdrop click'); + } + } + + constructor( + protected launchDialogService: LaunchDialogService, + protected el: ElementRef, + protected cd: ChangeDetectorRef, + protected opfErrorModalService: OpfErrorModalService + ) { + // Mechanism needed to trigger the cpnt life cycle hooks. + timer(1).subscribe({ + complete: () => { + this.cd.markForCheck(); + }, + }); + } + + ngOnInit() { + this.errorDialogOptions$ = this.launchDialogService.data$.pipe( + switchMap((data: ErrorDialogOptions) => { + return this.opfErrorModalService.getMessageAndConfirmTranslations(data); + }) + ); + } + + dismissModal(reason?: any): void { + this.launchDialogService.closeDialog(reason); + } +} diff --git a/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.module.ts b/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.module.ts new file mode 100644 index 00000000000..731b570c305 --- /dev/null +++ b/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.module.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { I18nModule, provideDefaultConfig } from '@spartacus/core'; +import { KeyboardFocusModule } from '@spartacus/storefront'; +import { defaultOpfErrorModalLayoutConfig } from './default-opf-error-modal.layout.config'; +import { OpfErrorModalComponent } from './opf-error-modal.component'; + +@NgModule({ + declarations: [OpfErrorModalComponent], + providers: [provideDefaultConfig(defaultOpfErrorModalLayoutConfig)], + exports: [OpfErrorModalComponent], + imports: [CommonModule, I18nModule, KeyboardFocusModule], +}) +export class OpfErrorModalModule {} diff --git a/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.service.spec.ts b/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.service.spec.ts new file mode 100644 index 00000000000..f9193594bec --- /dev/null +++ b/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.service.spec.ts @@ -0,0 +1,113 @@ +import { TestBed } from '@angular/core/testing'; +import { TranslationService } from '@spartacus/core'; +import { + ErrorDialogOptions, + defaultErrorDialogOptions, +} from '@spartacus/opf/base/root'; +import { Observable, of } from 'rxjs'; +import { OpfErrorModalService } from './opf-error-modal.service'; + +class MockTranslationService { + translate(key: string, replacement?: any): Observable { + if (key.includes('fail')) { + return of(''); + } else { + return of(replacement ? `${key} and ${replacement}` : key); + } + } +} + +let mockDialogOptionsWithKeys: ErrorDialogOptions = { + messageKey: 'opf.test.message', + confirmKey: 'opf.test.confirm.fail', + messageReplacements: '', + confirmReplacements: '', +}; + +let mockDialogOptionsEmpty: ErrorDialogOptions = { + messageKey: '', + confirmKey: '', + messageReplacements: 'rep1', + confirmReplacements: '', +}; + +let mockDialogOptionsWithKeysAndReplacements: ErrorDialogOptions = { + messageKey: 'opf.test.message', + confirmKey: 'opf.test.confirm.fail', + messageReplacements: 'rep1', + confirmReplacements: 'rep2', +}; + +let mockDialogOptionsWithStrings: ErrorDialogOptions = { + messageString: 'Opf Test Message', + confirmString: 'Opf Test Confirm', +}; + +describe('OpfErrorModalService', () => { + let service: OpfErrorModalService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: TranslationService, useClass: MockTranslationService }, + ], + }); + service = TestBed.inject(OpfErrorModalService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('getMessageAndConfirmTranslations', () => { + it('should translate from strings', (done) => { + service + .getMessageAndConfirmTranslations(mockDialogOptionsWithStrings) + .subscribe((translation) => { + expect(translation.message).toEqual('Opf Test Message'); + expect(translation.confirm).toEqual('Opf Test Confirm'); + done(); + }); + }); + + it('should translate from keys and fallback to default when failing', (done) => { + service + .getMessageAndConfirmTranslations(mockDialogOptionsWithKeys) + .subscribe((translation) => { + expect(translation.message).toEqual('opf.test.message'); + expect(translation.confirm).toEqual( + defaultErrorDialogOptions.confirmKey + ); + done(); + }); + }); + + it('should translate with default key when empty labels provided', (done) => { + service + .getMessageAndConfirmTranslations(mockDialogOptionsEmpty) + .subscribe((translation) => { + expect(translation.message).toEqual( + defaultErrorDialogOptions.messageKey + ); + expect(translation.confirm).toEqual( + defaultErrorDialogOptions.confirmKey + ); + done(); + }); + }); + + it('should translate with replacement and fallback to default when fail', (done) => { + service + .getMessageAndConfirmTranslations( + mockDialogOptionsWithKeysAndReplacements + ) + .subscribe((translation) => { + expect(translation.message).toEqual('opf.test.message and rep1'); + expect(translation.confirm).toEqual( + defaultErrorDialogOptions.confirmKey + ); + }); + done(); + }); + }); +}); diff --git a/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.service.ts b/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.service.ts new file mode 100644 index 00000000000..5e63020ede5 --- /dev/null +++ b/integration-libs/opf/base/components/opf-error-modal/opf-error-modal.service.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { TranslationService } from '@spartacus/core'; +import { + ErrorDialogOptions, + defaultErrorDialogOptions, +} from '@spartacus/opf/base/root'; +import { combineLatest, of } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root', +}) +export class OpfErrorModalService { + constructor(protected translationService: TranslationService) {} + + getMessageAndConfirmTranslations(dialogOptions: ErrorDialogOptions) { + return combineLatest([ + this.getLabelTranslation( + defaultErrorDialogOptions.messageKey as string, + dialogOptions.messageString, + dialogOptions.messageKey, + dialogOptions.messageReplacements + ), + this.getLabelTranslation( + defaultErrorDialogOptions.confirmKey as string, + dialogOptions.confirmString, + dialogOptions.confirmKey, + dialogOptions.confirmReplacements + ), + ]).pipe( + map((labelArray) => { + return { message: labelArray[0], confirm: labelArray[1] }; + }) + ); + } + + protected getLabelTranslation( + defaultKey: string, + labelString?: string, + labelKey?: string, + labelReplacements?: any + ) { + const defaultLabel$ = this.translationService.translate(defaultKey); + + if (labelString) { + return of(labelString); + } else if (labelKey) { + const labelFromKey$ = this.translationService + .translate(labelKey) + .pipe(switchMap((val) => (val ? of(val) : defaultLabel$))); + + if (labelReplacements) { + return this.translationService + .translate(labelKey, labelReplacements) + .pipe(switchMap((val) => (val ? of(val) : labelFromKey$))); + } else { + return labelFromKey$; + } + } else { + return defaultLabel$; + } + } +} diff --git a/integration-libs/opf/base/components/public_api.ts b/integration-libs/opf/base/components/public_api.ts new file mode 100644 index 00000000000..fda555465a3 --- /dev/null +++ b/integration-libs/opf/base/components/public_api.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-base-components.module'; diff --git a/integration-libs/opf/base/core/connectors/index.ts b/integration-libs/opf/base/core/connectors/index.ts new file mode 100644 index 00000000000..49833cc7005 --- /dev/null +++ b/integration-libs/opf/base/core/connectors/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-base.adapter'; +export * from './opf-base.connector'; diff --git a/integration-libs/opf/base/core/connectors/opf-base.adapter.ts b/integration-libs/opf/base/core/connectors/opf-base.adapter.ts new file mode 100644 index 00000000000..aaae579357b --- /dev/null +++ b/integration-libs/opf/base/core/connectors/opf-base.adapter.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ActiveConfiguration } from '@spartacus/opf/base/root'; +import { Observable } from 'rxjs'; + +export abstract class OpfBaseAdapter { + /** + * Abstract method used to get payment active configurations + */ + abstract getActiveConfigurations(): Observable; +} diff --git a/integration-libs/opf/base/core/connectors/opf-base.connector.spec.ts b/integration-libs/opf/base/core/connectors/opf-base.connector.spec.ts new file mode 100644 index 00000000000..d42ec63c244 --- /dev/null +++ b/integration-libs/opf/base/core/connectors/opf-base.connector.spec.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { + ActiveConfiguration, + OpfPaymentProviderType, +} from '@spartacus/opf/base/root'; +import { of } from 'rxjs'; +import { OpfBaseAdapter } from './opf-base.adapter'; +import { OpfBaseConnector } from './opf-base.connector'; +import createSpy = jasmine.createSpy; + +const mockActiveConfigurations: ActiveConfiguration[] = [ + { + id: 1, + providerType: OpfPaymentProviderType.PAYMENT_GATEWAY, + displayName: 'Test1', + }, + { + id: 2, + providerType: OpfPaymentProviderType.PAYMENT_GATEWAY, + displayName: 'Test2', + }, + { + id: 3, + providerType: OpfPaymentProviderType.PAYMENT_METHOD, + displayName: 'Test3', + }, +]; + +class MockOpfBaseAdapter implements OpfBaseAdapter { + getActiveConfigurations = createSpy('getActiveConfigurations').and.callFake( + () => of(mockActiveConfigurations) + ); +} + +describe('OpfBaseConnector', () => { + let service: OpfBaseConnector; + let adapter: OpfBaseAdapter; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OpfBaseConnector, + { provide: OpfBaseAdapter, useClass: MockOpfBaseAdapter }, + ], + }); + + service = TestBed.inject(OpfBaseConnector); + adapter = TestBed.inject(OpfBaseAdapter); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('getActiveConfigurations should call adapter', () => { + let result; + service.getActiveConfigurations().subscribe((res) => (result = res)); + expect(result).toEqual(mockActiveConfigurations); + expect(adapter.getActiveConfigurations).toHaveBeenCalled(); + }); +}); diff --git a/integration-libs/opf/base/core/connectors/opf-base.connector.ts b/integration-libs/opf/base/core/connectors/opf-base.connector.ts new file mode 100644 index 00000000000..99f89daf628 --- /dev/null +++ b/integration-libs/opf/base/core/connectors/opf-base.connector.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { ActiveConfiguration } from '@spartacus/opf/base/root'; +import { Observable } from 'rxjs'; +import { OpfBaseAdapter } from './opf-base.adapter'; + +@Injectable() +export class OpfBaseConnector { + constructor(protected adapter: OpfBaseAdapter) {} + + public getActiveConfigurations(): Observable { + return this.adapter.getActiveConfigurations(); + } +} diff --git a/integration-libs/opf/base/core/facade/facade-providers.ts b/integration-libs/opf/base/core/facade/facade-providers.ts new file mode 100644 index 00000000000..7cb972c2d39 --- /dev/null +++ b/integration-libs/opf/base/core/facade/facade-providers.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Provider } from '@angular/core'; +import { OpfBaseFacade } from '@spartacus/opf/base/root'; +import { OpfBaseService } from './opf-base.service'; + +export const facadeProviders: Provider[] = [ + OpfBaseService, + { + provide: OpfBaseFacade, + useExisting: OpfBaseService, + }, +]; diff --git a/integration-libs/opf/base/core/facade/index.ts b/integration-libs/opf/base/core/facade/index.ts new file mode 100644 index 00000000000..e38b42fa770 --- /dev/null +++ b/integration-libs/opf/base/core/facade/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-base.service'; diff --git a/integration-libs/opf/base/core/facade/opf-base.service.spec.ts b/integration-libs/opf/base/core/facade/opf-base.service.spec.ts new file mode 100644 index 00000000000..40d7f3b7284 --- /dev/null +++ b/integration-libs/opf/base/core/facade/opf-base.service.spec.ts @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { QueryService, QueryState } from '@spartacus/core'; +import { OpfBaseConnector } from '../connectors/opf-base.connector'; +import { OpfBaseService } from './opf-base.service'; +import { + ActiveConfiguration, + OpfPaymentProviderType, +} from '@spartacus/opf/base/root'; + +describe('OpfBaseService', () => { + let service: OpfBaseService; + let queryService: jasmine.SpyObj; + let opfBaseConnector: jasmine.SpyObj; + + beforeEach(() => { + const queryServiceSpy = jasmine.createSpyObj('QueryService', ['create']); + const opfBaseConnectorSpy = jasmine.createSpyObj('OpfBaseConnector', [ + 'getActiveConfigurations', + ]); + + TestBed.configureTestingModule({ + providers: [ + OpfBaseService, + { provide: QueryService, useValue: queryServiceSpy }, + { provide: OpfBaseConnector, useValue: opfBaseConnectorSpy }, + ], + }); + + service = TestBed.inject(OpfBaseService); + queryService = TestBed.inject(QueryService) as jasmine.SpyObj; + opfBaseConnector = TestBed.inject( + OpfBaseConnector + ) as jasmine.SpyObj; + + const mockActiveConfigurations: ActiveConfiguration[] = [ + { + id: 1, + description: 'Payment gateway for merchant 123', + merchantId: '123', + providerType: OpfPaymentProviderType.PAYMENT_GATEWAY, + displayName: 'Gateway 123', + acquirerCountryCode: 'US', + }, + { + id: 2, + description: 'Payment method for merchant 456', + merchantId: '456', + providerType: OpfPaymentProviderType.PAYMENT_METHOD, + displayName: 'Method 456', + acquirerCountryCode: 'GB', + }, + ]; + + opfBaseConnector.getActiveConfigurations.and.returnValue( + of(mockActiveConfigurations) + ); + + const mockQuery = { + get: jasmine + .createSpy('get') + .and.returnValue(of(mockActiveConfigurations)), + getState: jasmine.createSpy('getState').and.returnValue( + of({ + loading: false, + error: undefined, + data: mockActiveConfigurations, + }) + ), + }; + + queryService.create.and.returnValue(mockQuery); + service['activeConfigurationsQuery'] = mockQuery; + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('getActiveConfigurationsState should return an observable with the correct state and call the connector', (done: DoneFn) => { + service + .getActiveConfigurationsState() + .subscribe((state: QueryState) => { + expect(state.loading).toBeFalsy(); + expect(state.error).toBeUndefined(); + expect(state.data).toEqual([ + { + id: 1, + description: 'Payment gateway for merchant 123', + merchantId: '123', + providerType: OpfPaymentProviderType.PAYMENT_GATEWAY, + displayName: 'Gateway 123', + acquirerCountryCode: 'US', + }, + { + id: 2, + description: 'Payment method for merchant 456', + merchantId: '456', + providerType: OpfPaymentProviderType.PAYMENT_METHOD, + displayName: 'Method 456', + acquirerCountryCode: 'GB', + }, + ]); + done(); + }); + + expect(queryService.create).toHaveBeenCalled(); + }); +}); diff --git a/integration-libs/opf/base/core/facade/opf-base.service.ts b/integration-libs/opf/base/core/facade/opf-base.service.ts new file mode 100644 index 00000000000..ab9bd83f1ff --- /dev/null +++ b/integration-libs/opf/base/core/facade/opf-base.service.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { + CommandService, + Query, + QueryService, + QueryState, +} from '@spartacus/core'; +import { ActiveConfiguration, OpfBaseFacade } from '@spartacus/opf/base/root'; +import { Observable } from 'rxjs'; +import { OpfBaseConnector } from '../connectors/opf-base.connector'; + +@Injectable() +export class OpfBaseService implements OpfBaseFacade { + protected activeConfigurationsQuery: Query = + this.queryService.create(() => + this.opfBaseConnector.getActiveConfigurations() + ); + + constructor( + protected queryService: QueryService, + protected commandService: CommandService, + protected opfBaseConnector: OpfBaseConnector + ) {} + + getActiveConfigurationsState(): Observable< + QueryState + > { + return this.activeConfigurationsQuery.getState(); + } +} diff --git a/integration-libs/opf/base/core/ng-package.json b/integration-libs/opf/base/core/ng-package.json new file mode 100644 index 00000000000..38e01ac17de --- /dev/null +++ b/integration-libs/opf/base/core/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/base/core/opf-base-core.module.ts b/integration-libs/opf/base/core/opf-base-core.module.ts new file mode 100644 index 00000000000..cd9cf708a1d --- /dev/null +++ b/integration-libs/opf/base/core/opf-base-core.module.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfBaseConnector } from './connectors'; +import { facadeProviders } from './facade/facade-providers'; + +@NgModule({ + imports: [], + providers: [...facadeProviders, OpfBaseConnector], +}) +export class OpfBaseCoreModule {} diff --git a/integration-libs/opf/base/core/public_api.ts b/integration-libs/opf/base/core/public_api.ts new file mode 100644 index 00000000000..7d5a48d9b8f --- /dev/null +++ b/integration-libs/opf/base/core/public_api.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './connectors/index'; +export * from './facade/index'; +export * from './opf-base-core.module'; +export * from './services/index'; +export * from './tokens/index'; diff --git a/integration-libs/opf/base/core/services/index.ts b/integration-libs/opf/base/core/services/index.ts new file mode 100644 index 00000000000..f426d726ab1 --- /dev/null +++ b/integration-libs/opf/base/core/services/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-endpoints.service'; diff --git a/integration-libs/opf/base/core/services/opf-endpoints.service.spec.ts b/integration-libs/opf/base/core/services/opf-endpoints.service.spec.ts new file mode 100644 index 00000000000..c5471702ab1 --- /dev/null +++ b/integration-libs/opf/base/core/services/opf-endpoints.service.spec.ts @@ -0,0 +1,143 @@ +/* + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; + +import { BaseSiteService, StringTemplate } from '@spartacus/core'; +import { OpfApiConfig, OpfConfig } from '@spartacus/opf/base/root'; +import { OpfEndpointsService } from './opf-endpoints.service'; + +describe('OpfEndpointsService', () => { + let service: OpfEndpointsService; + let opfConfigMock: Partial; + let opfApiConfigMock: Partial; + let baseSiteServiceMock: any; + + beforeEach(() => { + opfConfigMock = { + opf: { + opfBaseUrl: 'https://elec-spa.com/opf', + }, + }; + opfApiConfigMock = { + backend: { + opfApi: { + endpoints: { + getActiveConfigurations: 'getActiveConfigurations', + }, + }, + }, + }; + + baseSiteServiceMock = { + getActive: () => of('electronics-spa'), + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: OpfConfig, useValue: opfConfigMock }, + { provide: OpfApiConfig, useValue: opfApiConfigMock }, + { provide: BaseSiteService, useValue: baseSiteServiceMock }, + ], + }); + + service = TestBed.inject(OpfEndpointsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('getBaseEndpoint()', () => { + it('should return the base URL when it is defined', () => { + const result = service['getBaseEndpoint'](); + expect(result).toEqual('https://elec-spa.com/opf'); + }); + + it('should return an empty string when config is undefined', () => { + (service['opfConfig'] as any) = undefined; + const result = service['getBaseEndpoint'](); + expect(result).toEqual(''); + }); + + it('should return an empty string when baseUrl is undefined', () => { + service['opfConfig'] = { opf: {} }; + const result = service['getBaseEndpoint'](); + expect(result).toEqual(''); + }); + + it('should return an empty string when baseUrl is empty', () => { + service['opfConfig'] = { opf: { opfBaseUrl: '' } }; + const result = service['getBaseEndpoint'](); + expect(result).toEqual(''); + }); + }); + + describe('getEndpointFromContext()', () => { + it('should return the endpoint configuration when it is defined', () => { + const endpoint = 'getActiveConfigurations'; + + const result = service['getEndpointFromContext'](endpoint); + + expect(result).toEqual('getActiveConfigurations'); + }); + + it('should return empty string when endpointsConfig is undefined', () => { + (service['opfApiConfig'] as any).backend.opfApi.endpoints = undefined; + const endpoint = 'sampleEndpoint'; + + const result = service['getEndpointFromContext'](endpoint); + + expect(result).toBe(''); + }); + + it('should return undefined when endpoint is not found', () => { + const endpoint = 'nonExistentEndpoint'; + + const result = service['getEndpointFromContext'](endpoint); + + expect(result).toBeUndefined(); + }); + }); + + describe('buildUrl()', () => { + it('should build a URL with active base site and resolved endpoint', () => { + const endpoint = 'getActiveConfigurations'; + + const result = service.buildUrl(endpoint, {}); + + const expectedUrl = + 'https://elec-spa.com/opf/electronics-spa/getActiveConfigurations'; + expect(result).toEqual(expectedUrl); + }); + + it('should not call StringTemplate resolve() when there are no urlParams', () => { + const endpoint = 'getActiveConfigurations'; + const spy = spyOn(StringTemplate, 'resolve').and.callThrough(); + + service.buildUrl(endpoint, {}); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('should call StringTemplate resolve() when there are urlParams', () => { + const endpoint = 'getActiveConfigurations'; + const attributes = { + urlParams: { param1: 'value1' }, + }; + + const spy = spyOn(StringTemplate, 'resolve').and.callThrough(); + + service.buildUrl(endpoint, attributes); + + expect(spy).toHaveBeenCalledWith( + 'getActiveConfigurations', + attributes.urlParams, + true + ); + }); + }); +}); diff --git a/integration-libs/opf/base/core/services/opf-endpoints.service.ts b/integration-libs/opf/base/core/services/opf-endpoints.service.ts new file mode 100644 index 00000000000..e4b34bd70ac --- /dev/null +++ b/integration-libs/opf/base/core/services/opf-endpoints.service.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { + BaseSiteService, + DynamicAttributes, + StringTemplate, +} from '@spartacus/core'; + +import { OpfApiConfig, OpfConfig } from '@spartacus/opf/base/root'; + +@Injectable({ + providedIn: 'root', +}) +export class OpfEndpointsService { + private _activeBaseSite: string; + + constructor( + protected opfConfig: OpfConfig, + protected opfApiConfig: OpfApiConfig, + protected baseSiteService: BaseSiteService + ) { + if (this.baseSiteService) { + this.baseSiteService + .getActive() + .subscribe((value) => (this._activeBaseSite = value)); + } + } + + buildUrl(endpoint: string, attributes?: DynamicAttributes): string { + const baseUrl = this.getBaseEndpoint(); + let opfEndpoint = this.getEndpointFromContext(endpoint); + if (attributes) { + const { urlParams } = attributes; + + if (urlParams && opfEndpoint) { + opfEndpoint = StringTemplate.resolve(opfEndpoint, urlParams, true); + } + } + + return `${baseUrl}/${this._activeBaseSite}/${opfEndpoint}`; + } + + private getEndpointFromContext(endpoint: string): string | undefined { + const endpointsConfig = this.opfApiConfig.backend?.opfApi?.endpoints; + + if (!endpointsConfig) { + return ''; + } + + const endpointConfig: any = + endpointsConfig[endpoint as keyof typeof endpointsConfig]; + + return endpointConfig; + } + + private getBaseEndpoint(): string { + if (this.opfConfig && this.opfConfig.opf && this.opfConfig.opf.opfBaseUrl) { + return this.opfConfig.opf.opfBaseUrl; + } + + return ''; + } +} diff --git a/integration-libs/opf/base/core/tokens/index.ts b/integration-libs/opf/base/core/tokens/index.ts new file mode 100644 index 00000000000..62a40ab0d42 --- /dev/null +++ b/integration-libs/opf/base/core/tokens/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './tokens'; diff --git a/integration-libs/opf/base/core/tokens/tokens.ts b/integration-libs/opf/base/core/tokens/tokens.ts new file mode 100644 index 00000000000..081bdd7decc --- /dev/null +++ b/integration-libs/opf/base/core/tokens/tokens.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InjectionToken } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { ActiveConfiguration } from '@spartacus/opf/base/root'; + +export const OPF_ACTIVE_CONFIGURATIONS_NORMALIZER = new InjectionToken< + Converter +>('OpfActiveConfigurationsNormalizer'); diff --git a/integration-libs/opf/base/ng-package.json b/integration-libs/opf/base/ng-package.json new file mode 100644 index 00000000000..38e01ac17de --- /dev/null +++ b/integration-libs/opf/base/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/base/opf-api/adapters/index.ts b/integration-libs/opf/base/opf-api/adapters/index.ts new file mode 100644 index 00000000000..d363f42831f --- /dev/null +++ b/integration-libs/opf/base/opf-api/adapters/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-api-base.adapter'; diff --git a/integration-libs/opf/base/opf-api/adapters/opf-api-base.adapter.spec.ts b/integration-libs/opf/base/opf-api/adapters/opf-api-base.adapter.spec.ts new file mode 100644 index 00000000000..a1f6a8fade6 --- /dev/null +++ b/integration-libs/opf/base/opf-api/adapters/opf-api-base.adapter.spec.ts @@ -0,0 +1,135 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpErrorResponse } from '@angular/common/http'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ConverterService, LoggerService } from '@spartacus/core'; +import { OpfApiBaseAdapter } from './opf-api-base.adapter'; +import { OpfEndpointsService } from '@spartacus/opf/base/core'; +import { + ActiveConfiguration, + OPF_CC_PUBLIC_KEY_HEADER, + OpfConfig, + OpfPaymentProviderType, +} from '@spartacus/opf/base/root'; +import { map } from 'rxjs'; + +const mockActiveConfigurations: ActiveConfiguration[] = [ + { + id: 1, + description: 'First active configuration', + merchantId: 'merchant-123', + providerType: OpfPaymentProviderType.PAYMENT_GATEWAY, + displayName: 'Payment Gateway 1', + acquirerCountryCode: 'US', + }, + { + id: 2, + description: 'Second active configuration', + merchantId: 'merchant-456', + providerType: OpfPaymentProviderType.PAYMENT_METHOD, + displayName: 'Payment Method 2', + acquirerCountryCode: 'CA', + }, +]; + +const mockErrorResponse = new HttpErrorResponse({ + error: 'test 404 error', + status: 404, + statusText: 'Not Found', +}); + +class MockLoggerService implements Partial { + log(): void {} + warn(): void {} + error(): void {} + info(): void {} + debug(): void {} +} + +const mockOpfConfig: OpfConfig = { + opf: { + commerceCloudPublicKey: 'test-public-key', + }, +}; + +class MockOpfEndpointsService implements Partial { + buildUrl(endpoint: string): string { + return `test-url/${endpoint}`; + } +} + +describe('OpfApiBaseAdapter', () => { + let service: OpfApiBaseAdapter; + let httpMock: HttpTestingController; + let converter: ConverterService; + let opfEndpointsService: OpfEndpointsService; + let logger: LoggerService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + OpfApiBaseAdapter, + ConverterService, + { provide: LoggerService, useClass: MockLoggerService }, + { provide: OpfEndpointsService, useClass: MockOpfEndpointsService }, + { provide: OpfConfig, useValue: mockOpfConfig }, + ], + }); + + service = TestBed.inject(OpfApiBaseAdapter); + httpMock = TestBed.inject(HttpTestingController); + converter = TestBed.inject(ConverterService); + opfEndpointsService = TestBed.inject(OpfEndpointsService); + logger = TestBed.inject(LoggerService); + + spyOn(converter, 'pipeable').and.returnValue( + map(() => mockActiveConfigurations) + ); + spyOn(logger, 'error').and.callThrough(); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + if (opfEndpointsService) { + } + expect(service).toBeTruthy(); + }); + + it('should fetch active configurations successfully', () => { + service.getActiveConfigurations().subscribe((result) => { + expect(result).toEqual(mockActiveConfigurations); + }); + + const req = httpMock.expectOne('test-url/getActiveConfigurations'); + expect(req.request.method).toBe('GET'); + expect(req.request.headers.get('Accept-Language')).toBe('en-us'); + expect(req.request.headers.get(OPF_CC_PUBLIC_KEY_HEADER)).toBe( + 'test-public-key' + ); + + req.flush(mockActiveConfigurations); + }); + + it('should handle http errors when fetching active configurations', () => { + service.getActiveConfigurations().subscribe({ + error: (error) => { + expect(error).toBeTruthy(); + }, + }); + + const req = httpMock.expectOne('test-url/getActiveConfigurations'); + req.flush(mockErrorResponse, { status: 404, statusText: 'Not Found' }); + }); +}); diff --git a/integration-libs/opf/base/opf-api/adapters/opf-api-base.adapter.ts b/integration-libs/opf/base/opf-api/adapters/opf-api-base.adapter.ts new file mode 100644 index 00000000000..34df182510c --- /dev/null +++ b/integration-libs/opf/base/opf-api/adapters/opf-api-base.adapter.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { + ConverterService, + LoggerService, + tryNormalizeHttpError, +} from '@spartacus/core'; +import { + OPF_ACTIVE_CONFIGURATIONS_NORMALIZER, + OpfBaseAdapter, + OpfEndpointsService, +} from '@spartacus/opf/base/core'; +import { + ActiveConfiguration, + OPF_CC_PUBLIC_KEY_HEADER, + OpfConfig, +} from '@spartacus/opf/base/root'; +import { Observable } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +@Injectable() +export class OpfApiBaseAdapter implements OpfBaseAdapter { + protected logger = inject(LoggerService); + + constructor( + protected http: HttpClient, + protected converter: ConverterService, + protected opfEndpointsService: OpfEndpointsService, + protected config: OpfConfig + ) {} + + protected headerWithNoLanguage: { [name: string]: string } = { + accept: 'application/json', + 'Content-Type': 'application/json', + }; + protected header: { [name: string]: string } = { + ...this.headerWithNoLanguage, + 'Accept-Language': 'en-us', + }; + + protected headerWithContentLanguage: { [name: string]: string } = { + ...this.headerWithNoLanguage, + 'Content-Language': 'en-us', + }; + + getActiveConfigurations(): Observable { + const headers = new HttpHeaders(this.header).set( + OPF_CC_PUBLIC_KEY_HEADER, + this.config.opf?.commerceCloudPublicKey || '' + ); + + return this.http + .get(this.getActiveConfigurationsEndpoint(), { + headers, + }) + .pipe( + catchError((error) => { + throw tryNormalizeHttpError(error, this.logger); + }), + this.converter.pipeable(OPF_ACTIVE_CONFIGURATIONS_NORMALIZER) + ); + } + + protected getActiveConfigurationsEndpoint(): string { + return this.opfEndpointsService.buildUrl('getActiveConfigurations'); + } +} diff --git a/integration-libs/opf/base/opf-api/config/default-opf-api-base-config.ts b/integration-libs/opf/base/opf-api/config/default-opf-api-base-config.ts new file mode 100644 index 00000000000..f841251c46b --- /dev/null +++ b/integration-libs/opf/base/opf-api/config/default-opf-api-base-config.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpfApiConfig } from '@spartacus/opf/base/root'; + +export const defaultOpfApiBaseConfig: OpfApiConfig = { + backend: { + opfApi: { + endpoints: { + getActiveConfigurations: 'active-configurations', + }, + }, + }, +}; diff --git a/integration-libs/opf/base/opf-api/model/index.ts b/integration-libs/opf/base/opf-api/model/index.ts new file mode 100644 index 00000000000..e5deeb22770 --- /dev/null +++ b/integration-libs/opf/base/opf-api/model/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-api-base-endpoints.model'; diff --git a/integration-libs/opf/base/opf-api/model/opf-api-base-endpoints.model.ts b/integration-libs/opf/base/opf-api/model/opf-api-base-endpoints.model.ts new file mode 100644 index 00000000000..49a1974bac0 --- /dev/null +++ b/integration-libs/opf/base/opf-api/model/opf-api-base-endpoints.model.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpfApiEndpoint } from '@spartacus/opf/base/root'; + +declare module '@spartacus/opf/base/root' { + interface OpfApiEndpoints { + /** + * Endpoint to get active payment configurations + */ + getActiveConfigurations?: string | OpfApiEndpoint; + } +} diff --git a/integration-libs/opf/base/opf-api/ng-package.json b/integration-libs/opf/base/opf-api/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/base/opf-api/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/base/opf-api/opf-api-base.module.ts b/integration-libs/opf/base/opf-api/opf-api-base.module.ts new file mode 100644 index 00000000000..1fdf1892974 --- /dev/null +++ b/integration-libs/opf/base/opf-api/opf-api-base.module.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { provideDefaultConfig } from '@spartacus/core'; +import { OpfBaseAdapter } from '@spartacus/opf/base/core'; +import { OpfApiBaseAdapter } from './adapters'; +import { defaultOpfApiBaseConfig } from './config/default-opf-api-base-config'; + +@NgModule({ + imports: [CommonModule], + providers: [ + provideDefaultConfig(defaultOpfApiBaseConfig), + { + provide: OpfBaseAdapter, + useClass: OpfApiBaseAdapter, + }, + ], +}) +export class OpfApiBaseModule {} diff --git a/integration-libs/opf/base/opf-api/public_api.ts b/integration-libs/opf/base/opf-api/public_api.ts new file mode 100644 index 00000000000..f55d858b490 --- /dev/null +++ b/integration-libs/opf/base/opf-api/public_api.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './model/index'; +export * from './opf-api-base.module'; diff --git a/integration-libs/opf/base/opf-base.module.ts b/integration-libs/opf/base/opf-base.module.ts new file mode 100644 index 00000000000..72f57d298a7 --- /dev/null +++ b/integration-libs/opf/base/opf-base.module.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfBaseComponentsModule } from '@spartacus/opf/base/components'; +import { OpfBaseCoreModule } from '@spartacus/opf/base/core'; +import { OpfApiBaseModule } from '@spartacus/opf/base/opf-api'; + +@NgModule({ + imports: [OpfApiBaseModule, OpfBaseCoreModule, OpfBaseComponentsModule], +}) +export class OpfBaseModule {} diff --git a/integration-libs/opf/base/public_api.ts b/integration-libs/opf/base/public_api.ts new file mode 100644 index 00000000000..e81320a9417 --- /dev/null +++ b/integration-libs/opf/base/public_api.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-base.module'; diff --git a/integration-libs/opf/base/root/config/constants.ts b/integration-libs/opf/base/root/config/constants.ts new file mode 100644 index 00000000000..9b80ffea156 --- /dev/null +++ b/integration-libs/opf/base/root/config/constants.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export const OPF_CC_PUBLIC_KEY_HEADER = 'sap-commerce-cloud-public-key'; +export const OPF_CC_ACCESS_CODE_HEADER = 'sap-commerce-cloud-access-code'; diff --git a/integration-libs/opf/base/root/config/default-opf-config.ts b/integration-libs/opf/base/root/config/default-opf-config.ts new file mode 100644 index 00000000000..892fa7fc041 --- /dev/null +++ b/integration-libs/opf/base/root/config/default-opf-config.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpfConfig } from './opf-config'; + +export const defaultOpfConfig: OpfConfig = { + opf: { + opfBaseUrl: '', + }, +}; diff --git a/integration-libs/opf/base/root/config/index.ts b/integration-libs/opf/base/root/config/index.ts new file mode 100644 index 00000000000..b491c261115 --- /dev/null +++ b/integration-libs/opf/base/root/config/index.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './constants'; +export * from './default-opf-config'; +export * from './opf-api-config'; +export * from './opf-config'; diff --git a/integration-libs/opf/base/root/config/opf-api-config.ts b/integration-libs/opf/base/root/config/opf-api-config.ts new file mode 100644 index 00000000000..8e3fcefe820 --- /dev/null +++ b/integration-libs/opf/base/root/config/opf-api-config.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { Config } from '@spartacus/core'; +import { OpfApiBackendConfig } from '../model/opf-api-backend-config.model'; + +@Injectable({ + providedIn: 'root', + useExisting: Config, +}) +export abstract class OpfApiConfig extends Config { + backend?: OpfApiBackendConfig; +} + +declare module '@spartacus/core' { + interface BackendConfig extends OpfApiBackendConfig {} +} diff --git a/integration-libs/opf/base/root/config/opf-config.ts b/integration-libs/opf/base/root/config/opf-config.ts new file mode 100644 index 00000000000..5fcde7c4cc4 --- /dev/null +++ b/integration-libs/opf/base/root/config/opf-config.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { Config } from '@spartacus/core'; + +@Injectable({ + providedIn: 'root', + useExisting: Config, +}) +export abstract class OpfConfig { + opf?: { + opfBaseUrl?: string; + commerceCloudPublicKey?: string; + }; +} + +declare module '@spartacus/core' { + interface Config extends OpfConfig {} +} diff --git a/integration-libs/opf/base/root/events/index.ts b/integration-libs/opf/base/root/events/index.ts new file mode 100644 index 00000000000..888125d4665 --- /dev/null +++ b/integration-libs/opf/base/root/events/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-event.listener'; diff --git a/integration-libs/opf/base/root/events/opf-event.listener.spec.ts b/integration-libs/opf/base/root/events/opf-event.listener.spec.ts new file mode 100644 index 00000000000..2b5fea81bbf --- /dev/null +++ b/integration-libs/opf/base/root/events/opf-event.listener.spec.ts @@ -0,0 +1,71 @@ +import { TestBed } from '@angular/core/testing'; +import { CreateCartEvent } from '@spartacus/cart/base/root'; +import { CxEvent, EventService, LoginEvent } from '@spartacus/core'; +import { OrderPlacedEvent } from '@spartacus/order/root'; +import { Subject } from 'rxjs'; +import { OpfMetadataStoreService } from '../services'; +import { OpfEventListenerService } from './opf-event.listener'; + +import createSpy = jasmine.createSpy; + +const mockEventStream$ = new Subject(); + +class MockOpfMetadataStoreService implements Partial { + clearOpfMetadata = createSpy(); +} + +class MockEventService implements Partial { + get = createSpy().and.returnValue(mockEventStream$.asObservable()); +} + +describe(`OpfEventListenerService`, () => { + let opfMetadataStoreService: OpfMetadataStoreService; + let service: OpfEventListenerService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OpfEventListenerService, + { + provide: EventService, + useClass: MockEventService, + }, + { + provide: OpfMetadataStoreService, + useClass: MockOpfMetadataStoreService, + }, + ], + }); + + service = TestBed.inject(OpfEventListenerService); + opfMetadataStoreService = TestBed.inject(OpfMetadataStoreService); + }); + + describe(`onOpfPaymentMetadataResetConditionsMet`, () => { + it(`LoginEvent should call clearOpfMetadata() method`, () => { + mockEventStream$.next(new LoginEvent()); + + expect(opfMetadataStoreService.clearOpfMetadata).toHaveBeenCalled(); + }); + + it(`OrderPlacedEvent should call clearOpfMetadata() method`, () => { + mockEventStream$.next(new OrderPlacedEvent()); + + expect(opfMetadataStoreService.clearOpfMetadata).toHaveBeenCalled(); + }); + + it(`CreateCartEvent should call clearOpfMetadata() method`, () => { + mockEventStream$.next(new CreateCartEvent()); + + expect(opfMetadataStoreService.clearOpfMetadata).toHaveBeenCalled(); + }); + + it('should unsubscribe from subscriptions on destroy', () => { + spyOn(service['subscriptions'], 'unsubscribe'); + + service.ngOnDestroy(); + + expect(service['subscriptions'].unsubscribe).toHaveBeenCalled(); + }); + }); +}); diff --git a/integration-libs/opf/base/root/events/opf-event.listener.ts b/integration-libs/opf/base/root/events/opf-event.listener.ts new file mode 100644 index 00000000000..8fd17130387 --- /dev/null +++ b/integration-libs/opf/base/root/events/opf-event.listener.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable, OnDestroy } from '@angular/core'; +import { CreateCartEvent } from '@spartacus/cart/base/root'; +import { EventService, LoginEvent } from '@spartacus/core'; +import { OrderPlacedEvent } from '@spartacus/order/root'; +import { Subscription, merge } from 'rxjs'; +import { OpfMetadataStoreService } from '../services'; + +@Injectable({ + providedIn: 'root', +}) +export class OpfEventListenerService implements OnDestroy { + protected subscriptions = new Subscription(); + + constructor( + protected eventService: EventService, + protected opfMetadataStoreService: OpfMetadataStoreService + ) { + this.onOpfPaymentMetadataResetConditionsMet(); + } + + protected onOpfPaymentMetadataResetConditionsMet(): void { + this.subscriptions.add( + merge( + this.eventService.get(LoginEvent), + this.eventService.get(OrderPlacedEvent), + this.eventService.get(CreateCartEvent) + ).subscribe(() => this.opfMetadataStoreService.clearOpfMetadata()) + ); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } +} diff --git a/integration-libs/opf/base/root/events/opf-event.module.ts b/integration-libs/opf/base/root/events/opf-event.module.ts new file mode 100644 index 00000000000..1b84d53247a --- /dev/null +++ b/integration-libs/opf/base/root/events/opf-event.module.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfEventListenerService } from './opf-event.listener'; + +@NgModule({}) +export class OpfEventModule { + constructor(_opfEventListenerService: OpfEventListenerService) { + // Intentional empty constructor + } +} diff --git a/integration-libs/opf/base/root/facade/index.ts b/integration-libs/opf/base/root/facade/index.ts new file mode 100644 index 00000000000..12742df7438 --- /dev/null +++ b/integration-libs/opf/base/root/facade/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-base.facade'; diff --git a/integration-libs/opf/base/root/facade/opf-base.facade.ts b/integration-libs/opf/base/root/facade/opf-base.facade.ts new file mode 100644 index 00000000000..e4a5189d4a0 --- /dev/null +++ b/integration-libs/opf/base/root/facade/opf-base.facade.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { facadeFactory, QueryState } from '@spartacus/core'; +import { Observable } from 'rxjs'; +import { OPF_BASE_FEATURE } from '../feature-name'; +import { ActiveConfiguration } from '../model'; + +@Injectable({ + providedIn: 'root', + useFactory: () => + facadeFactory({ + facade: OpfBaseFacade, + feature: OPF_BASE_FEATURE, + methods: ['getActiveConfigurationsState'], + }), +}) +export abstract class OpfBaseFacade { + /** + * Get payment active configurations + */ + abstract getActiveConfigurationsState(): Observable< + QueryState + >; +} diff --git a/integration-libs/opf/base/root/feature-name.ts b/integration-libs/opf/base/root/feature-name.ts new file mode 100644 index 00000000000..1c20225be2c --- /dev/null +++ b/integration-libs/opf/base/root/feature-name.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export const OPF_BASE_FEATURE = 'opfBase'; diff --git a/integration-libs/opf/base/root/model/augmented-core.model.ts b/integration-libs/opf/base/root/model/augmented-core.model.ts new file mode 100644 index 00000000000..6b829e259b8 --- /dev/null +++ b/integration-libs/opf/base/root/model/augmented-core.model.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@spartacus/storefront'; + +declare module '@spartacus/storefront' { + const enum LAUNCH_CALLER { + OPF_ERROR = 'OPF_ERROR', + } +} diff --git a/integration-libs/opf/base/root/model/index.ts b/integration-libs/opf/base/root/model/index.ts new file mode 100644 index 00000000000..625d43908ae --- /dev/null +++ b/integration-libs/opf/base/root/model/index.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import './augmented-core.model'; +export * from './opf-api-backend-config.model'; +export * from './opf-api-endpoint.model'; +export * from './opf-api-endpoints.model'; +export * from './opf-base.model'; +export * from './opf-error-dialog.model'; +export * from './opf-metadata-store.model'; diff --git a/integration-libs/opf/base/root/model/opf-api-backend-config.model.ts b/integration-libs/opf/base/root/model/opf-api-backend-config.model.ts new file mode 100644 index 00000000000..69a32bc27f0 --- /dev/null +++ b/integration-libs/opf/base/root/model/opf-api-backend-config.model.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpfApiEndpoints } from './opf-api-endpoints.model'; + +export interface OpfApiBackendConfig { + opfApi?: { + endpoints?: OpfApiEndpoints; + }; +} diff --git a/integration-libs/opf/base/root/model/opf-api-endpoint.model.ts b/integration-libs/opf/base/root/model/opf-api-endpoint.model.ts new file mode 100644 index 00000000000..e89dce073c6 --- /dev/null +++ b/integration-libs/opf/base/root/model/opf-api-endpoint.model.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface OpfApiEndpoint { + default?: string; + [scope: string]: string | undefined; +} diff --git a/integration-libs/opf/base/root/model/opf-api-endpoints.model.ts b/integration-libs/opf/base/root/model/opf-api-endpoints.model.ts new file mode 100644 index 00000000000..7edb4bdba3c --- /dev/null +++ b/integration-libs/opf/base/root/model/opf-api-endpoints.model.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface OpfApiEndpoints {} diff --git a/integration-libs/opf/base/root/model/opf-base.model.ts b/integration-libs/opf/base/root/model/opf-base.model.ts new file mode 100644 index 00000000000..ca4bc4a3911 --- /dev/null +++ b/integration-libs/opf/base/root/model/opf-base.model.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface ActiveConfiguration { + description?: string; + id?: number; + merchantId?: string; + providerType?: OpfPaymentProviderType; + displayName?: string; + acquirerCountryCode?: string; + logoUrl?: string; +} + +export interface OpfDynamicScript { + cssUrls?: OpfDynamicScriptResource[]; + jsUrls?: OpfDynamicScriptResource[]; + html?: string; +} + +export interface KeyValuePair { + key: string; + value: string; +} + +export interface OpfDynamicScriptResource { + url?: string; + sri?: string; + attributes?: KeyValuePair[]; + type?: OpfDynamicScriptResourceType; +} + +export enum OpfDynamicScriptResourceType { + SCRIPT = 'SCRIPT', + STYLES = 'STYLES', +} + +export enum OpfPaymentProviderType { + PAYMENT_GATEWAY = 'PAYMENT_GATEWAY', + PAYMENT_METHOD = 'PAYMENT_METHOD', +} diff --git a/integration-libs/opf/base/root/model/opf-error-dialog.model.ts b/integration-libs/opf/base/root/model/opf-error-dialog.model.ts new file mode 100644 index 00000000000..d7614b4e7a2 --- /dev/null +++ b/integration-libs/opf/base/root/model/opf-error-dialog.model.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export type ErrorDialogOptions = { + confirmString?: string; + confirmKey?: string; + confirmReplacements?: any; + messageString?: string; + messageKey?: string; + messageReplacements?: any; +}; + +export const defaultErrorDialogOptions: ErrorDialogOptions = { + messageKey: 'opfPayment.errors.proceedPayment', + confirmKey: 'common.continue', +}; diff --git a/integration-libs/opf/base/root/model/opf-metadata-store.model.ts b/integration-libs/opf/base/root/model/opf-metadata-store.model.ts new file mode 100644 index 00000000000..ea42b6a4d82 --- /dev/null +++ b/integration-libs/opf/base/root/model/opf-metadata-store.model.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface OpfMetadataModel { + termsAndConditionsChecked: boolean; + selectedPaymentOptionId: number | undefined; + defaultSelectedPaymentOptionId?: number; + isPaymentInProgress: boolean; + paymentSessionId: string | undefined; +} diff --git a/integration-libs/opf/base/root/ng-package.json b/integration-libs/opf/base/root/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/base/root/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/base/root/opf-base-root.module.spec.ts b/integration-libs/opf/base/root/opf-base-root.module.spec.ts new file mode 100644 index 00000000000..2f63962f083 --- /dev/null +++ b/integration-libs/opf/base/root/opf-base-root.module.spec.ts @@ -0,0 +1,43 @@ +/* + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { APP_INITIALIZER } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { OpfBaseRootModule } from './opf-base-root.module'; +import { OpfMetadataStatePersistanceService } from './services/opf-metadata-state-persistence.service'; + +describe('OpfBaseRootModule', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [OpfBaseRootModule], + }); + }); + + it('should create the module', () => { + const module = TestBed.inject(OpfBaseRootModule); + expect(module).toBeTruthy(); + }); + + it('should provide APP_INITIALIZER with opfStatePersistenceFactory', () => { + const appInitializer = TestBed.inject(APP_INITIALIZER); + const opfStatePersistenceService = TestBed.inject( + OpfMetadataStatePersistanceService + ); + + expect(appInitializer).toBeDefined(); + expect(appInitializer.length).toBe(1); + const initFunction = appInitializer[0]; + expect(initFunction).toBeDefined(); + initFunction(); + + const spyInitSync = spyOn( + opfStatePersistenceService, + 'initSync' + ).and.callThrough(); + + initFunction(); + expect(spyInitSync).toHaveBeenCalled(); + }); +}); diff --git a/integration-libs/opf/base/root/opf-base-root.module.ts b/integration-libs/opf/base/root/opf-base-root.module.ts new file mode 100644 index 00000000000..db9dfca3387 --- /dev/null +++ b/integration-libs/opf/base/root/opf-base-root.module.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { APP_INITIALIZER, inject, NgModule } from '@angular/core'; +import { GlobalMessageService, provideDefaultConfig } from '@spartacus/core'; +import { defaultOpfConfig } from './config/default-opf-config'; +import { OpfEventModule } from './events/opf-event.module'; +import { + OpfGlobalMessageService, + OpfMetadataStatePersistanceService, +} from './services'; + +export function opfStatePersistenceFactory(): () => void { + const opfStatePersistenceService = inject(OpfMetadataStatePersistanceService); + return () => opfStatePersistenceService.initSync(); +} +@NgModule({ + imports: [OpfEventModule], + providers: [ + { + provide: APP_INITIALIZER, + useFactory: opfStatePersistenceFactory, + multi: true, + }, + { + provide: GlobalMessageService, + useExisting: OpfGlobalMessageService, + }, + provideDefaultConfig(defaultOpfConfig), + ], +}) +export class OpfBaseRootModule {} diff --git a/integration-libs/opf/base/root/public_api.ts b/integration-libs/opf/base/root/public_api.ts new file mode 100644 index 00000000000..6f194c71e7e --- /dev/null +++ b/integration-libs/opf/base/root/public_api.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './config/index'; +export * from './events/index'; +export * from './facade/index'; +export * from './feature-name'; +export * from './model/index'; +export * from './opf-base-root.module'; +export * from './services/index'; diff --git a/integration-libs/opf/base/root/services/index.ts b/integration-libs/opf/base/root/services/index.ts new file mode 100644 index 00000000000..ee67629ce76 --- /dev/null +++ b/integration-libs/opf/base/root/services/index.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-global-message.service'; +export * from './opf-metadata-state-persistence.service'; +export * from './opf-metadata-store.service'; +export * from './opf-resource-loader.service'; diff --git a/integration-libs/opf/base/root/services/opf-global-message.service.spec.ts b/integration-libs/opf/base/root/services/opf-global-message.service.spec.ts new file mode 100644 index 00000000000..2854b34d38e --- /dev/null +++ b/integration-libs/opf/base/root/services/opf-global-message.service.spec.ts @@ -0,0 +1 @@ +// to be done diff --git a/integration-libs/opf/base/root/services/opf-global-message.service.ts b/integration-libs/opf/base/root/services/opf-global-message.service.ts new file mode 100644 index 00000000000..606641ce4e4 --- /dev/null +++ b/integration-libs/opf/base/root/services/opf-global-message.service.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { + GlobalMessageService, + GlobalMessageType, + StateWithGlobalMessage, + Translatable, +} from '@spartacus/core'; +import { timer } from 'rxjs'; +import { take } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root', +}) +export class OpfGlobalMessageService extends GlobalMessageService { + protected isGlobalMessageDisabled = false; + protected disabledKeys: string[] = []; + protected defaultTimeout = 2000; + constructor(protected store: Store) { + super(store); + } + /** + * Add one message into store + * @param text: string | Translatable + * @param type: GlobalMessageType object + * @param timeout: number + */ + add( + text: string | Translatable, + type: GlobalMessageType, + timeout?: number + ): void { + if ( + this.isGlobalMessageDisabled && + this.disabledKeys?.length && + (text as Translatable)?.key && + this.disabledKeys.includes((text as Translatable).key as string) + ) { + return; + } + super.add(text, type, timeout); + } + + /** + * disable specific keys for a period of time + * @param keys: string[] + * @param timeout: number + */ + disableGlobalMessage(keys: string[], timeout?: number): void { + this.isGlobalMessageDisabled = true; + this.disabledKeys = keys; + timer(timeout ?? this.defaultTimeout) + .pipe(take(1)) + .subscribe(() => { + this.isGlobalMessageDisabled = false; + this.disabledKeys = []; + }); + } +} diff --git a/integration-libs/opf/base/root/services/opf-metadata-state-persistence.service.spec.ts b/integration-libs/opf/base/root/services/opf-metadata-state-persistence.service.spec.ts new file mode 100644 index 00000000000..a95826354a0 --- /dev/null +++ b/integration-libs/opf/base/root/services/opf-metadata-state-persistence.service.spec.ts @@ -0,0 +1,108 @@ +/* + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { StatePersistenceService } from '@spartacus/core'; +import { BehaviorSubject, of, Subscription } from 'rxjs'; +import { OpfMetadataModel } from '../model'; +import { + OpfMetadataStatePersistanceService, + SyncedOpfState, +} from './opf-metadata-state-persistence.service'; +import { OpfMetadataStoreService } from './opf-metadata-store.service'; + +const mockOpfMetadata: OpfMetadataModel = { + isPaymentInProgress: true, + selectedPaymentOptionId: 111, + termsAndConditionsChecked: true, + paymentSessionId: '111111', +}; + +describe('OpfMetadataStatePersistanceService', () => { + let service: OpfMetadataStatePersistanceService; + let statePersistenceServiceMock: jasmine.SpyObj; + let opfMetadataStoreServiceMock: jasmine.SpyObj; + + beforeEach(() => { + statePersistenceServiceMock = jasmine.createSpyObj( + 'StatePersistenceService', + ['syncWithStorage'] + ); + opfMetadataStoreServiceMock = jasmine.createSpyObj( + 'OpMetadataStoreService', + ['getOpfMetadataState', 'updateOpfMetadata'] + ); + + TestBed.configureTestingModule({ + providers: [ + OpfMetadataStatePersistanceService, + { + provide: StatePersistenceService, + useValue: statePersistenceServiceMock, + }, + { + provide: OpfMetadataStoreService, + useValue: opfMetadataStoreServiceMock, + }, + ], + }); + + service = TestBed.inject(OpfMetadataStatePersistanceService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should initialize the synchronization with state and browser storage', () => { + const mockSyncedOpfState: SyncedOpfState = { + metadata: mockOpfMetadata, + }; + + const stateObservable = new BehaviorSubject( + mockSyncedOpfState + ); + + opfMetadataStoreServiceMock.getOpfMetadataState.and.returnValue( + of(stateObservable.value?.metadata) + ); + + service.initSync(); + + expect(statePersistenceServiceMock.syncWithStorage).toHaveBeenCalled(); + }); + + it('should get and transform Opf state', (done) => { + const stateObservable = new BehaviorSubject( + mockOpfMetadata + ); + opfMetadataStoreServiceMock.getOpfMetadataState.and.returnValue( + stateObservable + ); + + service['getOpfState']().subscribe((state: any) => { + expect(state).toEqual({ metadata: mockOpfMetadata }); + done(); + }); + }); + + it('should update OpfMetadataStoreService when onRead is called', () => { + const mockSyncedOpfState: SyncedOpfState = { + metadata: mockOpfMetadata, + }; + + service['onRead'](mockSyncedOpfState); + + expect(opfMetadataStoreServiceMock.updateOpfMetadata).toHaveBeenCalledWith( + mockOpfMetadata + ); + }); + + it('should unsubscribe on ngOnDestroy', () => { + spyOn(Subscription.prototype, 'unsubscribe'); + service.ngOnDestroy(); + expect(Subscription.prototype.unsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/integration-libs/opf/base/root/services/opf-metadata-state-persistence.service.ts b/integration-libs/opf/base/root/services/opf-metadata-state-persistence.service.ts new file mode 100644 index 00000000000..e998a2c2982 --- /dev/null +++ b/integration-libs/opf/base/root/services/opf-metadata-state-persistence.service.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable, OnDestroy } from '@angular/core'; +import { StatePersistenceService } from '@spartacus/core'; +import { Observable, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { OpfMetadataModel } from '../model/opf-metadata-store.model'; +import { OpfMetadataStoreService } from './opf-metadata-store.service'; + +/** + * OPF state synced to browser storage. + */ +export interface SyncedOpfState { + metadata?: OpfMetadataModel; +} + +/** + * Responsible for storing OPF state in the browser storage. + * Uses `StatePersistenceService` mechanism. + */ +@Injectable({ providedIn: 'root' }) +export class OpfMetadataStatePersistanceService implements OnDestroy { + protected subscription = new Subscription(); + + constructor( + protected statePersistenceService: StatePersistenceService, + protected opfMetadataStoreService: OpfMetadataStoreService + ) {} + + /** + * Identifier used for storage key. + */ + protected key = 'opf'; + + /** + * Initializes the synchronization between state and browser storage. + */ + public initSync() { + this.subscription.add( + this.statePersistenceService.syncWithStorage({ + key: this.key, + state$: this.getOpfState(), + onRead: (state) => this.onRead(state), + }) + ); + } + + /** + * Gets and transforms state from different sources into the form that should + * be saved in storage. + */ + protected getOpfState(): Observable { + return this.opfMetadataStoreService.getOpfMetadataState().pipe( + map((metadata: OpfMetadataModel) => { + return { + metadata, + }; + }) + ); + } + + /** + * Function called on each browser storage read. + * Used to update state from browser -> state. + */ + protected onRead(state: SyncedOpfState | undefined) { + if (state && state.metadata) { + this.opfMetadataStoreService.updateOpfMetadata(state.metadata); + } + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} diff --git a/integration-libs/opf/base/root/services/opf-metadata-store.service.spec.ts b/integration-libs/opf/base/root/services/opf-metadata-store.service.spec.ts new file mode 100644 index 00000000000..8347fbb29f8 --- /dev/null +++ b/integration-libs/opf/base/root/services/opf-metadata-store.service.spec.ts @@ -0,0 +1,87 @@ +/* + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { OpfMetadataModel } from '../model'; +import { OpfMetadataStoreService } from './opf-metadata-store.service'; + +const initialState = { + termsAndConditionsChecked: false, + selectedPaymentOptionId: undefined, + isPaymentInProgress: false, + paymentSessionId: undefined, +}; + +const state: OpfMetadataModel = { + isPaymentInProgress: true, + selectedPaymentOptionId: 111, + termsAndConditionsChecked: true, + paymentSessionId: '111111', +}; + +describe('OpfMetadataStoreService', () => { + let service: OpfMetadataStoreService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [OpfMetadataStoreService], + }); + + service = TestBed.inject(OpfMetadataStoreService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should initialize with the initial state', () => { + expect(service.opfMetadataState.value).toEqual(initialState); + }); + + it('should return the current OpfMetadataStoreService as an observable', (done) => { + service.opfMetadataState.next(state); + + service.getOpfMetadataState().subscribe((state) => { + expect(state).toEqual(state); + done(); + }); + }); + + it('should update OpfMetadataStoreService with the given payload', () => { + const mockedState: OpfMetadataModel = { + ...state, + isPaymentInProgress: false, + }; + + service.opfMetadataState.next(mockedState); + + const updatedPayload = { + isPaymentInProgress: true, + termsAndConditionsChecked: false, + }; + + service.updateOpfMetadata(updatedPayload); + + expect(service.opfMetadataState.value).toEqual({ + ...mockedState, + ...updatedPayload, + }); + }); + + it('should clear OpfMetadataStoreService and set it back to the initial state', () => { + const state = { + isPaymentInProgress: true, + termsAndConditionsChecked: true, + selectedPaymentOptionId: 111, + paymentSessionId: '111111', + }; + + service.opfMetadataState.next(state); + + service.clearOpfMetadata(); + + expect(service.opfMetadataState.value).toEqual(initialState); + }); +}); diff --git a/integration-libs/opf/base/root/services/opf-metadata-store.service.ts b/integration-libs/opf/base/root/services/opf-metadata-store.service.ts new file mode 100644 index 00000000000..7c341437bf2 --- /dev/null +++ b/integration-libs/opf/base/root/services/opf-metadata-store.service.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { OpfMetadataModel } from '../model/opf-metadata-store.model'; + +const initialState: OpfMetadataModel = { + termsAndConditionsChecked: false, + selectedPaymentOptionId: undefined, + isPaymentInProgress: false, + paymentSessionId: undefined, +}; + +@Injectable({ providedIn: 'root' }) +export class OpfMetadataStoreService { + opfMetadataState = new BehaviorSubject(initialState); + + getOpfMetadataState(): Observable { + return this.opfMetadataState.asObservable(); + } + + updateOpfMetadata(payload: Partial): void { + this.opfMetadataState.next({ + ...this.opfMetadataState.value, + ...payload, + }); + } + + clearOpfMetadata(): void { + this.opfMetadataState.next(initialState); + } +} diff --git a/integration-libs/opf/base/root/services/opf-resource-loader.service.spec.ts b/integration-libs/opf/base/root/services/opf-resource-loader.service.spec.ts new file mode 100644 index 00000000000..08a4d7093b3 --- /dev/null +++ b/integration-libs/opf/base/root/services/opf-resource-loader.service.spec.ts @@ -0,0 +1,398 @@ +import { DOCUMENT } from '@angular/common'; +import { PLATFORM_ID } from '@angular/core'; +import { TestBed, fakeAsync } from '@angular/core/testing'; +import { ScriptLoader } from '@spartacus/core'; +import { OpfDynamicScriptResourceType } from '../model'; +import { OpfResourceLoaderService } from './opf-resource-loader.service'; + +describe('OpfResourceLoaderService', () => { + let opfResourceLoaderService: OpfResourceLoaderService; + let mockDocument: any; + let mockPlatformId: Object; + + beforeEach(() => { + mockDocument = { + createElement: jasmine.createSpy('createElement').and.callFake(() => ({ + href: '', + rel: '', + type: '', + setAttribute: jasmine.createSpy('setAttribute'), + addEventListener: jasmine.createSpy('addEventListener'), + })), + head: { + appendChild: jasmine.createSpy('appendChild'), + }, + querySelector: jasmine.createSpy('querySelector'), + }; + + mockPlatformId = 'browser'; + + TestBed.configureTestingModule({ + providers: [ + OpfResourceLoaderService, + { provide: DOCUMENT, useValue: mockDocument }, + { provide: PLATFORM_ID, useValue: mockPlatformId }, + ], + }); + }); + + it('should be created', () => { + opfResourceLoaderService = TestBed.inject(OpfResourceLoaderService); + expect(opfResourceLoaderService).toBeTruthy(); + }); + + it('should create OpfResourceLoaderService instance', () => { + opfResourceLoaderService = TestBed.inject(OpfResourceLoaderService); + expect(opfResourceLoaderService instanceof ScriptLoader).toBe(true); + }); + + describe('loadProviderResources', () => { + beforeEach(() => { + opfResourceLoaderService = TestBed.inject(OpfResourceLoaderService); + }); + + it('should load provider resources successfully for both scripts and styles', fakeAsync(() => { + const mockScriptResource = { + url: 'script-url', + type: OpfDynamicScriptResourceType.SCRIPT, + }; + + const mockStyleResource = { + url: 'style-url', + type: OpfDynamicScriptResourceType.STYLES, + }; + + spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); + spyOn(opfResourceLoaderService, 'loadStyles').and.callThrough(); + + opfResourceLoaderService.loadProviderResources( + [mockScriptResource], + [mockStyleResource] + ); + + expect(opfResourceLoaderService['loadStyles']).toHaveBeenCalled(); + expect(opfResourceLoaderService['loadScript']).toHaveBeenCalled(); + })); + + it('should load provider resources successfully for scripts', fakeAsync(() => { + const mockScriptResource = { + url: 'script-url', + type: OpfDynamicScriptResourceType.SCRIPT, + }; + + spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); + spyOn(opfResourceLoaderService, 'loadStyles').and.callThrough(); + + opfResourceLoaderService.loadProviderResources([mockScriptResource]); + + expect(opfResourceLoaderService['loadStyles']).not.toHaveBeenCalled(); + expect(opfResourceLoaderService['loadScript']).toHaveBeenCalled(); + })); + + it('should load provider resources successfully for styles', fakeAsync(() => { + const mockStyleResource = { + url: 'style-url', + type: OpfDynamicScriptResourceType.STYLES, + }; + + spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); + spyOn(opfResourceLoaderService, 'loadStyles').and.callThrough(); + + opfResourceLoaderService.loadProviderResources([], [mockStyleResource]); + + expect(opfResourceLoaderService['loadScript']).not.toHaveBeenCalled(); + expect(opfResourceLoaderService['loadStyles']).toHaveBeenCalled(); + })); + + it('should load provider resources successfully for styles with no url', fakeAsync(() => { + const mockStyleResource = { + type: OpfDynamicScriptResourceType.STYLES, + }; + + spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); + spyOn(opfResourceLoaderService, 'loadStyles').and.callThrough(); + spyOn( + opfResourceLoaderService, + 'markResourceAsLoaded' + ).and.callThrough(); + + opfResourceLoaderService.loadProviderResources([], [mockStyleResource]); + + expect(opfResourceLoaderService['loadScript']).not.toHaveBeenCalled(); + expect(opfResourceLoaderService['loadStyles']).not.toHaveBeenCalled(); + expect( + opfResourceLoaderService['markResourceAsLoaded'] + ).toHaveBeenCalled(); + })); + + it('should not load provider resources when no resources are provided', fakeAsync(() => { + spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); + spyOn(opfResourceLoaderService, 'loadStyles').and.callThrough(); + spyOn( + opfResourceLoaderService, + 'markResourceAsLoaded' + ).and.callThrough(); + + opfResourceLoaderService.loadProviderResources(); + + expect(opfResourceLoaderService['loadScript']).not.toHaveBeenCalled(); + expect(opfResourceLoaderService['loadStyles']).not.toHaveBeenCalled(); + expect( + opfResourceLoaderService['markResourceAsLoaded'] + ).not.toHaveBeenCalled(); + })); + + it('should mark resource as loaded when script is successfully loaded', fakeAsync(() => { + const mockScriptResource = { + url: 'script-url', + type: OpfDynamicScriptResourceType.SCRIPT, + }; + + spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); + spyOn(opfResourceLoaderService, 'loadStyles').and.callThrough(); + spyOn( + opfResourceLoaderService, + 'markResourceAsLoaded' + ).and.callThrough(); + spyOn(ScriptLoader.prototype, 'embedScript').and.callFake( + (options: any) => { + options.callback?.(); + } + ); + + opfResourceLoaderService.loadProviderResources([mockScriptResource]); + + expect(opfResourceLoaderService['loadStyles']).not.toHaveBeenCalled(); + expect(opfResourceLoaderService['loadScript']).toHaveBeenCalled(); + expect(ScriptLoader.prototype.embedScript).toHaveBeenCalled(); + expect( + opfResourceLoaderService['markResourceAsLoaded'] + ).toHaveBeenCalled(); + })); + + it('should handle resource loading error when script is not successfully loaded', fakeAsync(() => { + const mockScriptResource = { + url: 'script-url', + type: OpfDynamicScriptResourceType.SCRIPT, + }; + + spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); + spyOn(opfResourceLoaderService, 'loadStyles').and.callThrough(); + spyOn( + opfResourceLoaderService, + 'markResourceAsLoaded' + ).and.callThrough(); + spyOn( + opfResourceLoaderService, + 'handleLoadingResourceError' + ).and.callThrough(); + spyOn(ScriptLoader.prototype, 'embedScript').and.callFake( + (options: any) => { + options.errorCallback?.(); + } + ); + + opfResourceLoaderService.loadProviderResources([mockScriptResource]); + + expect(opfResourceLoaderService['loadStyles']).not.toHaveBeenCalled(); + expect(opfResourceLoaderService['loadScript']).toHaveBeenCalled(); + expect( + opfResourceLoaderService['markResourceAsLoaded'] + ).not.toHaveBeenCalled(); + expect(ScriptLoader.prototype.embedScript).toHaveBeenCalled(); + expect( + opfResourceLoaderService['handleLoadingResourceError'] + ).toHaveBeenCalled(); + })); + + it('should mark resource as loaded when style is successfully loaded', fakeAsync(() => { + const mockStylesResources = { + url: 'style-url', + type: OpfDynamicScriptResourceType.STYLES, + }; + + spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); + spyOn(opfResourceLoaderService, 'loadStyles').and.callThrough(); + spyOn( + opfResourceLoaderService, + 'markResourceAsLoaded' + ).and.callThrough(); + spyOn(opfResourceLoaderService, 'embedStyles').and.callFake( + (options: any) => { + options.callback?.(); // Simulate script loading + } + ); + + opfResourceLoaderService.loadProviderResources([], [mockStylesResources]); + + expect(opfResourceLoaderService['loadScript']).not.toHaveBeenCalled(); + expect(opfResourceLoaderService['loadStyles']).toHaveBeenCalled(); + expect( + opfResourceLoaderService['markResourceAsLoaded'] + ).toHaveBeenCalled(); + expect(opfResourceLoaderService['embedStyles']).toHaveBeenCalled(); + })); + + it('should handle resource loading error when style is not successfully loaded', fakeAsync(() => { + const mockStylesResources = { + url: 'style-url', + type: OpfDynamicScriptResourceType.STYLES, + }; + + spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); + spyOn(opfResourceLoaderService, 'loadStyles').and.callThrough(); + spyOn( + opfResourceLoaderService, + 'markResourceAsLoaded' + ).and.callThrough(); + spyOn( + opfResourceLoaderService, + 'handleLoadingResourceError' + ).and.callThrough(); + spyOn(opfResourceLoaderService, 'embedStyles').and.callFake( + (options: any) => { + options.errorCallback?.(); // Simulate script loading + } + ); + + opfResourceLoaderService.loadProviderResources([], [mockStylesResources]); + + expect(opfResourceLoaderService['loadScript']).not.toHaveBeenCalled(); + expect( + opfResourceLoaderService['markResourceAsLoaded'] + ).not.toHaveBeenCalled(); + expect(opfResourceLoaderService['loadStyles']).toHaveBeenCalled(); + expect(opfResourceLoaderService['embedStyles']).toHaveBeenCalled(); + expect( + opfResourceLoaderService['handleLoadingResourceError'] + ).toHaveBeenCalled(); + })); + + it('should not embed styles if there is no style in the element', fakeAsync(() => { + const mockStyleResource = { + url: 'style-url', + type: OpfDynamicScriptResourceType.STYLES, + }; + + spyOn(opfResourceLoaderService, 'embedStyles').and.callThrough(); + spyOn( + opfResourceLoaderService, + 'markResourceAsLoaded' + ).and.callThrough(); + mockDocument.querySelector = jasmine + .createSpy('querySelector') + .and.returnValue({} as Element); + + opfResourceLoaderService.loadProviderResources([], [mockStyleResource]); + + expect(opfResourceLoaderService['embedStyles']).not.toHaveBeenCalled(); + expect( + opfResourceLoaderService['markResourceAsLoaded'] + ).toHaveBeenCalled(); + })); + + it('should not embed script if there is no script in the element', fakeAsync(() => { + const mockScriptResource = { + url: 'script-url', + type: OpfDynamicScriptResourceType.SCRIPT, + }; + + spyOn(opfResourceLoaderService, 'embedScript').and.callThrough(); + spyOn( + opfResourceLoaderService, + 'markResourceAsLoaded' + ).and.callThrough(); + mockDocument.querySelector = jasmine + .createSpy('querySelector') + .and.returnValue({} as Element); + + opfResourceLoaderService.loadProviderResources([mockScriptResource]); + + expect(opfResourceLoaderService['embedScript']).not.toHaveBeenCalled(); + expect( + opfResourceLoaderService['markResourceAsLoaded'] + ).toHaveBeenCalled(); + })); + }); + + describe('loadProviderResources using server platform', () => { + beforeEach(() => { + TestBed.overrideProvider(PLATFORM_ID, { useValue: 'server' }); + opfResourceLoaderService = TestBed.inject(OpfResourceLoaderService); + }); + + it('should not loadStyles with SSR when platform is set to server', fakeAsync(() => { + const mockStyleResource = { + url: 'style-url', + type: OpfDynamicScriptResourceType.STYLES, + }; + + spyOn(opfResourceLoaderService, 'loadStyles').and.callThrough(); + opfResourceLoaderService.loadProviderResources([], [mockStyleResource]); + expect(opfResourceLoaderService['loadStyles']).not.toHaveBeenCalled(); + })); + + it('should not loadScript with SSR when platform is set to server', fakeAsync(() => { + const mockScriptResource = { + url: 'script-url', + type: OpfDynamicScriptResourceType.SCRIPT, + }; + spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); + opfResourceLoaderService.loadProviderResources([], [mockScriptResource]); + expect(opfResourceLoaderService['loadScript']).not.toHaveBeenCalled(); + })); + }); + + describe('clearAllProviderResources', () => { + it('should clear all provider resources', () => { + opfResourceLoaderService = TestBed.inject(OpfResourceLoaderService); + + const mockLinkElement = { + remove: jasmine.createSpy('remove'), + }; + + mockDocument.querySelectorAll = jasmine + .createSpy('querySelectorAll') + .and.returnValue([mockLinkElement]); + + opfResourceLoaderService.clearAllProviderResources(); + + expect(mockLinkElement.remove).toHaveBeenCalled(); + }); + }); + + describe('executeHtml', () => { + it('should execute script from HTML correctly', () => { + opfResourceLoaderService = TestBed.inject(OpfResourceLoaderService); + + const mockScript = document.createElement('script'); + mockScript.innerText = 'console.log("Script executed");'; + spyOn(document, 'createElement').and.returnValue(mockScript); + spyOn(console, 'log'); + + opfResourceLoaderService.executeScriptFromHtml( + '' + ); + + expect(console.log).toHaveBeenCalledWith('Script executed'); + }); + }); + + describe('executeHtml in SSR', () => { + it('should not execute script with SSR when platform is set to server', () => { + TestBed.overrideProvider(PLATFORM_ID, { useValue: 'server' }); + opfResourceLoaderService = TestBed.inject(OpfResourceLoaderService); + + const mockScript = document.createElement('script'); + mockScript.innerText = 'console.log("Script executed");'; + spyOn(document, 'createElement').and.returnValue(mockScript); + spyOn(console, 'log'); + + opfResourceLoaderService.executeScriptFromHtml( + '' + ); + + expect(console.log).not.toHaveBeenCalledWith('Script executed'); + }); + }); +}); diff --git a/integration-libs/opf/base/root/services/opf-resource-loader.service.ts b/integration-libs/opf/base/root/services/opf-resource-loader.service.ts new file mode 100644 index 00000000000..11a4471b87f --- /dev/null +++ b/integration-libs/opf/base/root/services/opf-resource-loader.service.ts @@ -0,0 +1,199 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DOCUMENT, isPlatformServer } from '@angular/common'; +import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { ScriptLoader } from '@spartacus/core'; + +import { + OpfDynamicScriptResource, + OpfDynamicScriptResourceType, +} from '../model'; + +@Injectable({ + providedIn: 'root', +}) +export class OpfResourceLoaderService extends ScriptLoader { + constructor( + @Inject(DOCUMENT) protected document: any, + @Inject(PLATFORM_ID) protected platformId: Object + ) { + super(document, platformId); + } + + protected readonly OPF_RESOURCE_ATTRIBUTE_KEY = 'data-opf-resource'; + + protected loadedResources: OpfDynamicScriptResource[] = []; + + protected embedStyles(embedOptions: { + src: string; + callback?: EventListener; + errorCallback: EventListener; + }): void { + const { src, callback, errorCallback } = embedOptions; + + const link: HTMLLinkElement = this.document.createElement('link'); + link.href = src; + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.setAttribute(this.OPF_RESOURCE_ATTRIBUTE_KEY, 'true'); + + if (callback) { + link.addEventListener('load', callback); + } + + if (errorCallback) { + link.addEventListener('error', errorCallback); + } + + this.document.head.appendChild(link); + } + + protected hasStyles(src?: string): boolean { + return !!this.document.querySelector(`link[href="${src}"]`); + } + + protected hasScript(src?: string): boolean { + return super.hasScript(src); + } + + protected handleLoadingResourceError( + resolve: (value: void | PromiseLike) => void + ) { + resolve(); + } + + protected isResourceLoadingCompleted(resources: OpfDynamicScriptResource[]) { + return resources.length === this.loadedResources.length; + } + + protected markResourceAsLoaded( + resource: OpfDynamicScriptResource, + resources: OpfDynamicScriptResource[], + resolve: (value: void | PromiseLike) => void + ) { + this.loadedResources.push(resource); + if (this.isResourceLoadingCompleted(resources)) { + resolve(); + } + } + + protected loadScript( + resource: OpfDynamicScriptResource, + resources: OpfDynamicScriptResource[], + resolve: (value: void | PromiseLike) => void + ) { + const attributes: any = { + type: 'text/javascript', + [this.OPF_RESOURCE_ATTRIBUTE_KEY]: true, + }; + + if (resource.attributes) { + resource.attributes.forEach((attribute) => { + attributes[attribute.key] = attribute.value; + }); + } + + if (resource.url && !this.hasScript(resource.url)) { + super.embedScript({ + src: resource.url, + attributes: attributes, + callback: () => { + this.markResourceAsLoaded(resource, resources, resolve); + }, + errorCallback: () => { + this.handleLoadingResourceError(resolve); + }, + }); + } else { + this.markResourceAsLoaded(resource, resources, resolve); + } + } + + protected loadStyles( + resource: OpfDynamicScriptResource, + resources: OpfDynamicScriptResource[], + resolve: (value: void | PromiseLike) => void + ) { + if (resource.url && !this.hasStyles(resource.url)) { + this.embedStyles({ + src: resource.url, + callback: () => this.markResourceAsLoaded(resource, resources, resolve), + errorCallback: () => { + this.handleLoadingResourceError(resolve); + }, + }); + } else { + this.markResourceAsLoaded(resource, resources, resolve); + } + } + + executeScriptFromHtml(html: string | undefined) { + // SSR mode not supported for security concerns + if (!isPlatformServer(this.platformId) && html) { + const element = new DOMParser().parseFromString(html, 'text/html'); + const script = element.getElementsByTagName('script'); + if (!script?.[0]?.innerText) { + return; + } + Function(script[0].innerText)(); + } + } + + clearAllProviderResources() { + this.document + .querySelectorAll(`[${this.OPF_RESOURCE_ATTRIBUTE_KEY}]`) + .forEach((resource: undefined | HTMLLinkElement | HTMLScriptElement) => { + if (resource) { + resource.remove(); + } + }); + } + + loadProviderResources( + scripts: OpfDynamicScriptResource[] = [], + styles: OpfDynamicScriptResource[] = [] + ): Promise { + // SSR mode not supported for security concerns + if (isPlatformServer(this.platformId)) { + return Promise.resolve(); + } + + const resources: OpfDynamicScriptResource[] = [ + ...scripts.map((script) => ({ + ...script, + type: OpfDynamicScriptResourceType.SCRIPT, + })), + ...styles.map((style) => ({ + ...style, + type: OpfDynamicScriptResourceType.STYLES, + })), + ]; + if (!resources.length) { + return Promise.resolve(); + } + return new Promise((resolve) => { + this.loadedResources = []; + + resources.forEach((resource: OpfDynamicScriptResource) => { + if (!resource.url) { + this.markResourceAsLoaded(resource, resources, resolve); + } else { + switch (resource.type) { + case OpfDynamicScriptResourceType.SCRIPT: + this.loadScript(resource, resources, resolve); + break; + case OpfDynamicScriptResourceType.STYLES: + this.loadStyles(resource, resources, resolve); + break; + default: + break; + } + } + }); + }); + } +} diff --git a/integration-libs/opf/base/styles/_index.scss b/integration-libs/opf/base/styles/_index.scss new file mode 100644 index 00000000000..192091fb04e --- /dev/null +++ b/integration-libs/opf/base/styles/_index.scss @@ -0,0 +1 @@ +@import './components/index'; diff --git a/integration-libs/opf/base/styles/components/_index.scss b/integration-libs/opf/base/styles/components/_index.scss new file mode 100644 index 00000000000..f79abba97cd --- /dev/null +++ b/integration-libs/opf/base/styles/components/_index.scss @@ -0,0 +1 @@ +@import './opf-error-modal'; diff --git a/integration-libs/opf/base/styles/components/_opf-error-modal.scss b/integration-libs/opf/base/styles/components/_opf-error-modal.scss new file mode 100644 index 00000000000..f8839eae670 --- /dev/null +++ b/integration-libs/opf/base/styles/components/_opf-error-modal.scss @@ -0,0 +1,45 @@ +@import '@spartacus/styles/scss/cxbase/blocks/modal'; + +%cx-opf-error-modal { + .cx-opf-error-modal { + @extend .modal-dialog; + @extend .modal-dialog-centered; + + .cx-opf-error-modal-container { + @extend .modal-content; + + .cx-opf-error-modal-header { + display: flex; + justify-content: space-between; + .cx-opf-error-modal-title { + @include type('3'); + } + } + + .cx-opf-error-modal-body { + @extend .modal-body; + } + + .cx-confirmation { + margin-bottom: 0px; + } + + .cx-opf-error-modal-footer { + display: flex; + + button { + flex: 0 0 100%; + text-transform: lowercase; + + &:first-line { + text-transform: capitalize; + } + + &:focus { + @include visible-focus(); + } + } + } + } + } +} diff --git a/integration-libs/opf/checkout/assets/ng-package.json b/integration-libs/opf/checkout/assets/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/checkout/assets/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/checkout/assets/public_api.ts b/integration-libs/opf/checkout/assets/public_api.ts new file mode 100644 index 00000000000..eafa913ea0c --- /dev/null +++ b/integration-libs/opf/checkout/assets/public_api.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './translations/index'; diff --git a/integration-libs/opf/checkout/assets/translations/en/index.ts b/integration-libs/opf/checkout/assets/translations/en/index.ts new file mode 100644 index 00000000000..948e6e6a811 --- /dev/null +++ b/integration-libs/opf/checkout/assets/translations/en/index.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import opfCheckout from './opfCheckout.json'; + +export const en = { + opfCheckout, +}; diff --git a/integration-libs/opf/checkout/assets/translations/en/opfCheckout.json b/integration-libs/opf/checkout/assets/translations/en/opfCheckout.json new file mode 100644 index 00000000000..8fc49c14289 --- /dev/null +++ b/integration-libs/opf/checkout/assets/translations/en/opfCheckout.json @@ -0,0 +1,22 @@ +{ + "opfCheckout": { + "tabs": { + "shipping": "Shipping", + "deliveryMethod": "Delivery Method", + "paymentAndReview": "Payment & Review" + }, + "paymentAndReviewTitle": "Payment and review", + "billingAddress": "Billing Address", + "paymentOption": "Payment option", + "termsAndConditions": "Terms & Conditions", + "itemsToBeShipped": "Items to be shipped", + "proceedPayment": "Place Order", + "retryPayment": "Retry to Continue", + "checkTermsAndConditionsFirst": "You must agree Terms & Conditions to see available payment options.", + "errors": { + "loadActiveConfigurations": "We are unable to load payment options at this time. Please try again later.", + "noActiveConfigurations": "There are no payment options available at this time. Please try again later or contact support.", + "updateBillingAddress": "The address could not be updated. Please check that the address information is correct and that your device is connected to the internet. If the problem persists, you may need to clear your cart and start the checkout again." + } + } +} diff --git a/integration-libs/opf/checkout/assets/translations/index.ts b/integration-libs/opf/checkout/assets/translations/index.ts new file mode 100644 index 00000000000..659170bb76a --- /dev/null +++ b/integration-libs/opf/checkout/assets/translations/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './translations'; diff --git a/integration-libs/opf/checkout/assets/translations/translations.ts b/integration-libs/opf/checkout/assets/translations/translations.ts new file mode 100644 index 00000000000..64566913c34 --- /dev/null +++ b/integration-libs/opf/checkout/assets/translations/translations.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TranslationChunksConfig, TranslationResources } from '@spartacus/core'; +import { en } from './en/index'; + +export const opfCheckoutTranslations: TranslationResources = { + en, +}; + +export const opfCheckoutTranslationChunksConfig: TranslationChunksConfig = { + opfCheckout: ['opfCheckout'], +}; diff --git a/integration-libs/opf/checkout/components/ng-package.json b/integration-libs/opf/checkout/components/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/checkout/components/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/get-address-card-content.pipe.spec.ts b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/get-address-card-content.pipe.spec.ts new file mode 100644 index 00000000000..3b6d4dd3a41 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/get-address-card-content.pipe.spec.ts @@ -0,0 +1,69 @@ +/* + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { Address } from '@spartacus/core'; +import { GetAddressCardContent } from './get-address-card-content.pipe'; + +describe('GetAddressCardContentPipe', () => { + let pipe: GetAddressCardContent; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [GetAddressCardContent], + }); + + pipe = TestBed.inject(GetAddressCardContent); + }); + + it('should create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should transform address to card content', () => { + const address = { + firstName: 'John', + lastName: 'Doe', + line1: '123 Main St', + line2: 'Apt 4B', + town: 'Cityville', + region: { isocode: 'CA' }, + country: { isocode: 'US' }, + postalCode: '12345', + phone: '555-1234', + }; + + const result = pipe.transform(address); + + expect(result).toEqual({ + textBold: 'John Doe', + text: ['123 Main St', 'Apt 4B', 'Cityville, CA, US', '12345', '555-1234'], + }); + }); + + it('should handle missing address', () => { + const result = pipe.transform(null as unknown as Address); + + expect(result).toEqual({}); + }); + + it('should handle missing region and country', () => { + const address = { + firstName: 'Jane', + lastName: 'Smith', + line1: '456 Elm St', + town: 'Townsville', + postalCode: '67890', + phone: '555-5678', + }; + + const result = pipe.transform(address); + + expect(result).toEqual({ + textBold: 'Jane Smith', + text: ['456 Elm St', undefined, 'Townsville', '67890', '555-5678'], + }); + }); +}); diff --git a/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/get-address-card-content.pipe.ts b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/get-address-card-content.pipe.ts new file mode 100644 index 00000000000..1674061deae --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/get-address-card-content.pipe.ts @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Pipe, PipeTransform } from '@angular/core'; +import { Address } from '@spartacus/core'; +import { Card } from '@spartacus/storefront'; + +@Pipe({ + name: 'cxGetAddressCardContent', +}) +export class GetAddressCardContent implements PipeTransform { + transform(address: Address): Card { + if (!address) { + return {}; + } + + return { + textBold: `${address.firstName} ${address.lastName}`, + text: [ + address.line1, + address.line2, + this.getTownLine(address), + address.postalCode, + address.phone, + ], + } as Card; + } + + protected getTownLine(address: Address): string { + const region = address.region?.isocode || ''; + const town = address.town || ''; + const countryIsocode = address.country?.isocode || ''; + + const townLineParts = []; + + if (town) { + townLineParts.push(town); + } + + if (region) { + if (town) { + townLineParts.push(', '); + } + townLineParts.push(region); + } + + if (countryIsocode) { + if (town || region) { + townLineParts.push(', '); + } + townLineParts.push(countryIsocode); + } + + return townLineParts.join(''); + } +} diff --git a/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.component.html b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.component.html new file mode 100644 index 00000000000..4cce8a8fb21 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.component.html @@ -0,0 +1,62 @@ +

{{ 'opfCheckout.billingAddress' | cxTranslate }}

+ + +
+
+ +
+
+ + +
+ + + +
+
+ + + + +
+
+ + + + diff --git a/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.component.spec.ts b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.component.spec.ts new file mode 100644 index 00000000000..4c454db5d35 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.component.spec.ts @@ -0,0 +1,176 @@ +/* + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Pipe, PipeTransform } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Address, Country } from '@spartacus/core'; +import { BehaviorSubject, EMPTY, Observable, of } from 'rxjs'; +import { OpfCheckoutBillingAddressFormComponent } from './opf-checkout-billing-address-form.component'; +import { OpfCheckoutBillingAddressFormService } from './opf-checkout-billing-address-form.service'; + +class Service { + billingAddress$ = new BehaviorSubject
(undefined); + isLoadingAddress$ = new BehaviorSubject(false); + isSameAsDelivery$ = new BehaviorSubject(true); + + getCountries(): Observable { + return EMPTY; + } + + getAddresses(): void {} + + putDeliveryAddressAsPaymentAddress(): void {} + + setBillingAddress(address: Address): Observable
{ + return of(address); + } + + get billingAddressValue(): Address | undefined { + return this.billingAddress$.value; + } + + get isSameAsDeliveryValue(): boolean { + return this.isSameAsDelivery$.value; + } + + setIsSameAsDeliveryValue(value: boolean): void { + this.isSameAsDelivery$.next(value); + } +} + +@Pipe({ + name: 'cxTranslate', +}) +class MockTranslatePipe implements PipeTransform { + transform(): any {} +} + +describe('OpfCheckoutBillingAddressFormComponent', () => { + let component: OpfCheckoutBillingAddressFormComponent; + let fixture: ComponentFixture; + let service: OpfCheckoutBillingAddressFormService; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [OpfCheckoutBillingAddressFormComponent, MockTranslatePipe], + providers: [ + { + provide: OpfCheckoutBillingAddressFormService, + useClass: Service, + }, + ], + }).compileComponents(); + + service = TestBed.inject(OpfCheckoutBillingAddressFormService); + fixture = TestBed.createComponent(OpfCheckoutBillingAddressFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize countries and addresses on ngOnInit', () => { + const countries = [{ id: '1', name: 'Country 1' }]; + spyOn(service, 'getCountries').and.returnValue(of(countries)); + spyOn(service, 'getAddresses'); + + component.ngOnInit(); + + expect(component.countries$).toBeDefined(); + expect(service.getCountries).toHaveBeenCalled(); + expect(service.getAddresses).toHaveBeenCalled(); + }); + + it('should cancel and hide form on cancelAndHideForm', () => { + const setIsSameAsDeliveryValueSpy = spyOn( + service, + 'setIsSameAsDeliveryValue' + ); + component.isEditBillingAddress = true; + component.isAddingBillingAddressInProgress = true; + + component.cancelAndHideForm(); + + expect(component.isEditBillingAddress).toBe(false); + expect(setIsSameAsDeliveryValueSpy).toHaveBeenCalledWith(true); + expect(component.isAddingBillingAddressInProgress).toBe(false); + }); + + it('should set isEditBillingAddress to true on editCustomBillingAddress', () => { + component.editCustomBillingAddress(); + expect(component.isEditBillingAddress).toBe(true); + }); + + it('should toggle same as delivery address on toggleSameAsDeliveryAddress', () => { + const mockEvent = { target: { checked: true } as unknown } as Event; + const putDeliveryAddressAsPaymentAddressSpy = spyOn( + service, + 'putDeliveryAddressAsPaymentAddress' + ); + const setIsSameAsDeliveryValueSpy = spyOn( + service, + 'setIsSameAsDeliveryValue' + ); + component.isAddingBillingAddressInProgress = true; + + component.toggleSameAsDeliveryAddress(mockEvent); + + expect(setIsSameAsDeliveryValueSpy).toHaveBeenCalledWith(true); + expect(putDeliveryAddressAsPaymentAddressSpy).toHaveBeenCalled(); + expect(component.isEditBillingAddress).toBe(false); + }); + + it('should return billingAddress if valid and not adding on getAddressData', () => { + component.isAddingBillingAddressInProgress = false; + const billingAddress = { id: '1', streetName: '123 Main St' }; + + const result = component.getAddressData(billingAddress); + + expect(result).toEqual(billingAddress); + }); + + it('should reset flags and call setBillingAddress on onSubmitAddress', () => { + spyOn(service, 'setBillingAddress').and.returnValue(of()); + const address = { id: '1', streetName: '456 Elm St' }; + + component.onSubmitAddress(address); + + expect(component.isEditBillingAddress).toBe(false); + expect(component.isAddingBillingAddressInProgress).toBe(false); + expect(service.setBillingAddress).toHaveBeenCalledWith(address); + }); + + it('should not call setBillingAddress if address is falsy on onSubmitAddress', () => { + spyOn(service, 'setBillingAddress'); + const address = null as unknown as Address; + + component.onSubmitAddress(address); + + expect(service.setBillingAddress).not.toHaveBeenCalled(); + }); + + it('should set flags correctly when toggleSameAsDeliveryAddress is called with checked = false', () => { + const mockEvent = { target: { checked: false } as unknown } as Event; + + component.isAddingBillingAddressInProgress = false; + component.isEditBillingAddress = false; + + component.toggleSameAsDeliveryAddress(mockEvent); + + expect(component.isAddingBillingAddressInProgress).toBe(true); + expect(component.isEditBillingAddress).toBe(true); + }); + + it('should return an empty object when billingAddress is falsy and isAddingBillingAddressInProgress is true', () => { + const billingAddress: Address | undefined | null = null; + component.isAddingBillingAddressInProgress = true; + + const result = component.getAddressData(billingAddress); + + expect(result).toEqual({}); + }); +}); diff --git a/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.component.ts b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.component.ts new file mode 100644 index 00000000000..81a17f51430 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.component.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { Address, Country } from '@spartacus/core'; +import { ICON_TYPE } from '@spartacus/storefront'; +import { Observable } from 'rxjs'; +import { OpfCheckoutBillingAddressFormService } from './opf-checkout-billing-address-form.service'; + +@Component({ + selector: 'cx-opf-checkout-billing-address-form', + templateUrl: './opf-checkout-billing-address-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpfCheckoutBillingAddressFormComponent implements OnInit { + iconTypes = ICON_TYPE; + + billingAddress$ = this.service.billingAddress$; + isLoadingAddress$ = this.service.isLoadingAddress$; + isSameAsDelivery$ = this.service.isSameAsDelivery$; + + isEditBillingAddress = false; + isAddingBillingAddressInProgress = false; + + countries$: Observable; + + constructor(protected service: OpfCheckoutBillingAddressFormService) {} + + ngOnInit() { + this.countries$ = this.service.getCountries(); + this.service.getAddresses(); + } + + cancelAndHideForm(): void { + this.isEditBillingAddress = false; + if (this.isAddingBillingAddressInProgress) { + this.service.setIsSameAsDeliveryValue(true); + this.isAddingBillingAddressInProgress = false; + } + } + + editCustomBillingAddress(): void { + this.isEditBillingAddress = true; + } + + toggleSameAsDeliveryAddress(event: Event): void { + const checked = (event.target).checked; + this.service.setIsSameAsDeliveryValue(checked); + if (checked) { + this.service.putDeliveryAddressAsPaymentAddress(); + this.isEditBillingAddress = false; + } else { + this.isAddingBillingAddressInProgress = true; + this.isEditBillingAddress = true; + } + } + + getAddressData(billingAddress: Address | undefined | null): Address { + return !!billingAddress?.id && !this.isAddingBillingAddressInProgress + ? billingAddress + : {}; + } + + onSubmitAddress(address: Address): void { + this.isEditBillingAddress = false; + this.isAddingBillingAddressInProgress = false; + + if (!address) { + return; + } + + this.service.setBillingAddress(address).subscribe(); + } +} diff --git a/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.module.ts b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.module.ts new file mode 100644 index 00000000000..a88e0e9610f --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.module.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { NgSelectModule } from '@ng-select/ng-select'; +import { I18nModule } from '@spartacus/core'; +import { + CardModule, + FormErrorsModule, + IconModule, + NgSelectA11yModule, + SpinnerModule, +} from '@spartacus/storefront'; +import { AddressFormModule } from '@spartacus/user/profile/components'; +import { GetAddressCardContent } from './get-address-card-content.pipe'; +import { OpfCheckoutBillingAddressFormComponent } from './opf-checkout-billing-address-form.component'; +import { OpfCheckoutBillingAddressFormService } from './opf-checkout-billing-address-form.service'; + +@NgModule({ + declarations: [OpfCheckoutBillingAddressFormComponent, GetAddressCardContent], + exports: [OpfCheckoutBillingAddressFormComponent], + imports: [ + NgSelectA11yModule, + CommonModule, + ReactiveFormsModule, + NgSelectModule, + CardModule, + I18nModule, + IconModule, + FormErrorsModule, + SpinnerModule, + AddressFormModule, + ], + providers: [OpfCheckoutBillingAddressFormService], +}) +export class OpfCheckoutBillingAddressFormModule {} diff --git a/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.service.spec.ts b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.service.spec.ts new file mode 100644 index 00000000000..19642c79624 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.service.spec.ts @@ -0,0 +1,254 @@ +/* + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fakeAsync, flush, TestBed } from '@angular/core/testing'; +import { ActiveCartFacade, Cart } from '@spartacus/cart/base/root'; +import { + CheckoutBillingAddressFacade, + CheckoutDeliveryAddressFacade, +} from '@spartacus/checkout/base/root'; +import { + Address, + GlobalMessageService, + HttpErrorModel, + UserPaymentService, +} from '@spartacus/core'; +import { of, throwError } from 'rxjs'; +import { OpfCheckoutPaymentWrapperService } from '../opf-checkout-payment-wrapper'; +import { OpfCheckoutBillingAddressFormService } from './opf-checkout-billing-address-form.service'; + +describe('OpfCheckoutBillingAddressFormService', () => { + let service: OpfCheckoutBillingAddressFormService; + let mockDeliveryAddressFacade: Partial; + let mockBillingAddressFacade: Partial; + let mockUserPaymentService: Partial; + let mockActiveCartFacade: Partial; + let mockGlobalMessageService: Partial; + let mockOpfCheckoutPaymentWrapperService: Partial; + + const mockDeliveryAddress: Address = { + id: '123', + }; + const mockPaymentAddress: Address = { + id: '321', + }; + + beforeEach(() => { + mockDeliveryAddressFacade = { + getDeliveryAddressState: () => + of({ loading: false, data: mockDeliveryAddress, error: false }), + }; + + mockBillingAddressFacade = { + setBillingAddress: (address: Address) => of(address), + }; + + mockUserPaymentService = { + getAllBillingCountries: () => of([]), + loadBillingCountries: () => {}, + }; + + mockActiveCartFacade = { + reloadActiveCart: () => of(true), + isStable: () => of(true), + getActive: () => of({ sapBillingAddress: mockPaymentAddress } as Cart), + }; + + mockGlobalMessageService = { + add: () => {}, + }; + + mockOpfCheckoutPaymentWrapperService = { + reloadPaymentMode: () => {}, + }; + + TestBed.configureTestingModule({ + providers: [ + OpfCheckoutBillingAddressFormService, + { + provide: CheckoutDeliveryAddressFacade, + useValue: mockDeliveryAddressFacade, + }, + { + provide: CheckoutBillingAddressFacade, + useValue: mockBillingAddressFacade, + }, + { provide: UserPaymentService, useValue: mockUserPaymentService }, + { provide: ActiveCartFacade, useValue: mockActiveCartFacade }, + { provide: GlobalMessageService, useValue: mockGlobalMessageService }, + { + provide: OpfCheckoutPaymentWrapperService, + useValue: mockOpfCheckoutPaymentWrapperService, + }, + ], + }); + + service = TestBed.inject(OpfCheckoutBillingAddressFormService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should load countries', () => { + spyOn(mockUserPaymentService, 'loadBillingCountries'); + + service.getCountries().subscribe(() => { + expect(mockUserPaymentService.loadBillingCountries).toHaveBeenCalled(); + }); + }); + + it('should get addresses when only payment address is present', () => { + spyOn(mockBillingAddressFacade, 'setBillingAddress').and.returnValue( + of(true) + ); + spyOn(mockActiveCartFacade, 'isStable').and.returnValue(of(true)); + + service.getAddresses(); + + expect(service['isLoadingAddressSub'].value).toBeFalsy(); + expect(service.billingAddressValue).toEqual(mockPaymentAddress); + expect(service.isSameAsDeliveryValue).toBeFalsy(); + }); + + it('should put delivery address as payment address', () => { + spyOn(mockDeliveryAddressFacade, 'getDeliveryAddressState').and.returnValue( + of({ loading: false, data: mockDeliveryAddress, error: false }) + ); + spyOn(mockBillingAddressFacade, 'setBillingAddress').and.returnValue( + of(true) + ); + + service.putDeliveryAddressAsPaymentAddress(); + + expect(service.isSameAsDeliveryValue).toBeTruthy(); + }); + + it('should put delivery address as payment address and handle error', () => { + spyOn(mockDeliveryAddressFacade, 'getDeliveryAddressState').and.returnValue( + of({ loading: false, data: mockDeliveryAddress, error: false }) + ); + spyOn(mockBillingAddressFacade, 'setBillingAddress').and.returnValue( + throwError({}) + ); + + service.putDeliveryAddressAsPaymentAddress(); + + expect(service.isSameAsDeliveryValue).toBeFalsy(); + }); + + it('should get delivery address', (done) => { + spyOn(mockDeliveryAddressFacade, 'getDeliveryAddressState').and.returnValue( + of({ loading: false, data: mockDeliveryAddress, error: false }) + ); + + service['getDeliveryAddress']().subscribe((result) => { + expect(result).toEqual(mockDeliveryAddress); + done(); + }); + }); + + it('should not get delivery address when loading', fakeAsync(() => { + spyOn(mockDeliveryAddressFacade, 'getDeliveryAddressState').and.returnValue( + of({ loading: true, data: undefined, error: false }) + ); + + let address; + + service['getDeliveryAddress']().subscribe((result) => { + address = result; + flush(); + }); + + expect(address).toBeUndefined(); + })); + + it('should get payment address', () => { + spyOn(mockActiveCartFacade, 'getActive').and.returnValue( + of({ sapBillingAddress: mockPaymentAddress } as Cart) + ); + + service['getPaymentAddress']().subscribe((result) => { + expect(result).toEqual(mockPaymentAddress); + }); + }); + + it('should not get payment address when not present', () => { + spyOn(mockActiveCartFacade, 'getActive').and.returnValue( + of({ sapBillingAddress: undefined } as Cart) + ); + + service['getPaymentAddress']().subscribe((result) => { + expect(result).toBeUndefined(); + }); + }); + + it('should set isSameAsDelivery value', () => { + const newValue = false; + spyOn(service['isSameAsDeliverySub'], 'next'); + + service.setIsSameAsDeliveryValue(newValue); + + expect(service['isSameAsDeliverySub'].next).toHaveBeenCalledWith(newValue); + }); + + it('should not get payment address when it is not present', (done) => { + spyOn(mockActiveCartFacade, 'getActive').and.returnValue( + of({ sapBillingAddress: undefined } as Cart) + ); + + service['getPaymentAddress']().subscribe((result) => { + expect(result).toBeUndefined(); + done(); + }); + }); + + it('should set isSameAsDelivery value to false', () => { + const newValue = false; + spyOn(service['isSameAsDeliverySub'], 'next'); + + service.setIsSameAsDeliveryValue(newValue); + + expect(service['isSameAsDeliverySub'].next).toHaveBeenCalledWith(newValue); + }); + + it('should handle error when setting billing address fails', () => { + const mockError: HttpErrorModel = { + message: 'Error setting billing address', + }; + spyOn(mockBillingAddressFacade, 'setBillingAddress').and.returnValue( + throwError(mockError) + ); + + service.setBillingAddress(mockDeliveryAddress).subscribe({ + error: (error) => { + expect(error).toEqual(mockError); + }, + }); + }); + + it('should set billing address to delivery address if payment address is not available', () => { + spyOn(service as any, 'getDeliveryAddress').and.returnValue( + of(mockDeliveryAddress) + ); + spyOn(service as any, 'getPaymentAddress').and.returnValue(of(undefined)); + spyOn(service, 'setBillingAddress').and.callThrough(); + + service.getAddresses(); + + expect(service.setBillingAddress).toHaveBeenCalledWith(mockDeliveryAddress); + + expect(service['billingAddressSub'].value).toEqual(mockDeliveryAddress); + }); + + it('should return EMPTY when address is undefined', () => { + spyOn(service as any, 'getDeliveryAddress').and.returnValue(of(undefined)); + spyOn(service, 'setBillingAddress').and.callThrough(); + + service.putDeliveryAddressAsPaymentAddress(); + + expect(service.setBillingAddress).not.toHaveBeenCalled(); + }); +}); diff --git a/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.service.ts b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.service.ts new file mode 100644 index 00000000000..b8b655c4bc0 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-billing-address-form/opf-checkout-billing-address-form.service.ts @@ -0,0 +1,183 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { ActiveCartFacade, Cart } from '@spartacus/cart/base/root'; +import { + CheckoutBillingAddressFacade, + CheckoutDeliveryAddressFacade, + CheckoutPaymentFacade, +} from '@spartacus/checkout/base/root'; +import { + Address, + Country, + GlobalMessageService, + GlobalMessageType, + HttpErrorModel, + UserPaymentService, +} from '@spartacus/core'; +import { + BehaviorSubject, + EMPTY, + Observable, + combineLatest, + throwError, +} from 'rxjs'; +import { + catchError, + filter, + finalize, + map, + shareReplay, + switchMap, + take, + tap, +} from 'rxjs/operators'; +import { OpfCheckoutPaymentWrapperService } from '../opf-checkout-payment-wrapper'; + +@Injectable() +export class OpfCheckoutBillingAddressFormService { + protected readonly billingAddressSub = new BehaviorSubject< + Address | undefined + >(undefined); + protected readonly isLoadingAddressSub = new BehaviorSubject(false); + protected readonly isSameAsDeliverySub = new BehaviorSubject(true); + protected billingAddressId: string | undefined; + + billingAddress$ = this.billingAddressSub.asObservable(); + isLoadingAddress$ = this.isLoadingAddressSub.asObservable(); + isSameAsDelivery$ = this.isSameAsDeliverySub.asObservable(); + + constructor( + protected checkoutDeliveryAddressFacade: CheckoutDeliveryAddressFacade, + protected checkoutBillingAddressFacade: CheckoutBillingAddressFacade, + protected userPaymentService: UserPaymentService, + protected checkoutPaymentService: CheckoutPaymentFacade, + protected activeCartService: ActiveCartFacade, + protected globalMessageService: GlobalMessageService, + protected opfCheckoutPaymentWrapperService: OpfCheckoutPaymentWrapperService + ) {} + + getCountries(): Observable { + return this.userPaymentService.getAllBillingCountries().pipe( + tap((countries) => { + if (Object.keys(countries).length === 0) { + this.userPaymentService.loadBillingCountries(); + } + }), + // we want to share data with the address form and prevent loading data twice + shareReplay(1) + ); + } + + getAddresses(): void { + this.isLoadingAddressSub.next(true); + + combineLatest([this.getDeliveryAddress(), this.getPaymentAddress()]) + .pipe(take(1)) + .subscribe( + ([deliveryAddress, paymentAddress]: [ + Address | undefined, + Address | undefined, + ]) => { + if (!paymentAddress && !!deliveryAddress) { + this.setBillingAddress(deliveryAddress); + this.billingAddressSub.next(deliveryAddress); + } + + if (!!paymentAddress && !!deliveryAddress) { + this.billingAddressId = paymentAddress.id; + this.billingAddressSub.next(paymentAddress); + this.isSameAsDeliverySub.next(false); + } + + this.isLoadingAddressSub.next(false); + } + ); + } + + putDeliveryAddressAsPaymentAddress(): void { + this.getDeliveryAddress() + .pipe( + switchMap((address: Address | undefined) => + !!address ? this.setBillingAddress(address) : EMPTY + ), + take(1) + ) + .subscribe({ + next: () => this.setIsSameAsDeliveryValue(true), + complete: () => {}, + error: () => this.setIsSameAsDeliveryValue(false), + // Method is responsible for placing delivery address as a payment address, + // so if was not successful, we know for sure that checkbox 'Same as delivery' should be unchecked + }); + } + + setBillingAddress(address: Address): Observable
{ + this.isLoadingAddressSub.next(true); + + return this.checkoutBillingAddressFacade + .setBillingAddress(this.getAddressWithId(address)) + .pipe( + switchMap(() => { + this.activeCartService.reloadActiveCart(); + + return this.activeCartService.isStable(); + }), + filter((isStable: boolean) => isStable), + switchMap(() => this.getPaymentAddress()), + + tap((billingAddress: Address | undefined) => { + if (!!billingAddress && !!billingAddress.id) { + this.billingAddressId = billingAddress.id; + + this.billingAddressSub.next(billingAddress); + this.opfCheckoutPaymentWrapperService.reloadPaymentMode(); + } + }), + catchError((error: HttpErrorModel) => { + this.globalMessageService.add( + { key: 'opfCheckout.errors.updateBillingAddress' }, + GlobalMessageType.MSG_TYPE_ERROR + ); + return throwError(error); + }), + finalize(() => { + this.isLoadingAddressSub.next(false); + }), + take(1) + ); + } + + get billingAddressValue(): Address | undefined { + return this.billingAddressSub.value; + } + + get isSameAsDeliveryValue(): boolean { + return this.isSameAsDeliverySub.value; + } + + setIsSameAsDeliveryValue(value: boolean): void { + this.isSameAsDeliverySub.next(value); + } + + protected getDeliveryAddress(): Observable
{ + return this.checkoutDeliveryAddressFacade.getDeliveryAddressState().pipe( + filter((state) => !state.loading), + map((state) => state.data) + ); + } + + protected getPaymentAddress(): Observable
{ + return this.activeCartService + .getActive() + .pipe(map((cart: Cart) => cart.sapBillingAddress)); + } + + protected getAddressWithId(address: Address): Address { + return { ...address, id: this.billingAddressId }; + } +} diff --git a/integration-libs/opf/checkout/components/opf-checkout-components.module.ts b/integration-libs/opf/checkout/components/opf-checkout-components.module.ts new file mode 100644 index 00000000000..475cf588568 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-components.module.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfCheckoutBillingAddressFormModule } from './opf-checkout-billing-address-form/opf-checkout-billing-address-form.module'; +import { OpfCheckoutPaymentAndReviewModule } from './opf-checkout-payment-and-review/opf-checkout-payment-and-review.module'; +import { OpfCheckoutPaymentWrapperModule } from './opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.module'; +import { OpfCheckoutPaymentsModule } from './opf-checkout-payments/opf-checkout-payments.module'; + +@NgModule({ + imports: [ + OpfCheckoutPaymentAndReviewModule, + OpfCheckoutPaymentsModule, + OpfCheckoutBillingAddressFormModule, + OpfCheckoutPaymentWrapperModule, + ], +}) +export class OpfCheckoutComponentsModule {} diff --git a/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/index.ts b/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/index.ts new file mode 100644 index 00000000000..12724a4d5fb --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-checkout-payment-and-review.component'; +export * from './opf-checkout-payment-and-review.module'; diff --git a/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/opf-checkout-payment-and-review.component.html b/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/opf-checkout-payment-and-review.component.html new file mode 100644 index 00000000000..a50550dcb32 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/opf-checkout-payment-and-review.component.html @@ -0,0 +1,126 @@ + + +
+

{{ 'opfCheckout.termsAndConditions' | cxTranslate }}

+ +
+
+ +
+
+
+ + + + + + + + + +
+ {{ + 'cartItems.cartTotal' + | cxTranslate: { count: cart.deliveryItemsQuantity } + }}: + {{ cart.totalPrice?.formattedValue }} +
+
+ + +
+ {{ 'opfCheckout.itemsToBeShipped' | cxTranslate }} +
+ +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + + +
+
+
diff --git a/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/opf-checkout-payment-and-review.component.spec.ts b/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/opf-checkout-payment-and-review.component.spec.ts new file mode 100644 index 00000000000..15c1f60016b --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/opf-checkout-payment-and-review.component.spec.ts @@ -0,0 +1,185 @@ +// TODO: Add unit tests + +// import { CommonModule } from '@angular/common'; +// import { +// Component, +// DebugElement, +// Directive, +// Input, +// Pipe, +// PipeTransform, +// } from '@angular/core'; +// import { ComponentFixture, TestBed } from '@angular/core/testing'; +// import { ReactiveFormsModule } from '@angular/forms'; +// import { By } from '@angular/platform-browser'; +// import { RouterTestingModule } from '@angular/router/testing'; +// import { ActiveCartService } from '@spartacus/cart/base/core'; +// import { ActiveCartFacade, Cart, OrderEntry } from '@spartacus/cart/base/root'; +// import { CheckoutStepService } from '@spartacus/checkout/base/components'; +// import { CheckoutStep, CheckoutStepType } from '@spartacus/checkout/base/root'; +// import { I18nTestingModule } from '@spartacus/core'; +// import { +// FormErrorsModule, +// IconTestingModule, +// OutletDirective, +// PromotionsModule, +// } from '@spartacus/storefront'; +// import { BehaviorSubject, Observable, of } from 'rxjs'; + +// import { OpfCheckoutPaymentAndReviewComponent } from './opf-checkout-payment-and-review.component'; + +// const mockCart = { +// code: 'test', +// paymentType: { +// code: 'PAYMENT_GATEWAY', +// }, +// }; +// const mockEntries: OrderEntry[] = [{ entryNumber: 123 }, { entryNumber: 456 }]; + +// const cart$ = new BehaviorSubject({}); +// class MockActiveCartService implements Partial { +// getActive(): Observable { +// return cart$.asObservable(); +// } +// getEntries(): Observable { +// return of(mockEntries); +// } +// } + +// @Pipe({ +// name: 'cxUrl', +// }) +// class MockUrlPipe implements PipeTransform { +// transform() {} +// } + +// @Component({ +// template: '', +// selector: 'cx-opf-checkout-payments', +// }) +// class MockOpfCheckoutPaymentsComponent { +// @Input() +// disabled = false; +// } + +// @Component({ +// template: '', +// selector: 'cx-opf-checkout-billing-address-form', +// }) +// class MockOpfCheckoutBillingAddressFormComponent {} + +// @Directive({ +// selector: '[cxOutlet]', +// }) +// class MockOutletDirective implements Partial { +// @Input() cxOutlet: string; +// @Input() cxOutletContext: string; +// } + +// const mockCheckoutStep: CheckoutStep = { +// id: 'step', +// name: 'name', +// routeName: '/route', +// type: [CheckoutStepType.DELIVERY_ADDRESS], +// }; +// class MockCheckoutStepService { +// steps$ = of([ +// { +// id: 'step1', +// name: 'step1', +// routeName: 'route1', +// type: [CheckoutStepType.PAYMENT_TYPE], +// }, +// { +// id: 'step2', +// name: 'step2', +// routeName: 'route2', +// type: [CheckoutStepType.REVIEW_ORDER], +// }, +// ]); +// getCheckoutStep(): CheckoutStep { +// return mockCheckoutStep; +// } +// } + +// describe('OPFCheckoutPaymentReviewComponent', () => { +// let component: OpfCheckoutPaymentAndReviewComponent; +// let fixture: ComponentFixture; +// let el: DebugElement; +// let activeCartService: ActiveCartFacade; + +// beforeEach(async () => { +// await TestBed.configureTestingModule({ +// imports: [ +// CommonModule, +// ReactiveFormsModule, +// I18nTestingModule, +// FormErrorsModule, +// RouterTestingModule, +// PromotionsModule, +// IconTestingModule, +// ], +// declarations: [ +// OpfCheckoutPaymentAndReviewComponent, +// MockOpfCheckoutPaymentsComponent, +// MockUrlPipe, +// MockOpfCheckoutBillingAddressFormComponent, +// MockOutletDirective, +// ], +// providers: [ +// { provide: ActiveCartFacade, useClass: MockActiveCartService }, +// { +// provide: CheckoutStepService, +// useClass: MockCheckoutStepService, +// }, +// ], +// }).compileComponents(); + +// fixture = TestBed.createComponent(OpfCheckoutPaymentAndReviewComponent); +// el = fixture.debugElement; +// activeCartService = TestBed.inject(ActiveCartFacade); + +// component = fixture.componentInstance; +// spyOn(activeCartService, 'getActive').and.returnValue(cart$); + +// fixture.detectChanges(); +// }); + +// it('should create', () => { +// expect(component).toBeTruthy(); +// }); + +// it('should call for active cart to get payment type', () => { +// expect(activeCartService.getActive).toHaveBeenCalled(); +// }); + +// it('should render cx-opf-checkout-payments component if payment type is not set to ACCOUNT', () => { +// cart$.next(mockCart); +// fixture.detectChanges(); + +// expect(el.query(By.css('cx-opf-checkout-payments'))).toBeTruthy(); +// }); + +// it('should not render cx-opf-checkout-payments component if payment type is set to ACCOUNT', () => { +// cart$.next({ ...mockCart, paymentType: { code: 'ACCOUNT' } }); + +// fixture.detectChanges(); + +// expect(el.query(By.css('cx-opf-checkout-payments'))).toBeFalsy(); +// }); + +// it('should change form value when checkbox get selected / change state', () => { +// cart$.next(mockCart); + +// fixture.detectChanges(); + +// expect(component.termsAndConditionInvalid).toEqual(true); + +// const inputEl = fixture.debugElement.query(By.css('input')).nativeElement; + +// inputEl.click(); + +// expect(inputEl.checked).toEqual(true); +// expect(component.termsAndConditionInvalid).toEqual(false); +// }); +// }); diff --git a/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/opf-checkout-payment-and-review.component.ts b/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/opf-checkout-payment-and-review.component.ts new file mode 100644 index 00000000000..b92fb09da60 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/opf-checkout-payment-and-review.component.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { + UntypedFormBuilder, + UntypedFormGroup, + Validators, +} from '@angular/forms'; +import { ActiveCartFacade, Cart, PaymentType } from '@spartacus/cart/base/root'; +import { + CheckoutReviewSubmitComponent, + CheckoutStepService, +} from '@spartacus/checkout/base/components'; +import { + CheckoutDeliveryAddressFacade, + CheckoutDeliveryModesFacade, + CheckoutPaymentFacade, +} from '@spartacus/checkout/base/root'; +import { TranslationService } from '@spartacus/core'; +import { OpfMetadataStoreService } from '@spartacus/opf/base/root'; + +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Component({ + selector: 'cx-opf-checkout-payment-and-review', + templateUrl: './opf-checkout-payment-and-review.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpfCheckoutPaymentAndReviewComponent + extends CheckoutReviewSubmitComponent + implements OnInit +{ + protected defaultTermsAndConditionsFieldValue = false; + + checkoutSubmitForm: UntypedFormGroup = this.fb.group({ + termsAndConditions: [ + this.defaultTermsAndConditionsFieldValue, + Validators.requiredTrue, + ], + }); + + get termsAndConditionInvalid(): boolean { + return this.checkoutSubmitForm.invalid; + } + + get termsAndConditionsFieldValue(): boolean { + return Boolean(this.checkoutSubmitForm.get('termsAndConditions')?.value); + } + + get paymentType$(): Observable { + return this.activeCartFacade + .getActive() + .pipe(map((cart: Cart) => cart.paymentType)); + } + + constructor( + protected fb: UntypedFormBuilder, + protected checkoutDeliveryAddressFacade: CheckoutDeliveryAddressFacade, + protected checkoutPaymentFacade: CheckoutPaymentFacade, + protected activeCartFacade: ActiveCartFacade, + protected translationService: TranslationService, + protected checkoutStepService: CheckoutStepService, + protected checkoutDeliveryModesFacade: CheckoutDeliveryModesFacade, + protected opfMetadataStoreService: OpfMetadataStoreService + ) { + super( + checkoutDeliveryAddressFacade, + checkoutPaymentFacade, + activeCartFacade, + translationService, + checkoutStepService, + checkoutDeliveryModesFacade + ); + } + + protected updateTermsAndConditionsState() { + this.opfMetadataStoreService.updateOpfMetadata({ + termsAndConditionsChecked: this.termsAndConditionsFieldValue, + }); + } + + toggleTermsAndConditions() { + this.updateTermsAndConditionsState(); + } + + ngOnInit() { + this.updateTermsAndConditionsState(); + } +} diff --git a/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/opf-checkout-payment-and-review.module.ts b/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/opf-checkout-payment-and-review.module.ts new file mode 100644 index 00000000000..cb0a1f84ba5 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payment-and-review/opf-checkout-payment-and-review.module.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; +import { + CartNotEmptyGuard, + CheckoutAuthGuard, +} from '@spartacus/checkout/base/components'; +import { + CmsConfig, + I18nModule, + UrlModule, + provideDefaultConfig, +} from '@spartacus/core'; +import { + CardModule, + IconModule, + OutletModule, + PromotionsModule, +} from '@spartacus/storefront'; + +import { AddressFormModule } from '@spartacus/user/profile/components'; +import { OpfCheckoutBillingAddressFormModule } from '../opf-checkout-billing-address-form/opf-checkout-billing-address-form.module'; +import { OpfCheckoutPaymentsModule } from '../opf-checkout-payments/opf-checkout-payments.module'; +import { OpfCheckoutTermsAndConditionsAlertModule } from '../opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.module'; +import { OpfCheckoutPaymentAndReviewComponent } from './opf-checkout-payment-and-review.component'; + +@NgModule({ + declarations: [OpfCheckoutPaymentAndReviewComponent], + imports: [ + CommonModule, + I18nModule, + OpfCheckoutPaymentsModule, + UrlModule, + ReactiveFormsModule, + RouterModule, + OpfCheckoutBillingAddressFormModule, + AddressFormModule, + OutletModule, + PromotionsModule, + IconModule, + CardModule, + OpfCheckoutTermsAndConditionsAlertModule, + ], + + providers: [ + provideDefaultConfig({ + cmsComponents: { + OpfCheckoutPaymentAndReview: { + component: OpfCheckoutPaymentAndReviewComponent, + guards: [CheckoutAuthGuard, CartNotEmptyGuard], + }, + }, + }), + ], +}) +export class OpfCheckoutPaymentAndReviewModule {} diff --git a/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/index.ts b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/index.ts new file mode 100644 index 00000000000..c0c3a6934bf --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/index.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-checkout-payment-wrapper.component'; +export * from './opf-checkout-payment-wrapper.module'; +export * from './opf-checkout-payment-wrapper.service'; diff --git a/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.component.html b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.component.html new file mode 100644 index 00000000000..64dac0d1c44 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.component.html @@ -0,0 +1,83 @@ +
+ + +
+ + +
+ + + + +
+
+ +
+ +
+
+ +
+
+
+
+ + + +
+ +
+
+ + + + +
diff --git a/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.component.spec.ts b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.component.spec.ts new file mode 100644 index 00000000000..a9f423a8e79 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.component.spec.ts @@ -0,0 +1,142 @@ +import { ViewContainerRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DomSanitizer } from '@angular/platform-browser'; +import { OpfGlobalFunctionsService } from '@spartacus/opf/global-functions/core'; +import { + GlobalFunctionsDomain, + GlobalFunctionsInput, + OpfGlobalFunctionsFacade, +} from '@spartacus/opf/global-functions/root'; +import { PaymentPattern } from '@spartacus/opf/payment/root'; +import { of } from 'rxjs'; +import { OpfCheckoutPaymentWrapperComponent } from './opf-checkout-payment-wrapper.component'; +import { OpfCheckoutPaymentWrapperService } from './opf-checkout-payment-wrapper.service'; +describe('OpfCheckoutPaymentWrapperComponent', () => { + let component: OpfCheckoutPaymentWrapperComponent; + let fixture: ComponentFixture; + let mockService: jasmine.SpyObj; + let mockGlobalFunctionsService: jasmine.SpyObj; + let domSanitizer: DomSanitizer; + + beforeEach(() => { + mockService = jasmine.createSpyObj('OpfCheckoutPaymentWrapperService', [ + 'getRenderPaymentMethodEvent', + 'initiatePayment', + 'reloadPaymentMode', + ]); + + mockGlobalFunctionsService = jasmine.createSpyObj( + 'OpfGlobalFunctionsFacade', + ['registerGlobalFunctions', 'removeGlobalFunctions'] + ); + + TestBed.configureTestingModule({ + declarations: [OpfCheckoutPaymentWrapperComponent], + providers: [ + { provide: OpfCheckoutPaymentWrapperService, useValue: mockService }, + { + provide: OpfGlobalFunctionsFacade, + useValue: mockGlobalFunctionsService, + }, + { + provide: ViewContainerRef, + useValue: {}, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(OpfCheckoutPaymentWrapperComponent); + component = fixture.componentInstance; + domSanitizer = TestBed.inject(DomSanitizer); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should renderHtml call bypassSecurityTrustHtml', () => { + const html = ''; + spyOn(domSanitizer, 'bypassSecurityTrustHtml').and.stub(); + component.renderHtml(html); + + expect(domSanitizer.bypassSecurityTrustHtml).toHaveBeenCalledWith(html); + }); + + it('should renderUrl call bypassSecurityTrustResourceUrl', () => { + const url = 'https://sap.com'; + spyOn(domSanitizer, 'bypassSecurityTrustResourceUrl').and.stub(); + component.renderUrl(url); + + expect(domSanitizer.bypassSecurityTrustResourceUrl).toHaveBeenCalledWith( + url + ); + }); + + it('should call initiatePayment on ngOnInit', () => { + const mockPaymentSessionData = { + paymentSessionId: 'session123', + pattern: PaymentPattern.HOSTED_FIELDS, + }; + + mockService.initiatePayment.and.returnValue(of(mockPaymentSessionData)); + + component.selectedPaymentId = 123; + component.ngOnInit(); + + const globalFunctionsInput: GlobalFunctionsInput = { + domain: GlobalFunctionsDomain.CHECKOUT, + paymentSessionId: mockPaymentSessionData.paymentSessionId, + }; + + expect(mockService.initiatePayment).toHaveBeenCalledWith(123); + expect( + mockGlobalFunctionsService.registerGlobalFunctions + ).toHaveBeenCalledWith(jasmine.objectContaining(globalFunctionsInput)); + }); + + it('should call removeGlobalFunctions if paymentSessionData is not HOSTED_FIELDS', () => { + const mockPaymentSessionData = { + paymentSessionId: 'session123', + pattern: PaymentPattern.FULL_PAGE, + }; + + mockService.initiatePayment.and.returnValue(of(mockPaymentSessionData)); + + component.selectedPaymentId = 123; + component.ngOnInit(); + + expect(mockGlobalFunctionsService.removeGlobalFunctions).toHaveBeenCalled(); + }); + + it('should call reloadPaymentMode on retryInitiatePayment', () => { + component.retryInitiatePayment(); + + expect(mockService.reloadPaymentMode).toHaveBeenCalled(); + }); + + it('should return true if paymentSessionData is HOSTED_FIELDS', () => { + const mockPaymentSessionData = { + paymentSessionId: 'session123', + pattern: 'HOSTED_FIELDS', + }; + + const result = (component as any)?.isHostedFields(mockPaymentSessionData); + + expect(result).toBeTruthy(); + }); + + it('should return false if paymentSessionData is not HOSTED_FIELDS', () => { + const mockPaymentSessionData = { + paymentSessionId: 'session123', + pattern: 'NON_HOSTED_FIELDS', + }; + + const result = (component as any)?.isHostedFields(mockPaymentSessionData); + + expect(result).toBeFalsy(); + }); +}); diff --git a/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.component.ts b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.component.ts new file mode 100644 index 00000000000..083fdc3bdca --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.component.ts @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ChangeDetectionStrategy, + Component, + Input, + OnDestroy, + OnInit, + ViewContainerRef, +} from '@angular/core'; +import { + DomSanitizer, + SafeHtml, + SafeResourceUrl, +} from '@angular/platform-browser'; +import { + GlobalFunctionsDomain, + OpfGlobalFunctionsFacade, +} from '@spartacus/opf/global-functions/root'; +import { + PaymentPattern, + PaymentSessionData, +} from '@spartacus/opf/payment/root'; +import { Subscription } from 'rxjs'; +import { OpfCheckoutPaymentWrapperService } from './opf-checkout-payment-wrapper.service'; + +@Component({ + selector: 'cx-opf-checkout-payment-wrapper', + templateUrl: './opf-checkout-payment-wrapper.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpfCheckoutPaymentWrapperComponent implements OnInit, OnDestroy { + @Input() selectedPaymentId: number; + + renderPaymentMethodEvent$ = this.service.getRenderPaymentMethodEvent(); + + RENDER_PATTERN = PaymentPattern; + + sub: Subscription = new Subscription(); + + constructor( + protected service: OpfCheckoutPaymentWrapperService, + protected sanitizer: DomSanitizer, + protected globalFunctionsService: OpfGlobalFunctionsFacade, + protected vcr: ViewContainerRef + ) {} + + renderHtml(html: string): SafeHtml { + return this.sanitizer.bypassSecurityTrustHtml(html); + } + + renderUrl(url: string): SafeResourceUrl { + return this.sanitizer.bypassSecurityTrustResourceUrl(url); + } + + ngOnInit() { + this.initiatePaymentMode(); + } + + ngOnDestroy() { + this.globalFunctionsService.removeGlobalFunctions( + GlobalFunctionsDomain.CHECKOUT + ); + this.sub.unsubscribe(); + } + + retryInitiatePayment(): void { + this.service.reloadPaymentMode(); + } + + protected initiatePaymentMode(): void { + this.sub.add( + this.service.initiatePayment(this.selectedPaymentId).subscribe({ + next: (paymentSessionData) => { + if (this.isHostedFields(paymentSessionData)) { + this.globalFunctionsService.registerGlobalFunctions({ + domain: GlobalFunctionsDomain.CHECKOUT, + paymentSessionId: (paymentSessionData as PaymentSessionData) + .paymentSessionId as string, + vcr: this.vcr, + }); + } else { + this.globalFunctionsService.removeGlobalFunctions( + GlobalFunctionsDomain.CHECKOUT + ); + } + }, + }) + ); + } + + protected isHostedFields( + paymentSessionData: PaymentSessionData | Error + ): boolean { + return !!( + !(paymentSessionData instanceof Error) && + paymentSessionData?.paymentSessionId && + paymentSessionData?.pattern === PaymentPattern.HOSTED_FIELDS + ); + } +} diff --git a/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.module.ts b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.module.ts new file mode 100644 index 00000000000..136a4764880 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.module.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { I18nModule } from '@spartacus/core'; +import { SpinnerModule } from '@spartacus/storefront'; +import { OpfCheckoutPaymentWrapperComponent } from './opf-checkout-payment-wrapper.component'; +import { OpfCheckoutPaymentWrapperService } from './opf-checkout-payment-wrapper.service'; + +@NgModule({ + declarations: [OpfCheckoutPaymentWrapperComponent], + providers: [OpfCheckoutPaymentWrapperService], + exports: [OpfCheckoutPaymentWrapperComponent], + imports: [CommonModule, I18nModule, SpinnerModule], +}) +export class OpfCheckoutPaymentWrapperModule {} diff --git a/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.service.spec.ts b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.service.spec.ts new file mode 100644 index 00000000000..19cfb370941 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.service.spec.ts @@ -0,0 +1,476 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { + ActiveCartFacade, + CartAccessCodeFacade, +} from '@spartacus/cart/base/root'; +import { + GlobalMessageService, + RouterState, + RoutingService, + UserIdService, +} from '@spartacus/core'; +import { + OpfDynamicScriptResourceType, + OpfMetadataStoreService, + OpfResourceLoaderService, +} from '@spartacus/opf/base/root'; +import { OrderFacade } from '@spartacus/order/root'; +import { of, throwError } from 'rxjs'; + +import { OPF_PAYMENT_AND_REVIEW_SEMANTIC_ROUTE } from '@spartacus/opf/checkout/root'; +import { + OpfPaymentFacade, + PaymentPattern, + PaymentSessionData, +} from '@spartacus/opf/payment/root'; +import { OpfCheckoutPaymentWrapperService } from './opf-checkout-payment-wrapper.service'; + +const mockUrl = 'https://sap.com'; + +describe('OpfCheckoutPaymentWrapperService', () => { + let service: OpfCheckoutPaymentWrapperService; + let opfPaymentFacadeMock: jasmine.SpyObj; + let cartAccessCodeFacadeMock: jasmine.SpyObj; + let opfResourceLoaderServiceMock: jasmine.SpyObj; + let userIdServiceMock: jasmine.SpyObj; + let activeCartServiceMock: jasmine.SpyObj; + let routingServiceMock: jasmine.SpyObj; + let globalMessageServiceMock: jasmine.SpyObj; + let orderFacadeMock: jasmine.SpyObj; + let opfMetadataStoreServiceMock: jasmine.SpyObj; + + beforeEach(() => { + opfPaymentFacadeMock = jasmine.createSpyObj('OpfPaymentFacade', [ + 'initiatePayment', + ]); + cartAccessCodeFacadeMock = jasmine.createSpyObj('CartAccessCodeFacade', [ + 'getCartAccessCode', + ]); + opfResourceLoaderServiceMock = jasmine.createSpyObj( + 'OpfResourceLoaderService', + [ + 'executeScriptFromHtml', + 'clearAllProviderResources', + 'loadProviderResources', + ] + ); + userIdServiceMock = jasmine.createSpyObj('UserIdService', ['getUserId']); + activeCartServiceMock = jasmine.createSpyObj('ActiveCartFacade', [ + 'getActiveCartId', + ]); + routingServiceMock = jasmine.createSpyObj('RoutingService', [ + 'getRouterState', + 'go', + 'getFullUrl', + ]); + globalMessageServiceMock = jasmine.createSpyObj('GlobalMessageService', [ + 'add', + ]); + orderFacadeMock = jasmine.createSpyObj('OrderFacade', [ + 'placePaymentAuthorizedOrder', + ]); + opfMetadataStoreServiceMock = jasmine.createSpyObj( + 'OpfMetadataStoreService', + ['updateOpfMetadata'] + ); + + routingServiceMock.getRouterState.and.returnValue( + of({ + state: { + semanticRoute: 'checkoutReviewOrder', + }, + } as RouterState) + ); + + TestBed.configureTestingModule({ + providers: [ + OpfCheckoutPaymentWrapperService, + { provide: OpfPaymentFacade, useValue: opfPaymentFacadeMock }, + { provide: CartAccessCodeFacade, useValue: cartAccessCodeFacadeMock }, + { + provide: OpfResourceLoaderService, + useValue: opfResourceLoaderServiceMock, + }, + { provide: UserIdService, useValue: userIdServiceMock }, + { provide: ActiveCartFacade, useValue: activeCartServiceMock }, + { provide: RoutingService, useValue: routingServiceMock }, + { provide: GlobalMessageService, useValue: globalMessageServiceMock }, + { provide: OrderFacade, useValue: orderFacadeMock }, + { + provide: OpfMetadataStoreService, + useValue: opfMetadataStoreServiceMock, + }, + ], + }); + + service = TestBed.inject(OpfCheckoutPaymentWrapperService); + }); + + it('should retrieve renderPaymentMethodEvent$', (done) => { + const mockRenderPaymentMethodEvent = { isLoading: false, isError: false }; + service['renderPaymentMethodEvent$'].next(mockRenderPaymentMethodEvent); + + service.getRenderPaymentMethodEvent().subscribe((event) => { + expect(event).toEqual(mockRenderPaymentMethodEvent); + done(); + }); + }); + + it('should initiate payment successfully and render payment gateway', (done) => { + const mockPaymentOptionId = 123; + const mockOtpKey = 'otpKey'; + const mockUserId = 'userId'; + const mockCartId = 'cartId'; + const mockPaymentSessionData: PaymentSessionData = { + pattern: PaymentPattern.HOSTED_FIELDS, + dynamicScript: { + html: '', + jsUrls: [ + { + url: 'script.js', + type: OpfDynamicScriptResourceType.SCRIPT, + }, + ], + cssUrls: [ + { + url: 'styles.css', + type: OpfDynamicScriptResourceType.STYLES, + }, + ], + }, + }; + + opfPaymentFacadeMock.initiatePayment.and.returnValue( + of(mockPaymentSessionData) + ); + cartAccessCodeFacadeMock.getCartAccessCode.and.returnValue( + of({ accessCode: mockOtpKey }) + ); + userIdServiceMock.getUserId.and.returnValue(of(mockUserId)); + activeCartServiceMock.getActiveCartId.and.returnValue(of(mockCartId)); + routingServiceMock.getRouterState.and.returnValue( + of({ state: { semanticRoute: 'checkoutReviewOrder' } } as RouterState) + ); + routingServiceMock.getFullUrl.and.returnValue(mockUrl); + opfMetadataStoreServiceMock.updateOpfMetadata.and.stub(); + opfResourceLoaderServiceMock.loadProviderResources.and.returnValue( + Promise.resolve() + ); + spyOn(service, 'renderPaymentGateway').and.callThrough(); + spyOn(service, 'storePaymentSessionId'); + + service.initiatePayment(mockPaymentOptionId).subscribe(() => { + expect(opfPaymentFacadeMock.initiatePayment).toHaveBeenCalledWith({ + otpKey: mockOtpKey, + config: { + configurationId: mockPaymentOptionId.toString(), + cartId: mockCartId, + resultURL: mockUrl, + cancelURL: mockUrl, + }, + }); + + expect( + opfResourceLoaderServiceMock.loadProviderResources + ).toHaveBeenCalledWith( + [ + { + url: 'script.js', + type: OpfDynamicScriptResourceType.SCRIPT, + }, + ], + [ + { + url: 'styles.css', + type: OpfDynamicScriptResourceType.STYLES, + }, + ] + ); + + expect(service.renderPaymentGateway).toHaveBeenCalledWith({ + pattern: PaymentPattern.HOSTED_FIELDS, + dynamicScript: { + html: '', + jsUrls: [ + { + url: 'script.js', + type: OpfDynamicScriptResourceType.SCRIPT, + }, + ], + cssUrls: [ + { + url: 'styles.css', + type: OpfDynamicScriptResourceType.STYLES, + }, + ], + }, + }); + + expect((service as any).storePaymentSessionId).toHaveBeenCalled(); + + done(); + }); + }); + + it('should handle when payment initiation fails with 409 error', (done) => { + const mockPaymentOptionId = 123; + const mockOtpKey = 'otpKey'; + const mockUserId = 'userId'; + const mockCartId = 'cartId'; + + opfPaymentFacadeMock.initiatePayment.and.returnValue( + throwError({ status: 409 }) + ); + + orderFacadeMock.placePaymentAuthorizedOrder.and.returnValue(of({})); + cartAccessCodeFacadeMock.getCartAccessCode.and.returnValue( + of({ accessCode: mockOtpKey }) + ); + userIdServiceMock.getUserId.and.returnValue(of(mockUserId)); + activeCartServiceMock.getActiveCartId.and.returnValue(of(mockCartId)); + routingServiceMock.getRouterState.and.returnValue( + of({ state: { semanticRoute: 'checkoutReviewOrder' } } as RouterState) + ); + routingServiceMock.getFullUrl.and.returnValue(mockUrl); + opfMetadataStoreServiceMock.updateOpfMetadata.and.stub(); + opfResourceLoaderServiceMock.loadProviderResources.and.returnValue( + Promise.resolve() + ); + spyOn(service, 'renderPaymentGateway').and.callThrough(); + + service.initiatePayment(mockPaymentOptionId).subscribe( + () => {}, + (error) => { + expect(error).toBe('Payment already done'); + done(); + } + ); + }); + + it('should handle when payment initiation fails with 500 error', (done) => { + const mockPaymentOptionId = 123; + const mockOtpKey = 'otpKey'; + const mockUserId = 'userId'; + const mockCartId = 'cartId'; + + opfPaymentFacadeMock.initiatePayment.and.returnValue( + throwError({ status: 500 }) + ); + + cartAccessCodeFacadeMock.getCartAccessCode.and.returnValue( + of({ accessCode: mockOtpKey }) + ); + userIdServiceMock.getUserId.and.returnValue(of(mockUserId)); + activeCartServiceMock.getActiveCartId.and.returnValue(of(mockCartId)); + routingServiceMock.getRouterState.and.returnValue( + of({ state: { semanticRoute: 'checkoutReviewOrder' } } as RouterState) + ); + routingServiceMock.getFullUrl.and.returnValue(mockUrl); + opfMetadataStoreServiceMock.updateOpfMetadata.and.stub(); + opfResourceLoaderServiceMock.loadProviderResources.and.returnValue( + Promise.resolve() + ); + spyOn(service, 'renderPaymentGateway').and.callThrough(); + + service.initiatePayment(mockPaymentOptionId).subscribe( + () => {}, + (error) => { + expect(error).toBe('Payment failed'); + expect(globalMessageServiceMock.add).toHaveBeenCalled(); + done(); + } + ); + }); + + it('should reload payment mode', () => { + const mockPaymentOptionId = 123; + spyOn(service, 'initiatePayment').and.callThrough(); + userIdServiceMock.getUserId.and.returnValue(of()); + activeCartServiceMock.getActiveCartId.and.returnValue(of()); + service['lastPaymentOptionId'] = mockPaymentOptionId; + + service.reloadPaymentMode(); + + expect(service.initiatePayment).toHaveBeenCalledWith(mockPaymentOptionId); + }); + + it('should render payment gateway with destination URL', () => { + const mockPaymentSessionData: PaymentSessionData = { + pattern: PaymentPattern.FULL_PAGE, + destination: { url: mockUrl, form: [] }, + }; + + service['renderPaymentGateway'](mockPaymentSessionData); + + expect(service['renderPaymentMethodEvent$'].value).toEqual({ + isLoading: false, + isError: false, + renderType: PaymentPattern.FULL_PAGE, + data: mockUrl, + destination: { url: mockUrl, form: [] }, + }); + }); + + it('should handle paymentSessionId', () => { + const mockPaymentSessionId = 'mockPaymentSessionId'; + const mockPaymentSessionData: PaymentSessionData = { + pattern: PaymentPattern.FULL_PAGE, + paymentSessionId: mockPaymentSessionId, + }; + (service as any).storePaymentSessionId(mockPaymentSessionData); + expect(opfMetadataStoreServiceMock.updateOpfMetadata).toHaveBeenCalledWith({ + paymentSessionId: mockPaymentSessionId, + }); + + mockPaymentSessionData.pattern = PaymentPattern.HOSTED_FIELDS; + (service as any).storePaymentSessionId(mockPaymentSessionData); + expect(opfMetadataStoreServiceMock.updateOpfMetadata).toHaveBeenCalledWith({ + paymentSessionId: undefined, + }); + }); + + it('should render payment gateway with a hidden form and submit button', () => { + const mockFormData = [ + { + name: 'test_key', + value: 'test_value', + }, + { + name: 'test_key_2', + value: 'test_value_2', + }, + ]; + + const mockPaymentSessionData: PaymentSessionData = { + pattern: PaymentPattern.IFRAME, + destination: { + url: mockUrl, + form: mockFormData, + }, + }; + + service['renderPaymentGateway'](mockPaymentSessionData); + + expect(service['renderPaymentMethodEvent$'].value).toEqual({ + isLoading: false, + isError: false, + renderType: PaymentPattern.IFRAME, + data: mockUrl, + destination: { url: mockUrl, form: mockFormData }, + }); + }); + + it('should render payment gateway with dynamic script', (done) => { + const mockPaymentSessionData: PaymentSessionData = { + pattern: PaymentPattern.HOSTED_FIELDS, + dynamicScript: { + html: '', + jsUrls: [ + { + url: 'script.js', + type: OpfDynamicScriptResourceType.SCRIPT, + }, + ], + cssUrls: [ + { + url: 'styles.css', + type: OpfDynamicScriptResourceType.STYLES, + }, + ], + }, + }; + + opfResourceLoaderServiceMock.loadProviderResources.and.returnValue( + Promise.resolve() + ); + + service['renderPaymentGateway'](mockPaymentSessionData); + + expect( + opfResourceLoaderServiceMock.loadProviderResources + ).toHaveBeenCalledWith( + [ + { + url: 'script.js', + type: OpfDynamicScriptResourceType.SCRIPT, + }, + ], + [ + { + url: 'styles.css', + type: OpfDynamicScriptResourceType.STYLES, + }, + ] + ); + + setTimeout(() => { + expect(service['renderPaymentMethodEvent$'].value).toEqual({ + isLoading: false, + isError: false, + renderType: PaymentPattern.HOSTED_FIELDS, + data: '', + }); + done(); + }); + }); + + it('should handle place order success', () => { + service['onPlaceOrderSuccess'](); + expect(service['routingService'].go).toHaveBeenCalledWith({ + cxRoute: 'orderConfirmation', + }); + }); + + it('should set payment initiation config', () => { + const mockOtpKey = 'otpKey'; + const mockPaymentOptionId = 123; + const mockActiveCartId = 'cartId'; + service['activeCartId'] = mockActiveCartId; + routingServiceMock.getFullUrl.and.returnValue(mockUrl); + + activeCartServiceMock.getActiveCartId.and.returnValue(of(mockActiveCartId)); + + const config = service['setPaymentInitiationConfig']( + mockOtpKey, + mockPaymentOptionId + ); + + expect(config).toEqual({ + otpKey: mockOtpKey, + config: { + configurationId: mockPaymentOptionId.toString(), + cartId: mockActiveCartId, + resultURL: mockUrl, + cancelURL: mockUrl, + }, + }); + }); + + it('should execute script from HTML', fakeAsync(() => { + const mockHtml = ''; + + routingServiceMock.getRouterState.and.returnValue( + of({ + state: { semanticRoute: OPF_PAYMENT_AND_REVIEW_SEMANTIC_ROUTE }, + } as RouterState) + ); + opfResourceLoaderServiceMock.executeScriptFromHtml.and.stub(); + + service['executeScriptFromHtml'](mockHtml); + + expect(routingServiceMock.getRouterState).toHaveBeenCalled(); + + tick(500); + expect( + opfResourceLoaderServiceMock.executeScriptFromHtml + ).toHaveBeenCalledWith(mockHtml); + })); + + it('should call the necessary methods on an error', () => { + service['onPlaceOrderError'](); + + expect(service['routingService'].go).toHaveBeenCalledWith({ + cxRoute: OPF_PAYMENT_AND_REVIEW_SEMANTIC_ROUTE, + }); + }); +}); diff --git a/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.service.ts b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.service.ts new file mode 100644 index 00000000000..b3a16a79ba1 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.service.ts @@ -0,0 +1,273 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { + ActiveCartFacade, + CartAccessCodeFacade, +} from '@spartacus/cart/base/root'; +import { + DEFAULT_AUTHORIZATION_ERROR_RETRIES_COUNT, + GlobalMessageService, + GlobalMessageType, + HttpErrorModel, + HttpResponseStatus, + RoutingService, + UserIdService, + backOff, + isAuthorizationError, +} from '@spartacus/core'; + +import { + OpfMetadataStoreService, + OpfResourceLoaderService, +} from '@spartacus/opf/base/root'; +import { OPF_PAYMENT_AND_REVIEW_SEMANTIC_ROUTE } from '@spartacus/opf/checkout/root'; +import { + OpfPaymentFacade, + OpfRenderPaymentMethodEvent, + PaymentPattern, + PaymentSessionData, +} from '@spartacus/opf/payment/root'; +import { OrderFacade } from '@spartacus/order/root'; +import { + BehaviorSubject, + Observable, + combineLatest, + of, + throwError, +} from 'rxjs'; +import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators'; + +@Injectable() +export class OpfCheckoutPaymentWrapperService { + protected lastPaymentOptionId?: number; + + protected activeCartId?: string; + + protected renderPaymentMethodEvent$ = + new BehaviorSubject({ + isLoading: false, + isError: false, + }); + + constructor( + protected opfPaymentFacade: OpfPaymentFacade, + protected opfResourceLoaderService: OpfResourceLoaderService, + protected userIdService: UserIdService, + protected activeCartService: ActiveCartFacade, + protected routingService: RoutingService, + protected globalMessageService: GlobalMessageService, + protected orderFacade: OrderFacade, + protected opfMetadataStoreService: OpfMetadataStoreService, + protected cartAccessCodeFacade: CartAccessCodeFacade + ) {} + + protected executeScriptFromHtml(html: string): void { + /** + * Verify first if customer is still on the payment and review page. + * Then execute script extracted from HTML to render payment provider gateway. + */ + this.routingService + .getRouterState() + .pipe( + take(1), + filter( + (route) => + route.state.semanticRoute === OPF_PAYMENT_AND_REVIEW_SEMANTIC_ROUTE + ) + ) + .subscribe(() => { + setTimeout(() => { + this.opfResourceLoaderService.executeScriptFromHtml(html); + }); + }); + } + + getRenderPaymentMethodEvent(): Observable { + return this.renderPaymentMethodEvent$.asObservable(); + } + + initiatePayment( + paymentOptionId: number + ): Observable { + this.lastPaymentOptionId = paymentOptionId; + this.renderPaymentMethodEvent$.next({ + isLoading: true, + isError: false, + }); + this.opfResourceLoaderService.clearAllProviderResources(); + + return combineLatest([ + this.userIdService.getUserId(), + this.activeCartService.getActiveCartId(), + ]).pipe( + tap(() => + this.opfMetadataStoreService.updateOpfMetadata({ + isPaymentInProgress: true, + }) + ), + switchMap(([userId, cartId]: [string, string]) => { + this.activeCartId = cartId; + return this.cartAccessCodeFacade.getCartAccessCode(userId, cartId); + }), + filter((response) => Boolean(response?.accessCode)), + map(({ accessCode: otpKey }) => + this.setPaymentInitiationConfig(otpKey, paymentOptionId) + ), + switchMap((params) => this.opfPaymentFacade.initiatePayment(params)), + tap((paymentOptionConfig: PaymentSessionData | Error) => { + if (!(paymentOptionConfig instanceof Error)) { + this.storePaymentSessionId(paymentOptionConfig); + this.renderPaymentGateway(paymentOptionConfig); + } + }), + catchError((err) => this.handlePaymentInitiationError(err)), + backOff({ + /** + * We should retry this sequence only if the error is an authorization error. + * It means that `accessCode` (OTP signature) is not valid or expired and we need to refresh it. + */ + shouldRetry: isAuthorizationError, + maxTries: DEFAULT_AUTHORIZATION_ERROR_RETRIES_COUNT, + }), + take(1) + ); + } + + protected storePaymentSessionId(paymentOptionConfig: PaymentSessionData) { + const paymentSessionId = + paymentOptionConfig.pattern === PaymentPattern.FULL_PAGE && + paymentOptionConfig.paymentSessionId + ? paymentOptionConfig.paymentSessionId + : undefined; + this.opfMetadataStoreService.updateOpfMetadata({ paymentSessionId }); + } + + reloadPaymentMode(): void { + if (this.lastPaymentOptionId) { + this.initiatePayment(this.lastPaymentOptionId).subscribe(); + } + } + + renderPaymentGateway(config: PaymentSessionData) { + if (config?.destination) { + this.renderPaymentMethodEvent$.next({ + isLoading: false, + isError: false, + renderType: config?.pattern, + data: config?.destination.url, + destination: config?.destination, + }); + } + + if ( + config?.dynamicScript && + config?.pattern === PaymentPattern.HOSTED_FIELDS + ) { + const html = config?.dynamicScript?.html; + + this.opfResourceLoaderService + .loadProviderResources( + config.dynamicScript.jsUrls, + config.dynamicScript.cssUrls + ) + .then(() => { + this.renderPaymentMethodEvent$.next({ + isLoading: false, + isError: false, + renderType: config?.pattern, + data: html, + }); + + if (html) { + this.executeScriptFromHtml(html); + } + }); + } + } + + protected handlePaymentInitiationError( + err: HttpErrorModel + ): Observable { + if (isAuthorizationError(err)) { + return throwError(() => err); + } + + return Number(err.status) === HttpResponseStatus.CONFLICT + ? this.handlePaymentAlreadyDoneError() + : this.handleGeneralPaymentError(); + } + + protected handlePaymentAlreadyDoneError(): Observable { + return this.orderFacade.placePaymentAuthorizedOrder(true).pipe( + catchError(() => { + this.onPlaceOrderError(); + + // If place order will fail after two attempts, we wan't to stop stream and show error message + return of(); + }), + switchMap(() => { + this.onPlaceOrderSuccess(); + + return throwError('Payment already done'); + }) + ); + } + + protected onPlaceOrderSuccess(): void { + this.routingService.go({ cxRoute: 'orderConfirmation' }); + } + + protected onPlaceOrderError(): void { + this.renderPaymentMethodEvent$.next({ + ...this.renderPaymentMethodEvent$.value, + isError: true, + }); + + this.showErrorMessage('opfCheckout.errors.unknown'); + this.routingService.go({ cxRoute: OPF_PAYMENT_AND_REVIEW_SEMANTIC_ROUTE }); + } + + protected handleGeneralPaymentError(): Observable { + this.renderPaymentMethodEvent$.next({ + ...this.renderPaymentMethodEvent$.value, + isError: true, + }); + + this.showErrorMessage('opfPayment.errors.proceedPayment'); + + return throwError('Payment failed'); + } + + protected showErrorMessage(errorMessage: string): void { + this.globalMessageService.add( + { + key: errorMessage, + }, + GlobalMessageType.MSG_TYPE_ERROR + ); + } + + protected setPaymentInitiationConfig( + otpKey: string, + paymentOptionId: number + ) { + return { + otpKey, + config: { + configurationId: String(paymentOptionId), + cartId: this.activeCartId, + resultURL: this.routingService.getFullUrl({ + cxRoute: 'paymentVerificationResult', + }), + cancelURL: this.routingService.getFullUrl({ + cxRoute: 'paymentVerificationCancel', + }), + }, + }; + } +} diff --git a/integration-libs/opf/checkout/components/opf-checkout-payments/index.ts b/integration-libs/opf/checkout/components/opf-checkout-payments/index.ts new file mode 100644 index 00000000000..0058ca9e235 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payments/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-checkout-payments.component'; +export * from './opf-checkout-payments.module'; diff --git a/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.component.html b/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.component.html new file mode 100644 index 00000000000..df1531b650c --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.component.html @@ -0,0 +1,54 @@ +

+ {{ 'opfCheckout.paymentOption' | cxTranslate }} +

+ +
+ +
+ + + +
+
+
+ + + + diff --git a/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.component.spec.ts b/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.component.spec.ts new file mode 100644 index 00000000000..aa429986119 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.component.spec.ts @@ -0,0 +1,210 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + GlobalMessageEntities, + GlobalMessageService, + GlobalMessageType, + I18nTestingModule, + QueryState, + Translatable, +} from '@spartacus/core'; +import { + ActiveConfiguration, + OpfBaseFacade, + OpfMetadataModel, + OpfMetadataStoreService, + OpfPaymentProviderType, +} from '@spartacus/opf/base/root'; + +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { OpfCheckoutTermsAndConditionsAlertModule } from '../opf-checkout-terms-and-conditions-alert'; +import { OpfCheckoutPaymentsComponent } from './opf-checkout-payments.component'; + +const mockActiveConfigurations: ActiveConfiguration[] = [ + { + id: 1, + providerType: OpfPaymentProviderType.PAYMENT_GATEWAY, + displayName: 'Test1', + }, + { + id: 2, + providerType: OpfPaymentProviderType.PAYMENT_GATEWAY, + displayName: 'Test2', + logoUrl: 'logoUrl', + }, + { + id: 3, + providerType: OpfPaymentProviderType.PAYMENT_METHOD, + displayName: 'Test3', + }, +]; +class MockOpfBaseFacade implements Partial { + getActiveConfigurationsState(): Observable< + QueryState + > { + return activeConfigurationsState$.asObservable(); + } +} + +const activeConfigurationsState$ = new BehaviorSubject< + QueryState +>({ + loading: false, + error: false, + data: [], +}); + +class MockGlobalMessageService implements Partial { + get(): Observable { + return of({}); + } + add(_: string | Translatable, __: GlobalMessageType, ___?: number): void {} + remove(_: GlobalMessageType, __?: number): void {} +} + +const mockOpfMetadata: OpfMetadataModel = { + isPaymentInProgress: true, + selectedPaymentOptionId: 111, + termsAndConditionsChecked: true, + defaultSelectedPaymentOptionId: 1, + paymentSessionId: '111111', +}; + +describe('OpfCheckoutPaymentsComponent', () => { + let component: OpfCheckoutPaymentsComponent; + let fixture: ComponentFixture; + let globalMessageService: GlobalMessageService; + let opfMetadataStoreServiceMock: jasmine.SpyObj; + let el: DebugElement; + + beforeEach(async () => { + opfMetadataStoreServiceMock = jasmine.createSpyObj( + 'OpfMetadataStoreService', + ['getOpfMetadataState', 'updateOpfMetadata'] + ); + + opfMetadataStoreServiceMock.getOpfMetadataState.and.returnValue( + of(mockOpfMetadata) + ); + await TestBed.configureTestingModule({ + imports: [I18nTestingModule, OpfCheckoutTermsAndConditionsAlertModule], + declarations: [OpfCheckoutPaymentsComponent], + providers: [ + { + provide: OpfBaseFacade, + useClass: MockOpfBaseFacade, + }, + { provide: GlobalMessageService, useClass: MockGlobalMessageService }, + { + provide: OpfMetadataStoreService, + useValue: opfMetadataStoreServiceMock, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(OpfCheckoutPaymentsComponent); + component = fixture.componentInstance; + el = fixture.debugElement; + }); + beforeEach(() => { + globalMessageService = TestBed.inject(GlobalMessageService); + spyOn(globalMessageService, 'add').and.callThrough(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should preselect the payment options', () => { + fixture.detectChanges(); + expect(component.selectedPaymentId).toBe( + mockOpfMetadata.selectedPaymentOptionId + ); + }); + + it('should change active payment option', () => { + component.changePayment(mockActiveConfigurations[2]); + expect(opfMetadataStoreServiceMock.updateOpfMetadata).toHaveBeenCalledWith({ + selectedPaymentOptionId: component.selectedPaymentId, + }); + }); + + it('should display an error message if active configurations are not available', () => { + activeConfigurationsState$.next({ + loading: false, + error: false, + data: [], + }); + + fixture.detectChanges(); + + expect(globalMessageService.add).toHaveBeenCalledWith( + { key: 'opfCheckout.errors.noActiveConfigurations' }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }); + + it('should display an error message if getting Active Configurations State fails', () => { + activeConfigurationsState$.next({ + error: new Error('Request failed'), + loading: false, + data: undefined, + }); + + fixture.detectChanges(); + + expect(globalMessageService.add).toHaveBeenCalledWith( + { key: 'opfCheckout.errors.loadActiveConfigurations' }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }); + + it('should preselect the default payment option', () => { + const defaultSelectedPaymentOptionId = 1; + + opfMetadataStoreServiceMock.getOpfMetadataState.and.returnValue( + of({ + isPaymentInProgress: false, + selectedPaymentOptionId: undefined, + termsAndConditionsChecked: true, + defaultSelectedPaymentOptionId, + paymentSessionId: '111111', + }) + ); + + fixture.detectChanges(); + + expect(component.selectedPaymentId).toBe(defaultSelectedPaymentOptionId); + }); + + it('should render payment provider logo', () => { + activeConfigurationsState$.next({ + loading: false, + error: false, + data: mockActiveConfigurations, + }); + + fixture.detectChanges(); + + mockActiveConfigurations.forEach((configuration) => { + const logoElement = el.query( + By.css( + 'label[for=paymentId-' + configuration.id + '] .cx-payment-logo' + ) + ); + + if (configuration?.logoUrl) { + expect(logoElement).toBeTruthy(); + expect(logoElement.nativeElement.attributes['alt'].value).toBe( + configuration.displayName + ); + expect(logoElement.nativeElement.attributes['src'].value).toBe( + configuration.logoUrl + ); + } else { + expect(logoElement).toBeFalsy(); + } + }); + }); +}); diff --git a/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.component.ts b/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.component.ts new file mode 100644 index 00000000000..6bec985f315 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.component.ts @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ChangeDetectionStrategy, + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { + GlobalMessageService, + GlobalMessageType, + QueryState, +} from '@spartacus/core'; +import { + ActiveConfiguration, + OpfBaseFacade, + OpfMetadataModel, + OpfMetadataStoreService, +} from '@spartacus/opf/base/root'; +import { Subscription } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +@Component({ + selector: 'cx-opf-checkout-payments', + templateUrl: './opf-checkout-payments.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpfCheckoutPaymentsComponent implements OnInit, OnDestroy { + protected subscription = new Subscription(); + + activeConfigurations$ = this.opfBaseService + .getActiveConfigurationsState() + .pipe( + tap((state: QueryState) => { + if (state.error) { + this.displayError('loadActiveConfigurations'); + } else if (!state.loading && !Boolean(state.data?.length)) { + this.displayError('noActiveConfigurations'); + } + + if (state.data && !state.error && !state.loading) { + this.opfMetadataStoreService.updateOpfMetadata({ + defaultSelectedPaymentOptionId: state?.data[0]?.id, + }); + } + }) + ); + + @Input() + disabled = true; + + selectedPaymentId?: number; + + constructor( + protected opfBaseService: OpfBaseFacade, + protected opfMetadataStoreService: OpfMetadataStoreService, + protected globalMessageService: GlobalMessageService + ) {} + + /** + * Method pre-selects (based on terms and conditions state) + * previously selected payment option ID by customer. + */ + protected preselectPaymentOption(): void { + let isPreselected = false; + this.subscription.add( + this.opfMetadataStoreService + .getOpfMetadataState() + .subscribe((state: OpfMetadataModel) => { + if (state.termsAndConditionsChecked && !isPreselected) { + isPreselected = true; + this.selectedPaymentId = !state.selectedPaymentOptionId + ? state.defaultSelectedPaymentOptionId + : state.selectedPaymentOptionId; + this.opfMetadataStoreService.updateOpfMetadata({ + selectedPaymentOptionId: this.selectedPaymentId, + }); + } else if (!state.termsAndConditionsChecked) { + isPreselected = false; + this.selectedPaymentId = undefined; + } + }) + ); + } + + protected displayError(errorKey: string): void { + this.globalMessageService.add( + { key: `opfCheckout.errors.${errorKey}` }, + GlobalMessageType.MSG_TYPE_ERROR + ); + } + + changePayment(payment: ActiveConfiguration): void { + this.selectedPaymentId = payment.id; + this.opfMetadataStoreService.updateOpfMetadata({ + selectedPaymentOptionId: this.selectedPaymentId, + }); + } + + ngOnInit(): void { + this.preselectPaymentOption(); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} diff --git a/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.module.ts b/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.module.ts new file mode 100644 index 00000000000..0b11a0f9841 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.module.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { I18nModule } from '@spartacus/core'; +import { SpinnerModule } from '@spartacus/storefront'; +import { OpfCheckoutPaymentWrapperModule } from '../opf-checkout-payment-wrapper'; +import { OpfCheckoutTermsAndConditionsAlertModule } from '../opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.module'; +import { OpfCheckoutPaymentsComponent } from './opf-checkout-payments.component'; + +@NgModule({ + declarations: [OpfCheckoutPaymentsComponent], + exports: [OpfCheckoutPaymentsComponent], + imports: [ + CommonModule, + I18nModule, + SpinnerModule, + OpfCheckoutPaymentWrapperModule, + OpfCheckoutTermsAndConditionsAlertModule, + ], +}) +export class OpfCheckoutPaymentsModule {} diff --git a/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/index.ts b/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/index.ts new file mode 100644 index 00000000000..0353619ae09 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-checkout-terms-and-conditions-alert.component'; +export * from './opf-checkout-terms-and-conditions-alert.module'; diff --git a/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.component.html b/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.component.html new file mode 100644 index 00000000000..dd2eb965871 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.component.html @@ -0,0 +1,13 @@ +
+
+ + + + + {{ 'opfCheckout.checkTermsAndConditionsFirst' | cxTranslate }} + +
+
diff --git a/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.component.spec.ts b/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.component.spec.ts new file mode 100644 index 00000000000..29bf2e86180 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.component.spec.ts @@ -0,0 +1,71 @@ +import { Component, Input, Pipe, PipeTransform } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OpfCheckoutTermsAndConditionsAlertComponent } from './opf-checkout-terms-and-conditions-alert.component'; + +@Component({ + selector: 'cx-icon', + template: '', +}) +class MockIconComponent { + @Input() type: string; +} + +@Pipe({ + name: 'cxTranslate', +}) +class MockTranslatePipe implements PipeTransform { + transform(): any {} +} + +const alertSelector = '.cx-opf-checkout-terms-and-conditions-alert'; + +describe('OpfCheckoutTermsAndConditionsAlertComponent', () => { + let fixture: ComponentFixture; + let component: OpfCheckoutTermsAndConditionsAlertComponent; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ + OpfCheckoutTermsAndConditionsAlertComponent, + MockIconComponent, + MockTranslatePipe, + ], + }).compileComponents(); + + fixture = TestBed.createComponent( + OpfCheckoutTermsAndConditionsAlertComponent + ); + component = fixture.componentInstance; + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should render component if isVisible is set to true', () => { + component.isVisible = true; + fixture.detectChanges(); + const alertElement = fixture.nativeElement.querySelector(alertSelector); + + expect(alertElement).toBeTruthy(); + }); + + it('should not render component if isVisible is set to false', () => { + component.isVisible = false; + fixture.detectChanges(); + const alertElement = fixture.nativeElement.querySelector(alertSelector); + + expect(alertElement).toBeNull(); + }); + + it('should set isVisible to false, if close method is called', () => { + component.isVisible = true; + fixture.detectChanges(); + const alertElement = fixture.nativeElement.querySelector(alertSelector); + + expect(alertElement).toBeTruthy(); + + component.close(); + expect(component.isVisible).toBeFalsy(); + }); +}); diff --git a/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.component.ts b/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.component.ts new file mode 100644 index 00000000000..5b8b0c77109 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.component.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { ICON_TYPE } from '@spartacus/storefront'; + +@Component({ + selector: 'cx-opf-checkout-terms-and-conditions-alert', + templateUrl: './opf-checkout-terms-and-conditions-alert.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpfCheckoutTermsAndConditionsAlertComponent { + iconTypes = ICON_TYPE; + + @Input() isVisible: boolean; + + close() { + this.isVisible = false; + } +} diff --git a/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.module.ts b/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.module.ts new file mode 100644 index 00000000000..8dd28791630 --- /dev/null +++ b/integration-libs/opf/checkout/components/opf-checkout-terms-and-conditions-alert/opf-checkout-terms-and-conditions-alert.module.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { I18nModule } from '@spartacus/core'; +import { IconModule } from '@spartacus/storefront'; +import { OpfCheckoutTermsAndConditionsAlertComponent } from './opf-checkout-terms-and-conditions-alert.component'; + +@NgModule({ + declarations: [OpfCheckoutTermsAndConditionsAlertComponent], + exports: [OpfCheckoutTermsAndConditionsAlertComponent], + imports: [CommonModule, I18nModule, IconModule], +}) +export class OpfCheckoutTermsAndConditionsAlertModule {} diff --git a/integration-libs/opf/checkout/components/public_api.ts b/integration-libs/opf/checkout/components/public_api.ts new file mode 100644 index 00000000000..9119685c67d --- /dev/null +++ b/integration-libs/opf/checkout/components/public_api.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-checkout-components.module'; +export * from './opf-checkout-payment-and-review/index'; +export * from './opf-checkout-payments/index'; +export * from './opf-checkout-terms-and-conditions-alert/index'; diff --git a/integration-libs/opf/checkout/ng-package.json b/integration-libs/opf/checkout/ng-package.json new file mode 100644 index 00000000000..38e01ac17de --- /dev/null +++ b/integration-libs/opf/checkout/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/checkout/opf-checkout.module.ts b/integration-libs/opf/checkout/opf-checkout.module.ts new file mode 100644 index 00000000000..0364ca9a60a --- /dev/null +++ b/integration-libs/opf/checkout/opf-checkout.module.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfCheckoutComponentsModule } from '@spartacus/opf/checkout/components'; + +@NgModule({ + imports: [OpfCheckoutComponentsModule], +}) +export class OpfCheckoutModule {} diff --git a/integration-libs/opf/checkout/public_api.ts b/integration-libs/opf/checkout/public_api.ts new file mode 100644 index 00000000000..c7fd5e7a4bb --- /dev/null +++ b/integration-libs/opf/checkout/public_api.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-checkout.module'; diff --git a/integration-libs/opf/checkout/root/config/default-opf-checkout-b2b-config.ts b/integration-libs/opf/checkout/root/config/default-opf-checkout-b2b-config.ts new file mode 100644 index 00000000000..65f41b5c73b --- /dev/null +++ b/integration-libs/opf/checkout/root/config/default-opf-checkout-b2b-config.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@spartacus/checkout/b2b/root'; +import { + CheckoutConfig, + CheckoutStepType, +} from '@spartacus/checkout/base/root'; + +export const defaultOpfCheckoutB2bConfig: CheckoutConfig = { + checkout: { + steps: [ + { + id: 'paymentType', + name: 'checkoutB2B.progress.methodOfPayment', + routeName: 'checkoutPaymentType', + type: [CheckoutStepType.PAYMENT_TYPE], + }, + { + id: 'deliveryAddress', + name: 'opfCheckout.tabs.shipping', + routeName: 'checkoutDeliveryAddress', + type: [CheckoutStepType.DELIVERY_ADDRESS], + }, + { + id: 'deliveryMode', + name: 'opfCheckout.tabs.deliveryMethod', + routeName: 'checkoutDeliveryMode', + type: [CheckoutStepType.DELIVERY_MODE], + }, + { + id: 'reviewOrder', + name: 'opfCheckout.tabs.paymentAndReview', + routeName: 'checkoutReviewOrder', + // TODO OPF: provide proper step type (PAYMENT_REVIEW) once augmenting problem is solved + type: [CheckoutStepType.REVIEW_ORDER], + }, + ], + }, +}; diff --git a/integration-libs/opf/checkout/root/config/default-opf-checkout-config.ts b/integration-libs/opf/checkout/root/config/default-opf-checkout-config.ts new file mode 100644 index 00000000000..9332f5e2cbe --- /dev/null +++ b/integration-libs/opf/checkout/root/config/default-opf-checkout-config.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CheckoutConfig, + CheckoutStepType, +} from '@spartacus/checkout/base/root'; + +const opfCheckoutSteps = [ + { + id: 'deliveryAddress', + name: 'opfCheckout.tabs.shipping', + routeName: 'checkoutDeliveryAddress', + type: [CheckoutStepType.DELIVERY_ADDRESS], + nameMultiLine: false, + }, + { + id: 'deliveryMode', + name: 'opfCheckout.tabs.deliveryMethod', + routeName: 'checkoutDeliveryMode', + type: [CheckoutStepType.DELIVERY_MODE], + nameMultiLine: false, + }, + { + id: 'opfReviewOrder', + name: 'opfCheckout.tabs.paymentAndReview', + routeName: 'opfCheckoutPaymentAndReview', + // TODO OPF: provide proper step type (PAYMENT_REVIEW) once augmenting problem is solved + type: [CheckoutStepType.PAYMENT_TYPE], + nameMultiLine: false, + }, +]; + +export const defaultOpfCheckoutConfig: CheckoutConfig = { + checkout: { + flows: { + OPF: { + steps: opfCheckoutSteps, + guest: false, + }, + 'OPF-guest': { + steps: opfCheckoutSteps, + guest: true, + }, + }, + }, +}; diff --git a/integration-libs/opf/checkout/root/config/default-opf-checkout-routing-config.ts b/integration-libs/opf/checkout/root/config/default-opf-checkout-routing-config.ts new file mode 100644 index 00000000000..ce99b8eb4cd --- /dev/null +++ b/integration-libs/opf/checkout/root/config/default-opf-checkout-routing-config.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RoutingConfig } from '@spartacus/core'; + +export const defaultOpfCheckoutRoutingConfig: RoutingConfig = { + routing: { + routes: { + opfCheckoutPaymentAndReview: { + paths: ['checkout/opf-payment-and-review'], + }, + }, + }, +}; diff --git a/integration-libs/opf/checkout/root/config/index.ts b/integration-libs/opf/checkout/root/config/index.ts new file mode 100644 index 00000000000..7696a88316b --- /dev/null +++ b/integration-libs/opf/checkout/root/config/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './default-opf-checkout-b2b-config'; +export * from './default-opf-checkout-config'; diff --git a/integration-libs/opf/checkout/root/feature-name.ts b/integration-libs/opf/checkout/root/feature-name.ts new file mode 100644 index 00000000000..f91082ca920 --- /dev/null +++ b/integration-libs/opf/checkout/root/feature-name.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export const OPF_CHECKOUT_FEATURE = 'opfCheckout'; diff --git a/integration-libs/opf/checkout/root/model/index.ts b/integration-libs/opf/checkout/root/model/index.ts new file mode 100644 index 00000000000..023524f1a6d --- /dev/null +++ b/integration-libs/opf/checkout/root/model/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-checkout.model'; diff --git a/integration-libs/opf/checkout/root/model/opf-checkout.model.ts b/integration-libs/opf/checkout/root/model/opf-checkout.model.ts new file mode 100644 index 00000000000..bae48544c1d --- /dev/null +++ b/integration-libs/opf/checkout/root/model/opf-checkout.model.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export const OPF_PAYMENT_AND_REVIEW_SEMANTIC_ROUTE = + 'opfCheckoutPaymentAndReview'; diff --git a/integration-libs/opf/checkout/root/ng-package.json b/integration-libs/opf/checkout/root/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/checkout/root/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/checkout/root/opf-checkout-root.module.ts b/integration-libs/opf/checkout/root/opf-checkout-root.module.ts new file mode 100644 index 00000000000..a20fdb59c95 --- /dev/null +++ b/integration-libs/opf/checkout/root/opf-checkout-root.module.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { + CmsConfig, + provideDefaultConfig, + provideDefaultConfigFactory, +} from '@spartacus/core'; +import { defaultOpfCheckoutConfig } from './config/default-opf-checkout-config'; +import { defaultOpfCheckoutRoutingConfig } from './config/default-opf-checkout-routing-config'; +import { OPF_CHECKOUT_FEATURE } from './feature-name'; + +export const CHECKOUT_OPF_CMS_COMPONENTS: string[] = [ + 'OpfCheckoutPaymentAndReview', +]; + +export function defaultOpfCheckoutComponentsConfig() { + const config: CmsConfig = { + featureModules: { + [OPF_CHECKOUT_FEATURE]: { + cmsComponents: CHECKOUT_OPF_CMS_COMPONENTS, + }, + }, + }; + return config; +} + +@NgModule({ + providers: [ + provideDefaultConfig(defaultOpfCheckoutRoutingConfig), + provideDefaultConfig(defaultOpfCheckoutConfig), + provideDefaultConfigFactory(defaultOpfCheckoutComponentsConfig), + ], +}) +export class OpfCheckoutRootModule {} diff --git a/integration-libs/opf/checkout/root/public_api.ts b/integration-libs/opf/checkout/root/public_api.ts new file mode 100644 index 00000000000..070b2573dba --- /dev/null +++ b/integration-libs/opf/checkout/root/public_api.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './config/index'; +export * from './feature-name'; +export * from './model/index'; +export * from './opf-checkout-root.module'; diff --git a/integration-libs/opf/checkout/styles/_index.scss b/integration-libs/opf/checkout/styles/_index.scss new file mode 100644 index 00000000000..192091fb04e --- /dev/null +++ b/integration-libs/opf/checkout/styles/_index.scss @@ -0,0 +1 @@ +@import './components/index'; diff --git a/integration-libs/opf/checkout/styles/components/_index.scss b/integration-libs/opf/checkout/styles/components/_index.scss new file mode 100644 index 00000000000..e6d7390c87b --- /dev/null +++ b/integration-libs/opf/checkout/styles/components/_index.scss @@ -0,0 +1,5 @@ +@import './opf-checkout-payment-and-review'; +@import './opf-checkout-payments'; +@import './opf-checkout-billing-address-form'; +@import './opf-checkout-payment-wrapper'; +@import './opf-checkout-terms-and-conditions-alert'; diff --git a/integration-libs/opf/checkout/styles/components/_opf-checkout-billing-address-form.scss b/integration-libs/opf/checkout/styles/components/_opf-checkout-billing-address-form.scss new file mode 100644 index 00000000000..9e6f1fb22e7 --- /dev/null +++ b/integration-libs/opf/checkout/styles/components/_opf-checkout-billing-address-form.scss @@ -0,0 +1,25 @@ +%cx-opf-checkout-billing-address-form { + @include media-breakpoint-down(md) { + margin-top: 1rem; + } + + .cx-custom-address-info { + display: flex; + align-items: flex-start; + + cx-card { + flex-grow: 1; + } + + button { + outline: none; + border: none; + background: none; + margin-top: 1rem; + } + } + + .cx-card-body { + padding: 0 2.25rem; + } +} diff --git a/integration-libs/opf/checkout/styles/components/_opf-checkout-payment-and-review.scss b/integration-libs/opf/checkout/styles/components/_opf-checkout-payment-and-review.scss new file mode 100644 index 00000000000..ce650d038b6 --- /dev/null +++ b/integration-libs/opf/checkout/styles/components/_opf-checkout-payment-and-review.scss @@ -0,0 +1,96 @@ +%cx-opf-checkout-payment-and-review { + @include checkout-media-style(); + margin-top: 1.5rem; + + @include media-breakpoint-down(md) { + background-color: var(--cx-color-transparent); + } + + h3 { + padding-bottom: 1rem; + margin-bottom: 0; + font-size: 1.15rem; + } + + cx-opf-checkout-billing-address-form, + cx-opf-checkout-payments, + .cx-opf-terms-and-conditions { + display: block; + margin-bottom: 2rem; + border: 1px solid #d3d6db; + padding: 1.75rem 1.75rem; + border-radius: 10px; + background-color: #ffffff; + } + + .cx-opf-terms-and-conditions { + margin-bottom: 2rem; + + .form-check, + .form-group { + margin-bottom: 0; + } + } + + .cx-review-cart-item { + padding: 0; + } + + .cx-review-cart-total { + font-size: var(--cx-font-size, 1.125rem); + font-weight: var(--cx-font-weight-bold); + line-height: var(--cx-line-height, 1.2222222222); + padding-top: 1rem; + padding-bottom: 1.25rem; + } + + .cx-items-to-ship-label { + display: flex; + flex-direction: row; + align-items: center; + padding: 1rem 2rem; + font-weight: var(--cx-font-weight-semi); + height: 65px; + background: #f1f1f1; + + @include media-breakpoint-down(md) { + background: #d3d6db; + } + } + + .cx-shipping-and-delivery-info-cards { + display: flex; + gap: 1rem; + margin-top: 1rem; + flex-direction: row; + + @include media-breakpoint-down(md) { + flex-direction: column; + } + + .card-body { + padding: 0; + } + + .cx-review-summary-card { + display: flex; + flex: 1; + border: 1px solid #d3d6db; + border-radius: 10px; + justify-content: space-between; + padding: 1.75rem; + background-color: #ffffff; + } + + .cx-card-title { + font-weight: var(--cx-font-weight-semi); + } + } + + > cx-opf-checkout-terms-and-conditions-alert { + display: none; + @include media-breakpoint-down(md) { + display: block; + } + } +} diff --git a/integration-libs/opf/checkout/styles/components/_opf-checkout-payment-wrapper.scss b/integration-libs/opf/checkout/styles/components/_opf-checkout-payment-wrapper.scss new file mode 100644 index 00000000000..6959af76b2c --- /dev/null +++ b/integration-libs/opf/checkout/styles/components/_opf-checkout-payment-wrapper.scss @@ -0,0 +1,20 @@ +%cx-opf-checkout-payment-wrapper { + .cx-payment-option-container { + padding: 1rem 0 0 0; + .cx-info { + text-align: center; + font-size: var(--cx-font-size, 1rem); + color: var(--cx-color-dark); + display: block; + margin: 0.5rem 0 0.5rem 0; + } + .cx-payment-link { + text-align: end; + } + .cx-payment-iframe { + min-height: 400px; + width: 100%; + border: none; + } + } +} diff --git a/integration-libs/opf/checkout/styles/components/_opf-checkout-payments.scss b/integration-libs/opf/checkout/styles/components/_opf-checkout-payments.scss new file mode 100644 index 00000000000..25b40d051c2 --- /dev/null +++ b/integration-libs/opf/checkout/styles/components/_opf-checkout-payments.scss @@ -0,0 +1,44 @@ +%cx-opf-checkout-payments { + .cx-payment-options-list { + .form-check { + margin-bottom: 0; + padding-top: 0.625rem; + padding-bottom: 1rem; + border-top: 1px solid var(--cx-color-medium); + text-transform: capitalize; + + & ~ .form-check { + padding-top: 1rem; + } + } + + input:disabled + .form-check-label { + color: var(--cx-color-medium); + + .cx-payment-logo { + opacity: 0.3; + } + } + + input[type='radio'] { + border-color: var(--cx-color-text); + } + + input[type='radio']:disabled { + border-color: var(--cx-color-medium); + background-color: var(--cx-color-light); + } + + > cx-opf-checkout-terms-and-conditions-alert { + display: block; + @include media-breakpoint-down(md) { + display: none; + } + } + + .cx-payment-logo { + max-height: 30px; + margin: -2px 0 0 4px; + } + } +} diff --git a/integration-libs/opf/checkout/styles/components/_opf-checkout-terms-and-conditions-alert.scss b/integration-libs/opf/checkout/styles/components/_opf-checkout-terms-and-conditions-alert.scss new file mode 100644 index 00000000000..4f042640d4a --- /dev/null +++ b/integration-libs/opf/checkout/styles/components/_opf-checkout-terms-and-conditions-alert.scss @@ -0,0 +1,15 @@ +%cx-opf-checkout-terms-and-conditions-alert { + .alert { + .close { + top: var(--cx-top, 50%); + right: 1.5rem; + margin-top: -12px; + &:before { + margin: 0; + } + } + .message { + padding: 10px 0; + } + } +} diff --git a/integration-libs/opf/cta/components/ng-package.json b/integration-libs/opf/cta/components/ng-package.json new file mode 100644 index 00000000000..813b2c3c3b8 --- /dev/null +++ b/integration-libs/opf/cta/components/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/cta/components/opf-cta-components.module.ts b/integration-libs/opf/cta/components/opf-cta-components.module.ts new file mode 100644 index 00000000000..f7287763abf --- /dev/null +++ b/integration-libs/opf/cta/components/opf-cta-components.module.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfCtaElementModule } from './opf-cta-element'; +import { OpfCtaScriptsModule } from './opf-cta-scripts'; + +@NgModule({ + imports: [OpfCtaScriptsModule, OpfCtaElementModule], + providers: [], +}) +export class OpfCtaComponentsModule {} diff --git a/integration-libs/opf/cta/components/opf-cta-element/index.ts b/integration-libs/opf/cta/components/opf-cta-element/index.ts new file mode 100644 index 00000000000..67e9cc9d1c6 --- /dev/null +++ b/integration-libs/opf/cta/components/opf-cta-element/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-cta-element.component'; +export * from './opf-cta-element.module'; diff --git a/integration-libs/opf/cta/components/opf-cta-element/opf-cta-element.component.html b/integration-libs/opf/cta/components/opf-cta-element/opf-cta-element.component.html new file mode 100644 index 00000000000..7ce3521556a --- /dev/null +++ b/integration-libs/opf/cta/components/opf-cta-element/opf-cta-element.component.html @@ -0,0 +1,4 @@ +
diff --git a/integration-libs/opf/cta/components/opf-cta-element/opf-cta-element.component.spec.ts b/integration-libs/opf/cta/components/opf-cta-element/opf-cta-element.component.spec.ts new file mode 100644 index 00000000000..24801bc304e --- /dev/null +++ b/integration-libs/opf/cta/components/opf-cta-element/opf-cta-element.component.spec.ts @@ -0,0 +1,81 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DomSanitizer } from '@angular/platform-browser'; +import { WindowRef } from '@spartacus/core'; +import { OpfDynamicScript } from '@spartacus/opf/base/root'; +import { OpfCtaScriptsService } from '../opf-cta-scripts'; +import { OpfCtaElementComponent } from './opf-cta-element.component'; + +describe('OpfCtaButton', () => { + let component: OpfCtaElementComponent; + let fixture: ComponentFixture; + let domSanitizer: DomSanitizer; + let opfCtaScriptsServiceMock: jasmine.SpyObj; + let windowRef: WindowRef; + + const dynamicScriptMock: OpfDynamicScript = { + html: '

Thanks for purchasing our great products

Please use promo code:123abc for your next purchase

', + cssUrls: [ + { + url: 'https://checkoutshopper-test.adyen.com/checkoutshopper/sdk/4.2.1/adyen.css', + sri: '', + }, + ], + jsUrls: [ + { + url: 'https://checkoutshopper-test.adyen.com/checkoutshopper/sdk/4.2.1/adyen.js', + sri: '', + }, + ], + }; + + beforeEach(() => { + opfCtaScriptsServiceMock = jasmine.createSpyObj('OpfCtaScriptsService', [ + 'removeScriptTags', + 'loadAndRunScript', + ]); + + TestBed.configureTestingModule({ + declarations: [OpfCtaElementComponent], + providers: [ + { provide: OpfCtaScriptsService, useValue: opfCtaScriptsServiceMock }, + ], + }); + fixture = TestBed.createComponent(OpfCtaElementComponent); + component = fixture.componentInstance; + domSanitizer = TestBed.inject(DomSanitizer); + opfCtaScriptsServiceMock.loadAndRunScript.and.returnValue( + Promise.resolve(dynamicScriptMock) + ); + windowRef = TestBed.inject(WindowRef); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should renderHtml remove script tags and call bypassSecurityTrustHtml', () => { + const html = + '

Test1

Test2

'; + const expectedHtml = '

Test1

Test2

'; + spyOn(domSanitizer, 'bypassSecurityTrustHtml').and.stub(); + component.renderHtml(html); + + expect(domSanitizer.bypassSecurityTrustHtml).toHaveBeenCalledWith( + expectedHtml + ); + }); + + it('should renderHtml not call bypassSecurityTrustHtml in SSR', () => { + spyOn(windowRef, 'isBrowser').and.returnValue(false); + const html = '

Test

'; + spyOn(domSanitizer, 'bypassSecurityTrustHtml').and.stub(); + component.renderHtml(html); + + expect(domSanitizer.bypassSecurityTrustHtml).not.toHaveBeenCalledWith(html); + }); + + it('should call loadAndRunScript', () => { + component.ngAfterViewInit(); + expect(opfCtaScriptsServiceMock.loadAndRunScript).toHaveBeenCalled(); + }); +}); diff --git a/integration-libs/opf/cta/components/opf-cta-element/opf-cta-element.component.ts b/integration-libs/opf/cta/components/opf-cta-element/opf-cta-element.component.ts new file mode 100644 index 00000000000..1f753de8ab7 --- /dev/null +++ b/integration-libs/opf/cta/components/opf-cta-element/opf-cta-element.component.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + Input, + inject, +} from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { WindowRef } from '@spartacus/core'; +import { OpfDynamicScript } from '@spartacus/opf/base/root'; +import { OpfCtaScriptsService } from '../opf-cta-scripts/opf-cta-scripts.service'; + +@Component({ + selector: 'cx-opf-cta-element', + templateUrl: './opf-cta-element.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpfCtaElementComponent implements AfterViewInit { + protected sanitizer = inject(DomSanitizer); + protected opfCtaScriptsService = inject(OpfCtaScriptsService); + protected windowRef = inject(WindowRef); + + @Input() ctaScriptHtml: OpfDynamicScript; + + ngAfterViewInit(): void { + this.opfCtaScriptsService.loadAndRunScript(this.ctaScriptHtml); + } + renderHtml(html: string): SafeHtml { + // Display sanitized html in SSR for security concerns + return this.windowRef.isBrowser() + ? this.sanitizer.bypassSecurityTrustHtml(this.removeScriptTags(html)) + : html; + } + + // Removing script tags on FE until BE fix: CXSPA-8572 + protected removeScriptTags(html: string) { + const element = new DOMParser().parseFromString(html, 'text/html'); + Array.from(element.getElementsByTagName('script')).forEach((script) => { + html = html.replace(script.outerHTML, ''); + }); + return html; + } +} diff --git a/integration-libs/opf/cta/components/opf-cta-element/opf-cta-element.module.ts b/integration-libs/opf/cta/components/opf-cta-element/opf-cta-element.module.ts new file mode 100644 index 00000000000..655c69b85a3 --- /dev/null +++ b/integration-libs/opf/cta/components/opf-cta-element/opf-cta-element.module.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { OpfCtaElementComponent } from './opf-cta-element.component'; + +@NgModule({ + declarations: [OpfCtaElementComponent], + imports: [CommonModule], + exports: [OpfCtaElementComponent], +}) +export class OpfCtaElementModule {} diff --git a/integration-libs/opf/cta/components/opf-cta-scripts/index.ts b/integration-libs/opf/cta/components/opf-cta-scripts/index.ts new file mode 100644 index 00000000000..be1d6ce3900 --- /dev/null +++ b/integration-libs/opf/cta/components/opf-cta-scripts/index.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-cta-scripts.component'; +export * from './opf-cta-scripts.module'; +export * from './opf-cta-scripts.service'; diff --git a/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.component.html b/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.component.html new file mode 100644 index 00000000000..b4a578e3523 --- /dev/null +++ b/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.component.html @@ -0,0 +1,8 @@ + + + + + diff --git a/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.component.spec.ts b/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.component.spec.ts new file mode 100644 index 00000000000..f921be28a5c --- /dev/null +++ b/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.component.spec.ts @@ -0,0 +1,93 @@ +import { Component, Input } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OpfDynamicScript } from '@spartacus/opf/base/root'; +import { of, throwError } from 'rxjs'; +import { OpfCtaScriptsComponent } from './opf-cta-scripts.component'; +import { OpfCtaScriptsService } from './opf-cta-scripts.service'; +import createSpy = jasmine.createSpy; + +const mockHtmlsList: OpfDynamicScript[] = [ + { + html: '

Thanks for purchasing our great products

Please use promo code:123abc for your next purchase

', + }, + { + html: '

Thanks again for purchasing our great products

Please use promo code:123abc for your next purchase

', + }, +]; +const ctaElementSelector = 'cx-opf-cta-element'; + +@Component({ + selector: 'cx-opf-cta-element', + template: '', +}) +export class MockOpfCtaElementComponent { + @Input() ctaScriptHtml: string; +} + +@Component({ + selector: 'cx-spinner', + template: '', +}) +class MockSpinnerComponent {} + +describe('OpfCtaScriptsComponent', () => { + let component: OpfCtaScriptsComponent; + let fixture: ComponentFixture; + let opfCtaScriptsService: jasmine.SpyObj; + + const createComponentInstance = () => { + fixture = TestBed.createComponent(OpfCtaScriptsComponent); + component = fixture.componentInstance; + }; + beforeEach(() => { + opfCtaScriptsService = jasmine.createSpyObj('OpfCtaScriptsService', [ + 'getCtaHtmlslList', + ]); + + TestBed.configureTestingModule({ + declarations: [ + OpfCtaScriptsComponent, + MockOpfCtaElementComponent, + MockSpinnerComponent, + ], + providers: [ + { provide: OpfCtaScriptsService, useValue: opfCtaScriptsService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + opfCtaScriptsService.getCtaHtmlslList.and.returnValue(of(mockHtmlsList)); + createComponentInstance(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should return Htmls list and display ctaButton elements', (done) => { + component.ctaHtmls$.subscribe((htmlList) => { + expect(htmlList[0]).toBeTruthy(); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelectorAll(ctaElementSelector).length + ).toEqual(2); + done(); + }); + }); + + it('should isError be true when error is thrown', (done) => { + opfCtaScriptsService.getCtaHtmlslList = createSpy().and.returnValue( + throwError('error') + ); + createComponentInstance(); + component.ctaHtmls$.subscribe((htmlList) => { + expect(htmlList).toEqual([]); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector(ctaElementSelector) + ).toBeFalsy(); + done(); + }); + }); +}); diff --git a/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.component.ts b/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.component.ts new file mode 100644 index 00000000000..8cc0a044642 --- /dev/null +++ b/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.component.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; +import { OpfCtaScriptsService } from './opf-cta-scripts.service'; + +@Component({ + selector: 'cx-opf-cta-scripts', + templateUrl: './opf-cta-scripts.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpfCtaScriptsComponent { + protected opfCtaScriptService = inject(OpfCtaScriptsService); + + ctaHtmls$ = this.opfCtaScriptService.getCtaHtmlslList().pipe( + catchError(() => { + return of([]); + }) + ); +} diff --git a/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.module.ts b/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.module.ts new file mode 100644 index 00000000000..42b509fe7a1 --- /dev/null +++ b/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.module.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { CmsConfig, provideDefaultConfig } from '@spartacus/core'; + +import { + OpfDynamicCtaService, + OpfStaticCtaService, +} from '@spartacus/opf/cta/core'; +import { OpfCtaElementModule } from '../opf-cta-element'; +import { OpfCtaScriptsComponent } from './opf-cta-scripts.component'; +import { OpfCtaScriptsService } from './opf-cta-scripts.service'; + +@NgModule({ + declarations: [OpfCtaScriptsComponent], + providers: [ + OpfCtaScriptsService, + OpfDynamicCtaService, + OpfStaticCtaService, + provideDefaultConfig({ + cmsComponents: { + OpfCtaScriptsComponent: { + component: OpfCtaScriptsComponent, + }, + }, + }), + ], + exports: [OpfCtaScriptsComponent], + imports: [CommonModule, OpfCtaElementModule], +}) +export class OpfCtaScriptsModule {} diff --git a/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.service.spec.ts b/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.service.spec.ts new file mode 100644 index 00000000000..31a44a58060 --- /dev/null +++ b/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.service.spec.ts @@ -0,0 +1,372 @@ +import { TestBed } from '@angular/core/testing'; +import { CmsService, Page, Product, QueryState } from '@spartacus/core'; +import { + ActiveConfiguration, + OpfBaseFacade, + OpfDynamicScript, + OpfPaymentProviderType, + OpfResourceLoaderService, +} from '@spartacus/opf/base/root'; +import { + OpfDynamicCtaService, + OpfStaticCtaService, +} from '@spartacus/opf/cta/core'; +import { + CtaScriptsLocation, + CtaScriptsRequest, + CtaScriptsResponse, + OpfCtaFacade, +} from '@spartacus/opf/cta/root'; +import { Order, OrderFacade, OrderHistoryFacade } from '@spartacus/order/root'; +import { CurrentProductService } from '@spartacus/storefront'; +import { of } from 'rxjs'; +import { OpfCtaScriptsService } from './opf-cta-scripts.service'; + +const ctaScriptsRequestForOrderMock: CtaScriptsRequest = { + paymentAccountIds: [1], + scriptLocations: [CtaScriptsLocation.ORDER_CONFIRMATION_PAYMENT_GUIDE], +}; +const ctaScriptsRequestForPdpMock: CtaScriptsRequest = { + paymentAccountIds: [1], + scriptLocations: [CtaScriptsLocation.PDP_MESSAGING], +}; +const ctaScriptsRequestForCartPageMock: CtaScriptsRequest = { + paymentAccountIds: [1], + scriptLocations: [CtaScriptsLocation.CART_MESSAGING], +}; + +describe('OpfCtaScriptsService', () => { + let service: OpfCtaScriptsService; + let orderFacadeMock: jasmine.SpyObj; + let orderHistoryFacadeMock: jasmine.SpyObj; + let opfResourceLoaderServiceMock: jasmine.SpyObj; + let cmsServiceMock: jasmine.SpyObj; + let currentProductMock: jasmine.SpyObj; + let opfBaseFacadeMock: jasmine.SpyObj; + let opfCtaFacadeMock: jasmine.SpyObj; + let opfDynamicCtaServiceMock: jasmine.SpyObj; + let opfStaticCtaServiceMock: jasmine.SpyObj; + beforeEach(() => { + orderFacadeMock = jasmine.createSpyObj('OrderFacade', ['getOrderDetails']); + orderHistoryFacadeMock = jasmine.createSpyObj('OrderHistoryFacade', [ + 'getOrderDetails', + ]); + opfResourceLoaderServiceMock = jasmine.createSpyObj( + 'OpfResourceLoaderService', + [ + 'executeScriptFromHtml', + 'loadProviderResources', + 'clearAllProviderResources', + ] + ); + cmsServiceMock = jasmine.createSpyObj('CmsService', ['getCurrentPage']); + currentProductMock = jasmine.createSpyObj('CurrentProductService', [ + 'getProduct', + ]); + opfBaseFacadeMock = jasmine.createSpyObj('OpfBaseFacade', [ + 'getActiveConfigurationsState', + ]); + opfCtaFacadeMock = jasmine.createSpyObj('OpfCtaFacade', ['getCtaScripts']); + opfDynamicCtaServiceMock = jasmine.createSpyObj('OpfDynamicCtaService', [ + 'initiateEvents', + 'stopEvents', + 'fillCtaRequestforCartPage', + 'fillCtaRequestforProductPage', + 'registerScriptReadyEvent', + ]); + opfStaticCtaServiceMock = jasmine.createSpyObj('OpfStaticCtaService', [ + 'fillCtaRequestforPagesWithOrder', + ]); + + TestBed.configureTestingModule({ + providers: [ + OpfCtaScriptsService, + { provide: OrderFacade, useValue: orderFacadeMock }, + { provide: OrderHistoryFacade, useValue: orderHistoryFacadeMock }, + { + provide: OpfResourceLoaderService, + useValue: opfResourceLoaderServiceMock, + }, + { provide: CmsService, useValue: cmsServiceMock }, + { provide: CurrentProductService, useValue: currentProductMock }, + { provide: OpfBaseFacade, useValue: opfBaseFacadeMock }, + { provide: OpfCtaFacade, useValue: opfCtaFacadeMock }, + { provide: OpfDynamicCtaService, useValue: opfDynamicCtaServiceMock }, + { provide: OpfStaticCtaService, useValue: opfStaticCtaServiceMock }, + ], + }); + service = TestBed.inject(OpfCtaScriptsService); + opfStaticCtaServiceMock.fillCtaRequestforPagesWithOrder.and.returnValue( + of(ctaScriptsRequestForOrderMock) + ); + opfDynamicCtaServiceMock.fillCtaRequestforProductPage.and.returnValue( + of(ctaScriptsRequestForPdpMock) + ); + opfDynamicCtaServiceMock.fillCtaRequestforCartPage.and.returnValue( + of(ctaScriptsRequestForCartPageMock) + ); + opfDynamicCtaServiceMock.initiateEvents.and.returnValue(); + opfDynamicCtaServiceMock.stopEvents.and.returnValue(); + opfDynamicCtaServiceMock.registerScriptReadyEvent.and.returnValue(); + orderFacadeMock.getOrderDetails.and.returnValue(of(mockOrder)); + orderHistoryFacadeMock.getOrderDetails.and.returnValue(of(mockOrder)); + + opfResourceLoaderServiceMock.executeScriptFromHtml.and.returnValue(); + opfResourceLoaderServiceMock.loadProviderResources.and.returnValue( + Promise.resolve() + ); + currentProductMock.getProduct.and.returnValue(of(mockProduct)); + cmsServiceMock.getCurrentPage.and.returnValue(of(mockPage)); + opfBaseFacadeMock.getActiveConfigurationsState.and.returnValue( + of(activeConfigurationsMock) + ); + opfCtaFacadeMock.getCtaScripts.and.returnValue(of(ctaScriptsResponseMock)); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call opfStaticCtaService for CTA on ConfirmationPage', (done) => { + service.getCtaHtmlslList().subscribe((htmlsList) => { + expect(htmlsList[0].html).toContain( + 'Thanks for purchasing our great products' + ); + expect( + opfDynamicCtaServiceMock.registerScriptReadyEvent + ).not.toHaveBeenCalled(); + expect( + opfStaticCtaServiceMock.fillCtaRequestforPagesWithOrder + ).toHaveBeenCalled(); + done(); + }); + }); + + it('should call StaticCtaService for CTA on order page', (done) => { + cmsServiceMock.getCurrentPage.and.returnValue( + of({ ...mockPage, pageId: 'order' }) + ); + + service.getCtaHtmlslList().subscribe((htmlsList) => { + expect(htmlsList[0].html).toContain( + 'Thanks for purchasing our great products' + ); + expect( + opfDynamicCtaServiceMock.registerScriptReadyEvent + ).not.toHaveBeenCalled(); + expect( + opfStaticCtaServiceMock.fillCtaRequestforPagesWithOrder + ).toHaveBeenCalled(); + done(); + }); + }); + + it('should call DynamicCtaService for CTA on PDP', (done) => { + cmsServiceMock.getCurrentPage.and.returnValue( + of({ ...mockPage, pageId: 'productDetails' }) + ); + + service.getCtaHtmlslList().subscribe((htmlsList) => { + expect(htmlsList[0].html).toContain( + 'Thanks for purchasing our great products' + ); + expect( + opfDynamicCtaServiceMock.registerScriptReadyEvent + ).toHaveBeenCalled(); + expect( + opfDynamicCtaServiceMock.fillCtaRequestforProductPage + ).toHaveBeenCalled(); + done(); + }); + }); + + it('should call DynamicCtaService for CTA on Cart page', (done) => { + cmsServiceMock.getCurrentPage.and.returnValue( + of({ ...mockPage, pageId: 'cartPage' }) + ); + + service.getCtaHtmlslList().subscribe((htmlsList) => { + expect(htmlsList[0].html).toContain( + 'Thanks for purchasing our great products' + ); + expect( + opfDynamicCtaServiceMock.registerScriptReadyEvent + ).toHaveBeenCalled(); + expect( + opfDynamicCtaServiceMock.fillCtaRequestforCartPage + ).toHaveBeenCalled(); + done(); + }); + }); + + it('should throw an error when empty CTA scripts response from OPF server', (done) => { + opfCtaFacadeMock.getCtaScripts.and.returnValue(of({ value: [] })); + + service.getCtaHtmlslList().subscribe({ + error: (error) => { + expect(error).toEqual('Invalid CTA Scripts Response'); + + done(); + }, + }); + }); + + it('should throw an error when ScriptLocation is invalid', (done) => { + cmsServiceMock.getCurrentPage.and.returnValue( + of({ ...mockPage, pageId: 'testPage' }) + ); + + service.getCtaHtmlslList().subscribe({ + next: () => { + fail('Invalid script should fail'); + done(); + }, + error: (error) => { + expect(error).toEqual('Invalid Script Location'); + done(); + }, + }); + }); + + it('should throw an error when empty ScriptLocation', (done) => { + cmsServiceMock.getCurrentPage.and.returnValue( + of({ ...mockPage, pageId: undefined }) + ); + + service.getCtaHtmlslList().subscribe({ + next: () => { + fail('Empty script should fail'); + done(); + }, + error: (error) => { + expect(error).toEqual('Invalid Script Location'); + done(); + }, + }); + }); + + it('should execute script from script response html', (done) => { + service.loadAndRunScript(dynamicScriptMock).then((scriptResponse) => { + expect( + opfResourceLoaderServiceMock.executeScriptFromHtml + ).toHaveBeenCalled(); + expect(scriptResponse?.html).toEqual(dynamicScriptMock.html); + done(); + }); + }); + + it('should not execute script when html from script response is empty', (done) => { + service + .loadAndRunScript({ ...dynamicScriptMock, html: '' }) + .then((scriptResponse) => { + expect( + opfResourceLoaderServiceMock.executeScriptFromHtml + ).not.toHaveBeenCalled(); + expect(scriptResponse).toBeFalsy(); + done(); + }); + }); + + it('should not execute script when resource loading failed', (done) => { + opfResourceLoaderServiceMock.loadProviderResources.and.returnValue( + Promise.reject() + ); + service + .loadAndRunScript({ ...dynamicScriptMock, html: '' }) + .then((scriptResponse) => { + expect( + opfResourceLoaderServiceMock.executeScriptFromHtml + ).not.toHaveBeenCalled(); + expect(scriptResponse).toBeFalsy(); + done(); + }); + }); + + const dynamicScriptMock: OpfDynamicScript = { + html: '

Thanks for purchasing our great products

Please use promo code:123abc for your next purchase

', + cssUrls: [ + { + url: 'https://checkoutshopper-test.adyen.com/checkoutshopper/sdk/4.2.1/adyen.css', + sri: '', + }, + ], + jsUrls: [ + { + url: 'https://checkoutshopper-test.adyen.com/checkoutshopper/sdk/4.2.1/adyen.js', + sri: '', + }, + ], + }; + + const ctaScriptsResponseMock: CtaScriptsResponse = { + value: [ + { + paymentAccountId: 1, + dynamicScript: { + html: '

Thanks for purchasing our great products

Please use promo code:123abc for your next purchase

', + cssUrls: [ + { + url: 'https://checkoutshopper-test.adyen.com/checkoutshopper/sdk/4.2.1/adyen.css', + sri: '', + }, + ], + jsUrls: [ + { + url: 'https://checkoutshopper-test.adyen.com/checkoutshopper/sdk/4.2.1/adyen.js', + sri: '', + }, + ], + }, + }, + ], + }; + + const activeConfigurationsMock: QueryState< + ActiveConfiguration[] | undefined + > = { + loading: false, + error: false, + data: [ + { + id: 14, + providerType: OpfPaymentProviderType.PAYMENT_METHOD, + merchantId: 'SAP OPF', + displayName: 'Crypto with BitPay', + }, + ], + }; + + const mockPage: Page = { + pageId: 'orderConfirmationPage', + }; + + const mockProduct: Product = { + name: 'mockProduct', + code: 'code1', + stock: { + stockLevel: 333, + stockLevelStatus: 'inStock', + }, + }; + + const mockOrder: Order = { + code: 'mockOrder', + paymentInfo: { + id: 'mockPaymentInfoId', + }, + entries: [ + { + product: { + code: '11', + }, + quantity: 1, + }, + { + product: { + code: '22', + }, + quantity: 1, + }, + ], + }; +}); diff --git a/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.service.ts b/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.service.ts new file mode 100644 index 00000000000..8eaa6846914 --- /dev/null +++ b/integration-libs/opf/cta/components/opf-cta-scripts/opf-cta-scripts.service.ts @@ -0,0 +1,186 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable, inject } from '@angular/core'; +import { CmsService } from '@spartacus/core'; +import { Observable, Subscription, of, throwError } from 'rxjs'; +import { concatMap, filter, finalize, map, take, tap } from 'rxjs/operators'; + +import { + OpfBaseFacade, + OpfDynamicScript, + OpfResourceLoaderService, +} from '@spartacus/opf/base/root'; +import { + CmsPageLocation, + CtaScriptsLocation, + CtaScriptsRequest, + CtaScriptsResponse, + DynamicCtaLocations, + OpfCtaFacade, +} from '@spartacus/opf/cta/root'; + +import { + OpfDynamicCtaService, + OpfStaticCtaService, +} from '@spartacus/opf/cta/core'; + +@Injectable() +export class OpfCtaScriptsService { + protected opfBaseFacade = inject(OpfBaseFacade); + protected opfCtaFacade = inject(OpfCtaFacade); + protected opfResourceLoaderService = inject(OpfResourceLoaderService); + protected cmsService = inject(CmsService); + protected opfDynamicCtaService = inject(OpfDynamicCtaService); + protected opfStaticCtaService = inject(OpfStaticCtaService); + + protected subList: Array = []; + + loadAndRunScript( + script: OpfDynamicScript + ): Promise { + const html = script?.html; + + return new Promise( + (resolve: (value: OpfDynamicScript | undefined) => void) => { + this.opfResourceLoaderService + .loadProviderResources(script.jsUrls, script.cssUrls) + .then(() => { + if (html) { + this.opfResourceLoaderService.executeScriptFromHtml(html); + resolve(script); + } else { + resolve(undefined); + } + }) + .catch(() => { + resolve(undefined); + }); + } + ); + } + + getCtaHtmlslList(): Observable { + let isDynamicCtaLocation = false; + return this.fillCtaScriptRequest().pipe( + concatMap((ctaScriptsRequest) => { + isDynamicCtaLocation = + !!ctaScriptsRequest?.scriptLocations?.length && + !!ctaScriptsRequest?.scriptLocations.find((location) => + DynamicCtaLocations.includes(location) + ); + isDynamicCtaLocation && + this.opfDynamicCtaService.registerScriptReadyEvent(); + return this.fetchCtaScripts(ctaScriptsRequest); + }), + tap((scriptsResponse) => { + isDynamicCtaLocation && + !!scriptsResponse.length && + this.opfDynamicCtaService.initiateEvents(); + }), + finalize(() => { + this.opfResourceLoaderService.clearAllProviderResources(); + isDynamicCtaLocation && this.opfDynamicCtaService.stopEvents(); + }) + ); + } + + protected fetchCtaScripts( + ctaScriptsRequest: CtaScriptsRequest + ): Observable { + return this.opfCtaFacade.getCtaScripts(ctaScriptsRequest).pipe( + concatMap((ctaScriptsResponse: CtaScriptsResponse) => { + if (!ctaScriptsResponse?.value?.length) { + return throwError(() => 'Invalid CTA Scripts Response'); + } + const dynamicScripts = ctaScriptsResponse.value.map( + (ctaScript) => ctaScript.dynamicScript + ); + return of(dynamicScripts); + }), + take(1) + ); + } + + protected fillCtaScriptRequest(): Observable { + let paymentAccountIds: number[]; + + return this.getPaymentAccountIds().pipe( + concatMap((accIds) => { + paymentAccountIds = accIds; + return this.getScriptLocation(); + }), + concatMap((scriptsLocation: CtaScriptsLocation | undefined) => { + return this.fillRequestForTargetPage( + scriptsLocation, + paymentAccountIds + ); + }) + ); + } + + protected fillRequestForTargetPage( + scriptsLocation: CtaScriptsLocation | undefined, + paymentAccountIds: number[] + ): Observable { + if (!scriptsLocation) { + return throwError(() => 'Invalid Script Location'); + } + const locationToFunctionMap: Record< + CtaScriptsLocation, + () => Observable + > = { + [CtaScriptsLocation.ORDER_HISTORY_PAYMENT_GUIDE]: () => + this.opfStaticCtaService.fillCtaRequestforPagesWithOrder( + scriptsLocation + ), + [CtaScriptsLocation.ORDER_CONFIRMATION_PAYMENT_GUIDE]: () => + this.opfStaticCtaService.fillCtaRequestforPagesWithOrder( + scriptsLocation + ), + [CtaScriptsLocation.CART_MESSAGING]: () => + this.opfDynamicCtaService.fillCtaRequestforCartPage( + scriptsLocation, + paymentAccountIds + ), + [CtaScriptsLocation.PDP_MESSAGING]: () => + this.opfDynamicCtaService.fillCtaRequestforProductPage( + scriptsLocation, + paymentAccountIds + ), + }; + const selectedFunction = locationToFunctionMap[scriptsLocation]; + return selectedFunction(); + } + + protected getScriptLocation(): Observable { + const cmsToCtaLocationMap: Record = { + [CmsPageLocation.ORDER_PAGE]: + CtaScriptsLocation.ORDER_HISTORY_PAYMENT_GUIDE, + [CmsPageLocation.ORDER_CONFIRMATION_PAGE]: + CtaScriptsLocation.ORDER_CONFIRMATION_PAYMENT_GUIDE, + [CmsPageLocation.PDP_PAGE]: CtaScriptsLocation.PDP_MESSAGING, + [CmsPageLocation.CART_PAGE]: CtaScriptsLocation.CART_MESSAGING, + }; + return this.cmsService.getCurrentPage().pipe( + take(1), + map((page) => + page.pageId + ? cmsToCtaLocationMap[page.pageId as CmsPageLocation] + : undefined + ) + ); + } + + protected getPaymentAccountIds() { + return this.opfBaseFacade.getActiveConfigurationsState().pipe( + filter( + (state) => !state.loading && !state.error && Boolean(state.data?.length) + ), + map((state) => state.data?.map((val) => val.id) as number[]) + ); + } +} diff --git a/integration-libs/opf/cta/components/public_api.ts b/integration-libs/opf/cta/components/public_api.ts new file mode 100644 index 00000000000..9c1af5319b5 --- /dev/null +++ b/integration-libs/opf/cta/components/public_api.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-cta-components.module'; diff --git a/integration-libs/opf/cta/core/connectors/index.ts b/integration-libs/opf/cta/core/connectors/index.ts new file mode 100644 index 00000000000..181d6f21ca0 --- /dev/null +++ b/integration-libs/opf/cta/core/connectors/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-cta.adapter'; +export * from './opf-cta.connector'; diff --git a/integration-libs/opf/cta/core/connectors/opf-cta.adapter.ts b/integration-libs/opf/cta/core/connectors/opf-cta.adapter.ts new file mode 100644 index 00000000000..2c87a298436 --- /dev/null +++ b/integration-libs/opf/cta/core/connectors/opf-cta.adapter.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CtaScriptsRequest, CtaScriptsResponse } from '@spartacus/opf/cta/root'; +import { Observable } from 'rxjs'; + +export abstract class OpfCtaAdapter { + /** + * Abstract method used to get CTA scripts list + */ + abstract getCtaScripts( + ctaScriptsRequest: CtaScriptsRequest + ): Observable; +} diff --git a/integration-libs/opf/cta/core/connectors/opf-cta.connector.spec.ts b/integration-libs/opf/cta/core/connectors/opf-cta.connector.spec.ts new file mode 100644 index 00000000000..397a5182bb9 --- /dev/null +++ b/integration-libs/opf/cta/core/connectors/opf-cta.connector.spec.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { CtaScriptsRequest, CtaScriptsResponse } from '@spartacus/opf/cta/root'; +import { of } from 'rxjs'; +import { OpfCtaAdapter } from './opf-cta.adapter'; +import { OpfCtaConnector } from './opf-cta.connector'; +import createSpy = jasmine.createSpy; + +const mockCtaScriptsRequest: CtaScriptsRequest = { + cartId: '123', + paymentAccountIds: [123], +}; + +const mockCtaScriptsResponse: CtaScriptsResponse = { + value: [], +}; + +class MockOpfCtaAdapter implements OpfCtaAdapter { + getCtaScripts = createSpy('getCtaScripts').and.callFake(() => + of(mockCtaScriptsResponse) + ); +} + +describe('OpfCtaConnector', () => { + let service: OpfCtaConnector; + let adapter: OpfCtaAdapter; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OpfCtaConnector, + { provide: OpfCtaAdapter, useClass: MockOpfCtaAdapter }, + ], + }); + + service = TestBed.inject(OpfCtaConnector); + adapter = TestBed.inject(OpfCtaAdapter); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('getCtaScripts should call adapter', () => { + let result; + service + .getCtaScripts(mockCtaScriptsRequest) + .subscribe((res) => (result = res)); + expect(result).toEqual(mockCtaScriptsResponse); + expect(adapter.getCtaScripts).toHaveBeenCalledWith(mockCtaScriptsRequest); + }); +}); diff --git a/integration-libs/opf/cta/core/connectors/opf-cta.connector.ts b/integration-libs/opf/cta/core/connectors/opf-cta.connector.ts new file mode 100644 index 00000000000..21aebe7944b --- /dev/null +++ b/integration-libs/opf/cta/core/connectors/opf-cta.connector.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { CtaScriptsRequest, CtaScriptsResponse } from '@spartacus/opf/cta/root'; + +import { Observable } from 'rxjs'; +import { OpfCtaAdapter } from './opf-cta.adapter'; + +@Injectable() +export class OpfCtaConnector { + constructor(protected adapter: OpfCtaAdapter) {} + + public getCtaScripts( + ctaScriptsRequest: CtaScriptsRequest + ): Observable { + return this.adapter.getCtaScripts(ctaScriptsRequest); + } +} diff --git a/integration-libs/opf/cta/core/facade/facade-providers.ts b/integration-libs/opf/cta/core/facade/facade-providers.ts new file mode 100644 index 00000000000..6c7f8131a8e --- /dev/null +++ b/integration-libs/opf/cta/core/facade/facade-providers.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Provider } from '@angular/core'; +import { OpfCtaFacade } from '@spartacus/opf/cta/root'; +import { OpfCtaService } from './opf-cta.service'; + +export const facadeProviders: Provider[] = [ + OpfCtaService, + { + provide: OpfCtaFacade, + useExisting: OpfCtaService, + }, +]; diff --git a/integration-libs/opf/cta/core/facade/index.ts b/integration-libs/opf/cta/core/facade/index.ts new file mode 100644 index 00000000000..0043a789b2a --- /dev/null +++ b/integration-libs/opf/cta/core/facade/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-cta.service'; diff --git a/integration-libs/opf/cta/core/facade/opf-cta.service.spec.ts b/integration-libs/opf/cta/core/facade/opf-cta.service.spec.ts new file mode 100644 index 00000000000..df5f9d6588c --- /dev/null +++ b/integration-libs/opf/cta/core/facade/opf-cta.service.spec.ts @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { CommandService } from '@spartacus/core'; +import { CtaScriptsRequest, CtaScriptsResponse } from '@spartacus/opf/cta/root'; +import { Observable, of } from 'rxjs'; +import { OpfCtaService } from './opf-cta.service'; +import { OpfCtaConnector } from '../connectors/opf-cta.connector'; + +class MockCommandService { + create(fn: (payload: any) => Observable) { + return { + execute: (payload: any) => fn(payload), + }; + } +} + +const ctaScriptsResponseMock: CtaScriptsResponse = { + value: [ + { + paymentAccountId: 1, + dynamicScript: { + html: '

Thanks for purchasing our great products

Please use promo code:123abc for your next purchase

', + cssUrls: [ + { + url: 'https://checkoutshopper-test.adyen.com/checkoutshopper/sdk/4.2.1/adyen.css', + sri: '', + }, + ], + jsUrls: [ + { + url: 'https://checkoutshopper-test.adyen.com/checkoutshopper/sdk/4.2.1/adyen.js', + sri: '', + }, + ], + }, + }, + ], +}; + +class MockOpfCtaConnector { + getCtaScripts( + _ctaScriptsRequest: CtaScriptsRequest + ): Observable { + return of(ctaScriptsResponseMock); + } +} + +describe('OpfCtaService', () => { + let service: OpfCtaService; + let commandService: MockCommandService; + let opfCtaConnector: MockOpfCtaConnector; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OpfCtaService, + { provide: CommandService, useClass: MockCommandService }, + { provide: OpfCtaConnector, useClass: MockOpfCtaConnector }, + ], + }); + + service = TestBed.inject(OpfCtaService); + commandService = TestBed.inject(CommandService); + opfCtaConnector = TestBed.inject(OpfCtaConnector); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should execute getCtaScripts and return response', (done) => { + spyOn(opfCtaConnector, 'getCtaScripts').and.callThrough(); + spyOn(commandService, 'create').and.callThrough(); + const request: CtaScriptsRequest = { + paymentAccountIds: [1], + cartId: 'cart123', + ctaProductItems: [{ productId: 'prod123', quantity: 1 }], + }; + + service.getCtaScripts(request).subscribe((response: CtaScriptsResponse) => { + expect(response).toEqual(ctaScriptsResponseMock); + expect(opfCtaConnector.getCtaScripts).toHaveBeenCalledWith(request); + done(); + }); + }); + + it('should emit script ready event', () => { + let emittedValue: string | undefined; + + service.listenScriptReadyEvent().subscribe((value) => { + emittedValue = value; + }); + + const scriptIdentifier = 'script1'; + service.emitScriptReadyEvent(scriptIdentifier); + + expect(emittedValue).toBe(scriptIdentifier); + }); +}); diff --git a/integration-libs/opf/cta/core/facade/opf-cta.service.ts b/integration-libs/opf/cta/core/facade/opf-cta.service.ts new file mode 100644 index 00000000000..835c080a085 --- /dev/null +++ b/integration-libs/opf/cta/core/facade/opf-cta.service.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { Command, CommandService } from '@spartacus/core'; +import { + CtaScriptsRequest, + CtaScriptsResponse, + OpfCtaFacade, +} from '@spartacus/opf/cta/root'; +import { Observable, Subject } from 'rxjs'; +import { OpfCtaConnector } from '../connectors'; + +@Injectable() +export class OpfCtaService implements OpfCtaFacade { + protected _readyForScriptEvent: Subject = new Subject(); + readyForScriptEvent$: Observable = + this._readyForScriptEvent.asObservable(); + + protected ctaScriptsCommand: Command< + { + ctaScriptsRequest: CtaScriptsRequest; + }, + CtaScriptsResponse + > = this.commandService.create((payload) => { + return this.opfCtaConnector.getCtaScripts(payload.ctaScriptsRequest); + }); + + constructor( + protected commandService: CommandService, + protected opfCtaConnector: OpfCtaConnector + ) {} + + getCtaScripts(ctaScriptsRequest: CtaScriptsRequest) { + return this.ctaScriptsCommand.execute({ ctaScriptsRequest }); + } + emitScriptReadyEvent(scriptIdentifier: string) { + this._readyForScriptEvent.next(scriptIdentifier); + } + + listenScriptReadyEvent(): Observable { + return this.readyForScriptEvent$; + } +} diff --git a/integration-libs/opf/cta/core/ng-package.json b/integration-libs/opf/cta/core/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/cta/core/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/cta/core/opf-cta-core.module.ts b/integration-libs/opf/cta/core/opf-cta-core.module.ts new file mode 100644 index 00000000000..bddf1bc41c0 --- /dev/null +++ b/integration-libs/opf/cta/core/opf-cta-core.module.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfCtaConnector } from './connectors'; +import { facadeProviders } from './facade/facade-providers'; + +@NgModule({ + providers: [...facadeProviders, OpfCtaConnector], +}) +export class OpfCtaCoreModule {} diff --git a/integration-libs/opf/cta/core/public_api.ts b/integration-libs/opf/cta/core/public_api.ts new file mode 100644 index 00000000000..cf15109d563 --- /dev/null +++ b/integration-libs/opf/cta/core/public_api.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './connectors/index'; +export * from './facade/index'; +export * from './opf-cta-core.module'; +export * from './services/index'; +export * from './tokens/index'; diff --git a/integration-libs/opf/cta/core/services/index.ts b/integration-libs/opf/cta/core/services/index.ts new file mode 100644 index 00000000000..38980d13a1b --- /dev/null +++ b/integration-libs/opf/cta/core/services/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-dynamic-cta.service'; +export * from './opf-static-cta.service'; diff --git a/integration-libs/opf/cta/core/services/opf-dynamic-cta.service.spec.ts b/integration-libs/opf/cta/core/services/opf-dynamic-cta.service.spec.ts new file mode 100644 index 00000000000..62d3d450a06 --- /dev/null +++ b/integration-libs/opf/cta/core/services/opf-dynamic-cta.service.spec.ts @@ -0,0 +1,206 @@ +import { TestBed } from '@angular/core/testing'; +import { ActiveCartFacade, Cart } from '@spartacus/cart/base/root'; +import { + CurrencyService, + EventService, + LanguageService, + Product, + WindowRef, +} from '@spartacus/core'; +import { CtaScriptsLocation, OpfCtaFacade } from '@spartacus/opf/cta/root'; +import { OpfGlobalFunctionsFacade } from '@spartacus/opf/global-functions/root'; +import { CurrentProductService } from '@spartacus/storefront'; +import { of } from 'rxjs'; +import { OpfDynamicCtaService } from './opf-dynamic-cta.service'; + +describe('OpfDynamicCtaService', () => { + let service: OpfDynamicCtaService; + let globalFunctionsFacadeMock: jasmine.SpyObj; + let eventServiceMock: jasmine.SpyObj; + let currencyServiceMock: jasmine.SpyObj; + let languageServiceMock: jasmine.SpyObj; + let activeCartFacadeMock: jasmine.SpyObj; + let opfCtaFacadeMock: jasmine.SpyObj; + let currentProductServiceMock: jasmine.SpyObj; + + beforeEach(() => { + // Prevent external link navigation + window.onbeforeunload = function () { + return ''; + }; + globalFunctionsFacadeMock = jasmine.createSpyObj('GlobalFunctionsFacade', [ + 'registerGlobalFunctions', + 'removeGlobalFunctions', + ]); + + eventServiceMock = jasmine.createSpyObj('EventService', ['get']); + + currencyServiceMock = jasmine.createSpyObj('CurrencyService', [ + 'getActive', + ]); + languageServiceMock = jasmine.createSpyObj('LanguageService', [ + 'getActive', + ]); + opfCtaFacadeMock = jasmine.createSpyObj('OpfCtaFacade', [ + 'listenScriptReadyEvent', + ]); + currentProductServiceMock = jasmine.createSpyObj('CurrentProductService', [ + 'getProduct', + ]); + activeCartFacadeMock = jasmine.createSpyObj('ActiveCartFacade', [ + 'takeActive', + ]); + + TestBed.configureTestingModule({ + providers: [ + WindowRef, + OpfDynamicCtaService, + { + provide: OpfGlobalFunctionsFacade, + useValue: globalFunctionsFacadeMock, + }, + { provide: EventService, useValue: eventServiceMock }, + { provide: CurrencyService, useValue: currencyServiceMock }, + { provide: ActiveCartFacade, useValue: activeCartFacadeMock }, + { provide: LanguageService, useValue: languageServiceMock }, + { provide: OpfCtaFacade, useValue: opfCtaFacadeMock }, + { provide: CurrentProductService, useValue: currentProductServiceMock }, + ], + }); + service = TestBed.inject(OpfDynamicCtaService); + currencyServiceMock.getActive.and.returnValue(of(mockCurrency)); + languageServiceMock.getActive.and.returnValue(of(mockLanguage)); + eventServiceMock.get.and.returnValue(of(true)); + opfCtaFacadeMock.listenScriptReadyEvent.and.returnValue(of(mockScriptId)); + globalFunctionsFacadeMock.registerGlobalFunctions.and.returnValue(); + globalFunctionsFacadeMock.removeGlobalFunctions.and.returnValue(); + currentProductServiceMock.getProduct.and.returnValue(of(mockProduct)); + activeCartFacadeMock.takeActive.and.returnValue(of(mockCart)); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call activeCart on fillCtaRequestforCartPage', (done) => { + service + .fillCtaRequestforCartPage( + CtaScriptsLocation.CART_MESSAGING, + mockAccountIds + ) + .subscribe((ctaRequest) => { + expect(activeCartFacadeMock.takeActive).toHaveBeenCalled(); + expect(languageServiceMock.getActive).toHaveBeenCalled(); + expect(ctaRequest.scriptLocations).toEqual([ + CtaScriptsLocation.CART_MESSAGING, + ]); + done(); + }); + }); + + it('should call productService on fillCtaRequestforProductPage', (done) => { + service + .fillCtaRequestforProductPage( + CtaScriptsLocation.PDP_MESSAGING, + mockAccountIds + ) + .subscribe((ctaRequest) => { + expect(currentProductServiceMock.getProduct).toHaveBeenCalled(); + expect(languageServiceMock.getActive).toHaveBeenCalled(); + expect(ctaRequest.scriptLocations).toEqual([ + CtaScriptsLocation.PDP_MESSAGING, + ]); + expect( + ctaRequest.additionalData?.find( + (keyVal) => keyVal.key === 'scriptIdentifier' + )?.value + ).toEqual(mockScriptId); + done(); + }); + }); + + it('should start cartListener on cart page initiateEvents', (done) => { + service + .fillCtaRequestforCartPage( + CtaScriptsLocation.CART_MESSAGING, + mockAccountIds + ) + .subscribe(() => { + service.initiateEvents(); + expect(eventServiceMock.get).toHaveBeenCalled(); + done(); + }); + }); + + it('should not start cartListener on pdp initiateEvents', (done) => { + service + .fillCtaRequestforProductPage( + CtaScriptsLocation.PDP_MESSAGING, + mockAccountIds + ) + .subscribe(() => { + service.initiateEvents(); + expect(eventServiceMock.get).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should register ScriptReadyEvent global function', () => { + service.registerScriptReadyEvent(); + expect( + globalFunctionsFacadeMock.registerGlobalFunctions + ).toHaveBeenCalled(); + }); + + it('should remove global functions on stopEvents', (done) => { + service + .fillCtaRequestforProductPage( + CtaScriptsLocation.CART_MESSAGING, + mockAccountIds + ) + .subscribe(() => { + service.initiateEvents(); + service.stopEvents(); + expect( + globalFunctionsFacadeMock.removeGlobalFunctions + ).toHaveBeenCalled(); + + done(); + }); + }); + + const mockAccountIds = [51, 22]; + const mockScriptId = '0001'; + + const mockLanguage = 'en'; + const mockCurrency = 'UDS'; + + const mockProduct: Product = { + name: 'mockProduct', + code: 'code1', + stock: { + stockLevel: 333, + stockLevelStatus: 'inStock', + }, + }; + + const mockCart: Cart = { + code: 'xxx', + guid: 'xxx', + totalItems: 0, + entries: [ + { entryNumber: 0, product: { code: '1234' } }, + { entryNumber: 1, product: { code: '01234' } }, + { entryNumber: 2, product: { code: '3234' } }, + ], + totalPrice: { + currencyIso: 'USD', + value: 100, + }, + totalPriceWithTax: { + currencyIso: 'USD', + value: 0, + }, + user: { uid: 'test' }, + }; +}); diff --git a/integration-libs/opf/cta/core/services/opf-dynamic-cta.service.ts b/integration-libs/opf/cta/core/services/opf-dynamic-cta.service.ts new file mode 100644 index 00000000000..6a3b5e70ee6 --- /dev/null +++ b/integration-libs/opf/cta/core/services/opf-dynamic-cta.service.ts @@ -0,0 +1,251 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { inject, Injectable } from '@angular/core'; +import { + ActiveCartFacade, + Cart, + CartAddEntrySuccessEvent, + CartRemoveEntrySuccessEvent, + CartUpdateEntrySuccessEvent, +} from '@spartacus/cart/base/root'; +import { + CurrencyService, + EventService, + LanguageService, + Product, + WindowRef, +} from '@spartacus/core'; +import { + CtaEvent, + CtaProductItem, + CtaScriptsLocation, + CtaScriptsRequest, + OpfCtaFacade, +} from '@spartacus/opf/cta/root'; +import { OpfGlobalFunctionsFacade } from '@spartacus/opf/global-functions/root'; +import { + GlobalFunctionsDomain, + KeyValuePair, +} from '@spartacus/opf/payment/root'; +import { CurrentProductService } from '@spartacus/storefront'; +import { + combineLatest, + map, + merge, + Observable, + Subscription, + take, + tap, +} from 'rxjs'; + +@Injectable() +export class OpfDynamicCtaService { + protected globalFunctionsFacade = inject(OpfGlobalFunctionsFacade); + protected winRef = inject(WindowRef); + protected eventService = inject(EventService); + protected currencyService = inject(CurrencyService); + protected languageService = inject(LanguageService); + protected activeCartFacade = inject(ActiveCartFacade); + protected opfCtaFacade = inject(OpfCtaFacade); + protected currentProductService = inject(CurrentProductService); + + protected subList: Array = []; + protected isOnsiteMessagingInit = false; + protected scriptIdentifiers: Array = []; + protected isCartPage: boolean; + + fillCtaRequestforCartPage( + scriptLocation: CtaScriptsLocation, + paymentAccountIds: number[] + ) { + this.isCartPage = true; + return combineLatest({ + cart: this.activeCartFacade.takeActive(), + additionalData: this.fillAdditionalData(), + }).pipe( + take(1), + map(({ cart, additionalData }) => { + return { + paymentAccountIds: paymentAccountIds, + scriptLocations: [scriptLocation], + ctaProductItems: cart.entries?.map((entry) => { + return { + productId: entry.product?.code, + quantity: entry.quantity, + } as CtaProductItem; + }), + additionalData, + } as CtaScriptsRequest; + }) + ); + } + + fillCtaRequestforProductPage( + scriptLocation: CtaScriptsLocation, + paymentAccountIds: number[] + ) { + this.isCartPage = false; + return combineLatest({ + product: this.currentProductService.getProduct(), + additionalData: this.fillAdditionalData(), + }).pipe( + take(1), + map(({ product, additionalData }) => { + return { + ctaProductItems: [{ productId: product?.code, quantity: 1 }], + paymentAccountIds: paymentAccountIds, + scriptLocations: [scriptLocation], + additionalData, + } as CtaScriptsRequest; + }) + ); + } + + protected fillAdditionalData(): Observable> { + return combineLatest({ + language: this.languageService.getActive(), + currency: this.currencyService.getActive(), + }).pipe( + take(1), + map(({ language, currency }) => { + return [ + { + key: 'locale', + value: language, + }, + { + key: 'currency', + value: currency, + }, + { + key: 'scriptIdentifier', + value: this.getNewScriptIdentifier(), + }, + ]; + }) + ); + } + + initiateEvents() { + if (!this.isOnsiteMessagingInit) { + this.listenScriptReadyEvent(); + + this.isCartPage && this.cartChangedListener(); + !this.isCartPage && this.productChangedListener(); + + this.isOnsiteMessagingInit = true; + } + } + + stopEvents() { + if (this.isOnsiteMessagingInit) { + this.subList.forEach((sub) => sub.unsubscribe()); + this.subList = []; + this.globalFunctionsFacade.removeGlobalFunctions( + GlobalFunctionsDomain.GLOBAL + ); + this.isOnsiteMessagingInit = false; + } + } + + protected getNewScriptIdentifier(): string { + this.scriptIdentifiers.push(String(this.scriptIdentifiers.length + 1)); + return String( + this.scriptIdentifiers[this.scriptIdentifiers.length - 1] + ).padStart(4, '0'); + } + + registerScriptReadyEvent() { + this.globalFunctionsFacade.registerGlobalFunctions({ + paymentSessionId: '', + domain: GlobalFunctionsDomain.GLOBAL, + }); + } + + protected listenScriptReadyEvent() { + const sub = this.opfCtaFacade.listenScriptReadyEvent().subscribe({ + next: (id: string) => { + this.isCartPage && this.dispatchCartEvents([id]); + !this.isCartPage && this.dispatchProductEvents([id]); + }, + }); + this.subList.push(sub); + } + + protected dispatchCartEvents(scriptIdentifiers: Array): void { + this.activeCartFacade + .takeActive() + .pipe( + take(1), + map((cart: Cart) => { + return { total: cart.totalPrice?.value }; + }), + tap((cartTotalPrice) => { + const window = this.winRef.nativeWindow; + const dispatchEvent = window?.dispatchEvent; + if (dispatchEvent) { + dispatchEvent( + new CustomEvent(CtaEvent.OPF_CART_CHANGED, { + detail: { cart: cartTotalPrice, scriptIdentifiers }, + }) + ); + } + }) + ) + .subscribe(); + } + + protected dispatchProductEvents( + scriptIdentifiers: Array, + quantity = 1 + ): void { + this.currentProductService + .getProduct() + .pipe( + take(1), + map((product: Product | null) => { + return [ + { + price: { + sellingPrice: product?.price?.value, + }, + quantity, // hard-coded until 'pdp counter change' event gets implemented + }, + ]; + }), + tap((productInfo) => { + const window = this.winRef.nativeWindow as any; + const dispatchEvent = window?.dispatchEvent; + if (dispatchEvent) { + dispatchEvent( + new CustomEvent(CtaEvent.OPF_PRODUCT_AMOUNT_CHANGED, { + detail: { productInfo, scriptIdentifiers }, + }) + ); + } + }) + ) + .subscribe(); + } + + protected productChangedListener(): void { + //// need event listener detecting 'pdp counter changed' event and get its qty value. + } + + protected cartChangedListener(): void { + const sub = merge( + this.eventService.get(CartUpdateEntrySuccessEvent), + this.eventService.get(CartAddEntrySuccessEvent), + this.eventService.get(CartRemoveEntrySuccessEvent) + ).subscribe({ + next: () => { + this.dispatchCartEvents(this.scriptIdentifiers); + }, + }); + this.subList.push(sub); + } +} diff --git a/integration-libs/opf/cta/core/services/opf-static-cta.service.spec.ts b/integration-libs/opf/cta/core/services/opf-static-cta.service.spec.ts new file mode 100644 index 00000000000..e7a14e6b739 --- /dev/null +++ b/integration-libs/opf/cta/core/services/opf-static-cta.service.spec.ts @@ -0,0 +1,101 @@ +import { TestBed } from '@angular/core/testing'; +import { WindowRef } from '@spartacus/core'; +import { CtaScriptsLocation } from '@spartacus/opf/cta/root'; +import { Order, OrderFacade, OrderHistoryFacade } from '@spartacus/order/root'; +import { of } from 'rxjs'; +import { OpfStaticCtaService } from './opf-static-cta.service'; + +describe('OpfStaticCtaService', () => { + let service: OpfStaticCtaService; + let orderFacadeMock: jasmine.SpyObj; + let orderHistoryFacadeMock: jasmine.SpyObj; + + beforeEach(() => { + orderFacadeMock = jasmine.createSpyObj('OrderFacade', ['getOrderDetails']); + orderHistoryFacadeMock = jasmine.createSpyObj('OrderHistoryFacade', [ + 'getOrderDetails', + ]); + TestBed.configureTestingModule({ + providers: [ + WindowRef, + OpfStaticCtaService, + { provide: OrderFacade, useValue: orderFacadeMock }, + { provide: OrderHistoryFacade, useValue: orderHistoryFacadeMock }, + ], + }); + service = TestBed.inject(OpfStaticCtaService); + orderHistoryFacadeMock.getOrderDetails.and.returnValue(of(mockOrder)); + orderFacadeMock.getOrderDetails.and.returnValue(of(mockOrder)); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call OrderHistoryFacade for CTA on order history page', (done) => { + service + .fillCtaRequestforPagesWithOrder( + CtaScriptsLocation.ORDER_HISTORY_PAYMENT_GUIDE + ) + .subscribe((ctaScriptRequest) => { + expect(ctaScriptRequest.cartId).toContain('mockPaymentInfoId'); + expect(orderHistoryFacadeMock.getOrderDetails).toHaveBeenCalled(); + expect(orderFacadeMock.getOrderDetails).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should call orderFacade for CTA on confirmation page', (done) => { + service + .fillCtaRequestforPagesWithOrder( + CtaScriptsLocation.ORDER_CONFIRMATION_PAYMENT_GUIDE + ) + .subscribe((ctaScriptRequest) => { + expect(ctaScriptRequest.cartId).toContain('mockPaymentInfoId'); + expect(orderHistoryFacadeMock.getOrderDetails).not.toHaveBeenCalled(); + expect(orderFacadeMock.getOrderDetails).toHaveBeenCalled(); + done(); + }); + }); + + it('should throw error when no order Id', (done) => { + orderFacadeMock.getOrderDetails.and.returnValue( + of({ ...mockOrder, paymentInfo: undefined }) + ); + service + .fillCtaRequestforPagesWithOrder( + CtaScriptsLocation.ORDER_CONFIRMATION_PAYMENT_GUIDE + ) + .subscribe({ + next: () => { + fail(); + done(); + }, + error: (error) => { + expect(error).toBeTruthy(); + done(); + }, + }); + }); + + const mockOrder: Order = { + code: 'mockOrder', + paymentInfo: { + id: 'mockPaymentInfoId', + }, + entries: [ + { + product: { + code: '11', + }, + quantity: 1, + }, + { + product: { + code: '22', + }, + quantity: 1, + }, + ], + }; +}); diff --git a/integration-libs/opf/cta/core/services/opf-static-cta.service.ts b/integration-libs/opf/cta/core/services/opf-static-cta.service.ts new file mode 100644 index 00000000000..245b3f8ebfa --- /dev/null +++ b/integration-libs/opf/cta/core/services/opf-static-cta.service.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { inject, Injectable } from '@angular/core'; +import { OrderEntry } from '@spartacus/cart/base/root'; +import { CtaScriptsLocation, CtaScriptsRequest } from '@spartacus/opf/cta/root'; +import { Order, OrderFacade, OrderHistoryFacade } from '@spartacus/order/root'; +import { filter, map, Observable } from 'rxjs'; + +@Injectable() +export class OpfStaticCtaService { + protected orderDetailsService = inject(OrderFacade); + protected orderHistoryService = inject(OrderHistoryFacade); + + fillCtaRequestforPagesWithOrder( + scriptLocation: CtaScriptsLocation + ): Observable { + return this.getOrderDetails(scriptLocation).pipe( + map((order) => { + if (!order?.paymentInfo?.id) { + throw new Error('OrderPaymentInfoId missing'); + } + const ctaScriptsRequest: CtaScriptsRequest = { + cartId: order?.paymentInfo?.id, + ctaProductItems: this.getProductItems(order as Order), + scriptLocations: [scriptLocation], + }; + + return ctaScriptsRequest; + }) + ); + } + + protected getOrderDetails( + scriptsLocation: CtaScriptsLocation + ): Observable { + const order$ = + scriptsLocation === CtaScriptsLocation.ORDER_CONFIRMATION_PAYMENT_GUIDE + ? this.orderDetailsService.getOrderDetails() + : this.orderHistoryService.getOrderDetails(); + return order$.pipe( + filter((order) => !!order?.entries) + ) as Observable; + } + + protected getProductItems( + order: Order + ): { productId: string; quantity: number }[] | [] { + return (order.entries as OrderEntry[]) + .filter((item) => { + return !!item?.product?.code && !!item?.quantity; + }) + .map((item) => { + return { + productId: item.product?.code as string, + quantity: item.quantity as number, + }; + }); + } +} diff --git a/integration-libs/opf/cta/core/tokens/index.ts b/integration-libs/opf/cta/core/tokens/index.ts new file mode 100644 index 00000000000..62a40ab0d42 --- /dev/null +++ b/integration-libs/opf/cta/core/tokens/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './tokens'; diff --git a/integration-libs/opf/cta/core/tokens/tokens.ts b/integration-libs/opf/cta/core/tokens/tokens.ts new file mode 100644 index 00000000000..9ec3d7f7e5f --- /dev/null +++ b/integration-libs/opf/cta/core/tokens/tokens.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InjectionToken } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { CtaScriptsResponse } from '@spartacus/opf/cta/root'; + +export const OPF_CTA_SCRIPTS_NORMALIZER = new InjectionToken< + Converter +>('OpfCtaScriptsNormalizer'); diff --git a/integration-libs/opf/cta/ng-package.json b/integration-libs/opf/cta/ng-package.json new file mode 100644 index 00000000000..38e01ac17de --- /dev/null +++ b/integration-libs/opf/cta/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/cta/opf-api/adapters/index.ts b/integration-libs/opf/cta/opf-api/adapters/index.ts new file mode 100644 index 00000000000..69a3b6b3ddd --- /dev/null +++ b/integration-libs/opf/cta/opf-api/adapters/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-api-cta.adapter'; diff --git a/integration-libs/opf/cta/opf-api/adapters/opf-api-cta.adapter.spec.ts b/integration-libs/opf/cta/opf-api/adapters/opf-api-cta.adapter.spec.ts new file mode 100644 index 00000000000..432ca5c5bbc --- /dev/null +++ b/integration-libs/opf/cta/opf-api/adapters/opf-api-cta.adapter.spec.ts @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ConverterService, LoggerService } from '@spartacus/core'; +import { OpfApiCtaAdapter } from './opf-api-cta.adapter'; +import { OpfEndpointsService } from '@spartacus/opf/base/core'; +import { + OPF_CC_PUBLIC_KEY_HEADER, + OpfConfig, + OpfDynamicScriptResourceType, +} from '@spartacus/opf/base/root'; +import { CtaScriptsRequest, CtaScriptsResponse } from '@spartacus/opf/cta/root'; +import { Observable, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +const mockCtaScriptsRequest: CtaScriptsRequest = { + paymentAccountIds: [123], + cartId: 'mockCartId', + additionalData: [ + { key: 'divisionId', value: 'mockDivision' }, + { key: 'currency', value: 'USD' }, + ], +}; + +const mockCtaScriptsResponse: CtaScriptsResponse = { + value: [ + { + paymentAccountId: 123, + dynamicScript: { + html: '', + jsUrls: [ + { + url: 'script.js', + type: OpfDynamicScriptResourceType.SCRIPT, + }, + ], + cssUrls: [ + { + url: 'styles.css', + type: OpfDynamicScriptResourceType.STYLES, + }, + ], + }, + }, + ], +}; + +const mockOpfConfig: OpfConfig = { + opf: { + commerceCloudPublicKey: 'mockPublicKey', + }, +}; + +describe('OpfApiCtaAdapter', () => { + let adapter: OpfApiCtaAdapter; + let httpMock: HttpTestingController; + let converterService: jasmine.SpyObj; + let opfEndpointsService: jasmine.SpyObj; + + beforeEach(() => { + const converterSpy = jasmine.createSpyObj('ConverterService', ['pipeable']); + const opfEndpointsSpy = jasmine.createSpyObj('OpfEndpointsService', [ + 'buildUrl', + ]); + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + OpfApiCtaAdapter, + { provide: OpfConfig, useValue: mockOpfConfig }, + { provide: ConverterService, useValue: converterSpy }, + { provide: OpfEndpointsService, useValue: opfEndpointsSpy }, + LoggerService, + ], + }); + + adapter = TestBed.inject(OpfApiCtaAdapter); + httpMock = TestBed.inject(HttpTestingController); + converterService = TestBed.inject( + ConverterService + ) as jasmine.SpyObj; + opfEndpointsService = TestBed.inject( + OpfEndpointsService + ) as jasmine.SpyObj; + + opfEndpointsService.buildUrl.and.returnValue('mockUrl'); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should fetch CTA scripts successfully', () => { + converterService.pipeable.and.returnValue( + (input$: Observable) => input$ + ); + + adapter.getCtaScripts(mockCtaScriptsRequest).subscribe((response) => { + expect(response).toEqual(mockCtaScriptsResponse); + }); + + const req = httpMock.expectOne('mockUrl'); + expect(req.request.method).toBe('POST'); + expect(req.request.headers.get(OPF_CC_PUBLIC_KEY_HEADER)).toBe( + 'mockPublicKey' + ); + expect(req.request.headers.get('Accept-Language')).toBe('en-us'); + expect(req.request.body).toEqual(mockCtaScriptsRequest); + + req.flush(mockCtaScriptsResponse); + }); + + it('should handle server error and normalize the error', () => { + const mockErrorResponse = { + status: 500, + statusText: 'Internal Server Error', + }; + + converterService.pipeable.and.returnValue( + (input$: Observable) => input$ + ); + + adapter + .getCtaScripts(mockCtaScriptsRequest) + .pipe( + catchError((error) => { + expect(error).toEqual(jasmine.any(Error)); + return of(null); + }) + ) + .subscribe(); + + const req = httpMock.expectOne('mockUrl'); + req.flush(null, mockErrorResponse); + }); +}); diff --git a/integration-libs/opf/cta/opf-api/adapters/opf-api-cta.adapter.ts b/integration-libs/opf/cta/opf-api/adapters/opf-api-cta.adapter.ts new file mode 100644 index 00000000000..3122615c825 --- /dev/null +++ b/integration-libs/opf/cta/opf-api/adapters/opf-api-cta.adapter.ts @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { + ConverterService, + LoggerService, + backOff, + isServerError, + tryNormalizeHttpError, +} from '@spartacus/core'; +import { OpfEndpointsService } from '@spartacus/opf/base/core'; +import { OPF_CC_PUBLIC_KEY_HEADER, OpfConfig } from '@spartacus/opf/base/root'; +import { + OPF_CTA_SCRIPTS_NORMALIZER, + OpfCtaAdapter, +} from '@spartacus/opf/cta/core'; +import { CtaScriptsRequest, CtaScriptsResponse } from '@spartacus/opf/cta/root'; +import { SubmitResponse } from '@spartacus/opf/payment/root'; +import { Observable } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +@Injectable() +export class OpfApiCtaAdapter implements OpfCtaAdapter { + protected logger = inject(LoggerService); + + protected headerWithNoLanguage: { [name: string]: string } = { + accept: 'application/json', + 'Content-Type': 'application/json', + }; + protected header: { [name: string]: string } = { + ...this.headerWithNoLanguage, + 'Accept-Language': 'en-us', + }; + + protected headerWithContentLanguage: { [name: string]: string } = { + ...this.headerWithNoLanguage, + 'Content-Language': 'en-us', + }; + + constructor( + protected http: HttpClient, + protected converter: ConverterService, + protected opfEndpointsService: OpfEndpointsService, + protected config: OpfConfig + ) {} + + getCtaScripts( + ctaScriptsRequest: CtaScriptsRequest + ): Observable { + const headers = new HttpHeaders(this.header).set( + OPF_CC_PUBLIC_KEY_HEADER, + this.config.opf?.commerceCloudPublicKey || '' + ); + + const url = this.getCtaScriptsEndpoint(); + + return this.http + .post(url, ctaScriptsRequest, { headers }) + .pipe( + catchError((error) => { + throw tryNormalizeHttpError(error, this.logger); + }), + backOff({ + shouldRetry: isServerError, + maxTries: 2, + }), + this.converter.pipeable(OPF_CTA_SCRIPTS_NORMALIZER) + ); + } + + protected getCtaScriptsEndpoint(): string { + return this.opfEndpointsService.buildUrl('getCtaScripts'); + } +} diff --git a/integration-libs/opf/cta/opf-api/config/default-opf-api-cta-config.ts b/integration-libs/opf/cta/opf-api/config/default-opf-api-cta-config.ts new file mode 100644 index 00000000000..610dd3b7471 --- /dev/null +++ b/integration-libs/opf/cta/opf-api/config/default-opf-api-cta-config.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpfApiConfig } from '@spartacus/opf/base/root'; + +export const defaultOpfApiCtaConfig: OpfApiConfig = { + backend: { + opfApi: { + endpoints: { + getCtaScripts: 'payments/cta-scripts-rendering', + }, + }, + }, +}; diff --git a/integration-libs/opf/cta/opf-api/model/index.ts b/integration-libs/opf/cta/opf-api/model/index.ts new file mode 100644 index 00000000000..56500e9e6a4 --- /dev/null +++ b/integration-libs/opf/cta/opf-api/model/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-api-cta-endpoints.model'; diff --git a/integration-libs/opf/cta/opf-api/model/opf-api-cta-endpoints.model.ts b/integration-libs/opf/cta/opf-api/model/opf-api-cta-endpoints.model.ts new file mode 100644 index 00000000000..4572db567b3 --- /dev/null +++ b/integration-libs/opf/cta/opf-api/model/opf-api-cta-endpoints.model.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpfApiEndpoint } from '@spartacus/opf/base/root'; + +declare module '@spartacus/opf/base/root' { + interface OpfApiEndpoints { + /** + * Endpoint to get CTA (Call To Action) Scripts + */ + getCtaScripts?: string | OpfApiEndpoint; + } +} diff --git a/integration-libs/opf/cta/opf-api/ng-package.json b/integration-libs/opf/cta/opf-api/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/cta/opf-api/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/cta/opf-api/opf-api-cta.module.ts b/integration-libs/opf/cta/opf-api/opf-api-cta.module.ts new file mode 100644 index 00000000000..a8061d0a34e --- /dev/null +++ b/integration-libs/opf/cta/opf-api/opf-api-cta.module.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { provideDefaultConfig } from '@spartacus/core'; +import { OpfCtaAdapter } from '@spartacus/opf/cta/core'; +import { OpfApiCtaAdapter } from './adapters'; +import { defaultOpfApiCtaConfig } from './config/default-opf-api-cta-config'; + +@NgModule({ + imports: [CommonModule], + providers: [ + provideDefaultConfig(defaultOpfApiCtaConfig), + { + provide: OpfCtaAdapter, + useClass: OpfApiCtaAdapter, + }, + ], +}) +export class OpfApiCtaModule {} diff --git a/integration-libs/opf/cta/opf-api/public_api.ts b/integration-libs/opf/cta/opf-api/public_api.ts new file mode 100644 index 00000000000..ac9f1352305 --- /dev/null +++ b/integration-libs/opf/cta/opf-api/public_api.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './model/index'; +export * from './opf-api-cta.module'; diff --git a/integration-libs/opf/cta/opf-cta.module.ts b/integration-libs/opf/cta/opf-cta.module.ts new file mode 100644 index 00000000000..ec6d8c52f08 --- /dev/null +++ b/integration-libs/opf/cta/opf-cta.module.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfCtaComponentsModule } from '@spartacus/opf/cta/components'; +import { OpfCtaCoreModule } from '@spartacus/opf/cta/core'; +import { OpfApiCtaModule } from '@spartacus/opf/cta/opf-api'; + +@NgModule({ + imports: [OpfCtaCoreModule, OpfApiCtaModule, OpfCtaComponentsModule], +}) +export class OpfCtaModule {} diff --git a/integration-libs/opf/cta/public_api.ts b/integration-libs/opf/cta/public_api.ts new file mode 100644 index 00000000000..4529600d9e8 --- /dev/null +++ b/integration-libs/opf/cta/public_api.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-cta.module'; diff --git a/integration-libs/opf/cta/root/facade/index.ts b/integration-libs/opf/cta/root/facade/index.ts new file mode 100644 index 00000000000..04238b758c5 --- /dev/null +++ b/integration-libs/opf/cta/root/facade/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-cta.facade'; diff --git a/integration-libs/opf/cta/root/facade/opf-cta.facade.ts b/integration-libs/opf/cta/root/facade/opf-cta.facade.ts new file mode 100644 index 00000000000..d1c57133e53 --- /dev/null +++ b/integration-libs/opf/cta/root/facade/opf-cta.facade.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { facadeFactory } from '@spartacus/core'; +import { Observable } from 'rxjs'; +import { OPF_CTA_FEATURE } from '../feature-name'; +import { CtaScriptsRequest, CtaScriptsResponse } from '../model'; + +@Injectable({ + providedIn: 'root', + useFactory: () => + facadeFactory({ + facade: OpfCtaFacade, + feature: OPF_CTA_FEATURE, + methods: [ + 'getCtaScripts', + 'listenScriptReadyEvent', + 'emitScriptReadyEvent', + ], + }), +}) +export abstract class OpfCtaFacade { + /** + * Get call-to-action scripts + */ + abstract getCtaScripts( + ctaScriptsRequest: CtaScriptsRequest + ): Observable; + abstract listenScriptReadyEvent(): Observable; + abstract emitScriptReadyEvent(scriptIdentifier: string): void; +} diff --git a/integration-libs/opf/cta/root/feature-name.ts b/integration-libs/opf/cta/root/feature-name.ts new file mode 100644 index 00000000000..1144451b547 --- /dev/null +++ b/integration-libs/opf/cta/root/feature-name.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export const OPF_CTA_FEATURE = 'opfCta'; diff --git a/integration-libs/opf/cta/root/model/index.ts b/integration-libs/opf/cta/root/model/index.ts new file mode 100644 index 00000000000..ed178e55e9a --- /dev/null +++ b/integration-libs/opf/cta/root/model/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-cta.model'; diff --git a/integration-libs/opf/cta/root/model/opf-cta.model.ts b/integration-libs/opf/cta/root/model/opf-cta.model.ts new file mode 100644 index 00000000000..02b672b196e --- /dev/null +++ b/integration-libs/opf/cta/root/model/opf-cta.model.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpfDynamicScript } from '@spartacus/opf/base/root'; + +export type CtaAdditionalDataKey = + | 'divisionId' + | 'experienceId' + | 'currency' + | 'fulfillmentLocationId' + | 'locale' + | 'scriptIdentifier'; +export interface CtaScriptsRequest { + paymentAccountIds?: Array; + cartId?: string; + ctaProductItems?: Array; + scriptLocations?: Array; + additionalData?: Array<{ + key: CtaAdditionalDataKey; + value: string; + }>; +} + +export interface CtaProductItem { + productId: string; + quantity: number; + fulfillmentLocationId?: string; +} + +export enum CtaScriptsLocation { + CART_MESSAGING = 'CART_MESSAGING', + PDP_MESSAGING = 'PDP_MESSAGING', + + ORDER_CONFIRMATION_PAYMENT_GUIDE = 'ORDER_CONFIRMATION_PAYMENT_GUIDE', + ORDER_HISTORY_PAYMENT_GUIDE = 'ORDER_HISTORY_PAYMENT_GUIDE', +} + +export const DynamicCtaLocations: Array = [ + CtaScriptsLocation.CART_MESSAGING, + CtaScriptsLocation.PDP_MESSAGING, +]; + +export enum CmsPageLocation { + ORDER_CONFIRMATION_PAGE = 'orderConfirmationPage', + ORDER_PAGE = 'order', + PDP_PAGE = 'productDetails', + CART_PAGE = 'cartPage', +} + +export interface CtaScriptsResponse { + value: Array; +} + +export interface CtaScript { + paymentAccountId: number; + dynamicScript: OpfDynamicScript; +} + +export enum CtaEvent { + OPF_CART_CHANGED = 'opfCartChanged', + OPF_PRODUCT_AMOUNT_CHANGED = 'opfProductAmountChanged', +} diff --git a/integration-libs/opf/cta/root/ng-package.json b/integration-libs/opf/cta/root/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/cta/root/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/cta/root/opf-cta-root.module.ts b/integration-libs/opf/cta/root/opf-cta-root.module.ts new file mode 100644 index 00000000000..8717a3917ae --- /dev/null +++ b/integration-libs/opf/cta/root/opf-cta-root.module.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { CmsConfig, provideDefaultConfigFactory } from '@spartacus/core'; +import { OPF_CTA_FEATURE } from './feature-name'; + +export function defaultOpfCtaCmsComponentsConfig(): CmsConfig { + const config: CmsConfig = { + featureModules: { + [OPF_CTA_FEATURE]: { + cmsComponents: ['OpfCtaScriptsComponent'], + }, + }, + }; + return config; +} + +@NgModule({ + providers: [provideDefaultConfigFactory(defaultOpfCtaCmsComponentsConfig)], +}) +export class OpfCtaRootModule {} diff --git a/integration-libs/opf/cta/root/public_api.ts b/integration-libs/opf/cta/root/public_api.ts new file mode 100644 index 00000000000..7512b1bfc87 --- /dev/null +++ b/integration-libs/opf/cta/root/public_api.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './facade/index'; +export * from './feature-name'; +export * from './model/index'; +export * from './opf-cta-root.module'; diff --git a/integration-libs/opf/cta/styles/_index.scss b/integration-libs/opf/cta/styles/_index.scss new file mode 100644 index 00000000000..192091fb04e --- /dev/null +++ b/integration-libs/opf/cta/styles/_index.scss @@ -0,0 +1 @@ +@import './components/index'; diff --git a/integration-libs/opf/cta/styles/components/_index.scss b/integration-libs/opf/cta/styles/components/_index.scss new file mode 100644 index 00000000000..7f832e56e6d --- /dev/null +++ b/integration-libs/opf/cta/styles/components/_index.scss @@ -0,0 +1 @@ +@import './opf-cta-element'; diff --git a/integration-libs/opf/cta/styles/components/_opf-cta-element.scss b/integration-libs/opf/cta/styles/components/_opf-cta-element.scss new file mode 100644 index 00000000000..bf6c20ed03d --- /dev/null +++ b/integration-libs/opf/cta/styles/components/_opf-cta-element.scss @@ -0,0 +1,4 @@ +%cx-opf-cta-element { + display: block; + margin: 0.5rem 0 0.5rem 0; +} diff --git a/integration-libs/opf/global-functions/core/facade/facade-providers.ts b/integration-libs/opf/global-functions/core/facade/facade-providers.ts new file mode 100644 index 00000000000..c4852f1c55a --- /dev/null +++ b/integration-libs/opf/global-functions/core/facade/facade-providers.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Provider } from '@angular/core'; +import { OpfGlobalFunctionsFacade } from '@spartacus/opf/global-functions/root'; +import { OpfGlobalFunctionsService } from './opf-global-functions.service'; + +export const facadeProviders: Provider[] = [ + OpfGlobalFunctionsService, + { + provide: OpfGlobalFunctionsFacade, + useExisting: OpfGlobalFunctionsService, + }, +]; diff --git a/integration-libs/opf/global-functions/core/facade/index.ts b/integration-libs/opf/global-functions/core/facade/index.ts new file mode 100644 index 00000000000..06bc4217c26 --- /dev/null +++ b/integration-libs/opf/global-functions/core/facade/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-global-functions.service'; diff --git a/integration-libs/opf/global-functions/core/facade/opf-global-functions.service.spec.ts b/integration-libs/opf/global-functions/core/facade/opf-global-functions.service.spec.ts new file mode 100644 index 00000000000..34834eba3e6 --- /dev/null +++ b/integration-libs/opf/global-functions/core/facade/opf-global-functions.service.spec.ts @@ -0,0 +1,301 @@ +/* + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Component, + ComponentRef, + ElementRef, + InjectionToken, + ViewContainerRef, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { WindowRef } from '@spartacus/core'; +import { defaultErrorDialogOptions } from '@spartacus/opf/base/root'; +import { GlobalFunctionsDomain } from '@spartacus/opf/global-functions/root'; +import { OpfPaymentFacade } from '@spartacus/opf/payment/root'; +import { OpfProviderType } from '@spartacus/opf/quick-buy/root'; +import { LAUNCH_CALLER, LaunchDialogService } from '@spartacus/storefront'; +import { EMPTY, Observable, of } from 'rxjs'; +import { OpfGlobalFunctionsService } from './opf-global-functions.service'; +export const WINDOW = new InjectionToken('window'); +@Component({ + template: '', +}) +class TestContainerComponent { + constructor(public vcr: ViewContainerRef) {} +} +class MockLaunchDialogService implements Partial { + closeDialog(_reason: any) {} + openDialogAndSubscribe() { + return EMPTY; + } + launch() {} + clear() {} + openDialog( + _caller: LAUNCH_CALLER, + _openElement?: ElementRef, + _vcr?: ViewContainerRef + ) { + return EMPTY; + } +} + +describe('OpfGlobalFunctionsService', () => { + let service: OpfGlobalFunctionsService; + let opfPaymentFacadeMock: jasmine.SpyObj; + let windowRef: WindowRef; + opfPaymentFacadeMock = jasmine.createSpyObj('OpfPaymentFacade', [ + 'submitPayment', + 'submitCompletePayment', + 'getActiveConfigurationsState', + ]); + let componentRef: ComponentRef; + let launchDialogService: LaunchDialogService; + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [TestContainerComponent], + providers: [ + OpfGlobalFunctionsService, + WindowRef, + { provide: OpfPaymentFacade, useValue: opfPaymentFacadeMock }, + { provide: LaunchDialogService, useClass: MockLaunchDialogService }, + ], + }); + service = TestBed.inject(OpfGlobalFunctionsService); + windowRef = TestBed.inject(WindowRef); + componentRef = TestBed.createComponent(TestContainerComponent).componentRef; + launchDialogService = TestBed.inject(LaunchDialogService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('Global Functions in SSR', () => { + const mockPaymentSessionId = 'mockSessionId'; + let windowOpf: any; + + it('should not register global functions for CHECKOUT in SSR', () => { + spyOn(service, 'registerSubmit').and.callThrough(); + spyOn(windowRef, 'isBrowser').and.returnValue(false); + service.registerGlobalFunctions({ + domain: GlobalFunctionsDomain.CHECKOUT, + paymentSessionId: mockPaymentSessionId, + vcr: {} as ViewContainerRef, + }); + expect(service['registerSubmit']).not.toHaveBeenCalled(); + }); + + it('should not remove global functions for CHECKOUT in SSR', () => { + service.registerGlobalFunctions({ + domain: GlobalFunctionsDomain.CHECKOUT, + paymentSessionId: mockPaymentSessionId, + vcr: {} as ViewContainerRef, + }); + windowOpf = windowRef.nativeWindow['Opf']; + spyOn(windowRef, 'isBrowser').and.returnValue(false); + service.removeGlobalFunctions(GlobalFunctionsDomain.CHECKOUT); + expect(windowOpf['payments']['checkout']['submit']).toBeDefined(); + }); + }); + + describe('should register global functions for CHECKOUT domain', () => { + const mockPaymentSessionId = 'mockSessionId'; + let windowOpf: any; + + beforeEach(() => { + service.registerGlobalFunctions({ + domain: GlobalFunctionsDomain.CHECKOUT, + paymentSessionId: mockPaymentSessionId, + vcr: {} as ViewContainerRef, + }); + windowOpf = windowRef.nativeWindow['Opf']; + }); + + it('should register global functions for CHECKOUT', () => { + expect(windowOpf['payments']['checkout']['submit']).toBeDefined(); + expect( + windowOpf['payments']['checkout']['throwPaymentError'] + ).toBeDefined(); + expect( + windowOpf['payments']['checkout']['startLoadIndicator'] + ).toBeDefined(); + expect( + windowOpf['payments']['checkout']['stopLoadIndicator'] + ).toBeDefined(); + }); + + it('should handle registerSubmit event', () => { + opfPaymentFacadeMock.submitPayment.and.returnValue(of(true)); + spyOn(launchDialogService, 'launch').and.returnValue(of(componentRef)); + spyOn(launchDialogService, 'clear').and.callThrough(); + + const submitSuccess = (): void => {}; + const submitPending = (): void => {}; + const submitFailure = (): void => {}; + const additionalData = [ + { key: 'returnUrl', value: 'https://returnUrl/' }, + { key: 'allow3DS2', value: 'true' }, + { key: 'originUrl', value: 'https://originUrl/' }, + ]; + const cartId = 'mock-cart'; + + windowOpf.payments['checkout'].submit({ + cartId, + additionalData, + submitSuccess, + submitPending, + submitFailure, + paymentMethod: OpfProviderType.APPLE_PAY, + }); + expect(opfPaymentFacadeMock.submitPayment).toHaveBeenCalled(); + }); + + it('should handle registerSubmitComplete event', () => { + opfPaymentFacadeMock.submitCompletePayment.and.returnValue(of(true)); + spyOn(launchDialogService, 'launch').and.returnValue(of(componentRef)); + spyOn(launchDialogService, 'clear').and.callThrough(); + + const submitSuccess = (): void => {}; + const submitPending = (): void => {}; + const submitFailure = (): void => {}; + const additionalData = [ + { key: 'returnUrl', value: 'https://returnUrl/' }, + { key: 'allow3DS2', value: 'true' }, + { key: 'originUrl', value: 'https://originUrl/' }, + ]; + const cartId = 'mock-cart'; + + windowOpf.payments['checkout'].submitComplete({ + cartId, + additionalData, + submitSuccess, + submitPending, + submitFailure, + paymentMethod: OpfProviderType.APPLE_PAY, + }); + expect(opfPaymentFacadeMock.submitCompletePayment).toHaveBeenCalled(); + }); + + it('should handle throwPaymentError event', () => { + opfPaymentFacadeMock.submitCompletePayment.and.returnValue(of(true)); + spyOn(launchDialogService, 'launch').and.returnValue(of(componentRef)); + + const dialog$: Observable = of(1); + const dialogSubscribeSpy = spyOn(dialog$, 'subscribe'); + spyOn(launchDialogService, 'openDialog').and.returnValue(dialog$); + + windowOpf.payments['checkout'].throwPaymentError( + defaultErrorDialogOptions + ); + expect(launchDialogService.openDialog).toHaveBeenCalled(); + expect(dialogSubscribeSpy).toHaveBeenCalled(); + }); + + it('should handle startLoadIndicator event', () => { + opfPaymentFacadeMock.submitCompletePayment.and.returnValue(of(true)); + spyOn(launchDialogService, 'launch').and.returnValue(of(componentRef)); + spyOn(launchDialogService, 'clear').and.callThrough(); + + windowOpf.payments['checkout'].startLoadIndicator(); + expect(launchDialogService.launch).toHaveBeenCalled(); + + windowOpf.payments['checkout'].startLoadIndicator(); + expect(launchDialogService.clear).toHaveBeenCalled(); + }); + + it('should handle stopLoadIndicator event', () => { + spyOn(launchDialogService, 'launch').and.returnValue(of(componentRef)); + spyOn(launchDialogService, 'clear').and.callThrough(); + + windowOpf.payments['checkout'].startLoadIndicator(); + windowOpf.payments['checkout'].stopLoadIndicator(); + expect(launchDialogService.clear).toHaveBeenCalled(); + }); + + it('should remove global function for REDIRECT', () => { + expect( + windowOpf['payments'][GlobalFunctionsDomain.CHECKOUT] + ).toBeDefined(); + + service.removeGlobalFunctions(GlobalFunctionsDomain.CHECKOUT); + + expect( + windowOpf['payments'][GlobalFunctionsDomain.CHECKOUT] + ).not.toBeDefined(); + }); + }); + + describe('should register global functions for REDIRECT domain', () => { + const mockPaymentSessionId = 'mockSessionId'; + const paramsMap = [ + { key: 'key1', value: 'value1' }, + { key: 'key2', value: 'value2' }, + ]; + let windowOpf: any; + beforeEach(() => { + service.registerGlobalFunctions({ + domain: GlobalFunctionsDomain.REDIRECT, + paymentSessionId: mockPaymentSessionId, + vcr: {} as ViewContainerRef, + paramsMap, + }); + windowOpf = windowRef.nativeWindow['Opf']; + }); + + it('should handle submitCompleteRedirect event', () => { + opfPaymentFacadeMock.submitCompletePayment.and.returnValue(of(true)); + + spyOn(launchDialogService, 'launch').and.returnValue(of(componentRef)); + spyOn(launchDialogService, 'clear').and.callThrough(); + + const submitSuccess = (): void => {}; + const submitPending = (): void => {}; + const submitFailure = (): void => {}; + const additionalData = [ + { key: 'returnUrl', value: 'https://returnUrl/' }, + { key: 'allow3DS2', value: 'true' }, + { key: 'originUrl', value: 'https://originUrl/' }, + ]; + const cartId = 'mock-cart'; + + windowOpf.payments[GlobalFunctionsDomain.REDIRECT].submitCompleteRedirect( + { + cartId, + additionalData, + submitSuccess, + submitPending, + submitFailure, + } + ); + expect(opfPaymentFacadeMock.submitCompletePayment).toHaveBeenCalled(); + }); + + it('should handle getRedirectParams event', () => { + const redirectParams = + windowOpf.payments[GlobalFunctionsDomain.REDIRECT].getRedirectParams(); + expect(redirectParams).toEqual(paramsMap); + }); + + it('should remove global function for REDIRECT', () => { + expect( + windowOpf['payments'][GlobalFunctionsDomain.REDIRECT][ + 'submitCompleteRedirect' + ] + ).toBeDefined(); + expect( + windowOpf['payments'][GlobalFunctionsDomain.REDIRECT][ + 'getRedirectParams' + ] + ).toBeDefined(); + + service.removeGlobalFunctions(GlobalFunctionsDomain.REDIRECT); + + expect( + windowOpf['payments'][GlobalFunctionsDomain.REDIRECT] + ).not.toBeDefined(); + }); + }); +}); diff --git a/integration-libs/opf/global-functions/core/facade/opf-global-functions.service.ts b/integration-libs/opf/global-functions/core/facade/opf-global-functions.service.ts new file mode 100644 index 00000000000..369b31a5a61 --- /dev/null +++ b/integration-libs/opf/global-functions/core/facade/opf-global-functions.service.ts @@ -0,0 +1,360 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ComponentRef, + Injectable, + NgZone, + ViewContainerRef, + inject, +} from '@angular/core'; +import { WindowRef } from '@spartacus/core'; +import { + ErrorDialogOptions, + defaultErrorDialogOptions, +} from '@spartacus/opf/base/root'; +import { OpfCtaFacade } from '@spartacus/opf/cta/root'; +import { + GlobalFunctionsDomain, + GlobalFunctionsInput, + OpfGlobalFunctionsFacade, +} from '@spartacus/opf/global-functions/root'; +import { + GlobalOpfPaymentMethods, + KeyValuePair, + MerchantCallback, + OpfPage, + OpfPaymentFacade, + PaymentMethod, +} from '@spartacus/opf/payment/root'; +import { LAUNCH_CALLER, LaunchDialogService } from '@spartacus/storefront'; +import { Observable, Subject, lastValueFrom } from 'rxjs'; +import { finalize, take } from 'rxjs/operators'; + +@Injectable() +export class OpfGlobalFunctionsService implements OpfGlobalFunctionsFacade { + protected winRef = inject(WindowRef); + protected ngZone = inject(NgZone); + protected opfPaymentFacade = inject(OpfPaymentFacade); + protected launchDialogService = inject(LaunchDialogService); + protected opfCtaFacade = inject(OpfCtaFacade); + protected loaderSpinnerCpntRef: void | Observable< + ComponentRef | undefined + >; + protected _readyForScriptEvent: Subject = new Subject(); + readyForScriptEvent$: Observable = + this._readyForScriptEvent.asObservable(); + + registerGlobalFunctions({ + domain, + paymentSessionId, + vcr, + paramsMap, + }: GlobalFunctionsInput): void { + // SSR not supported + if (!this.winRef.isBrowser()) { + return; + } + switch (domain) { + case GlobalFunctionsDomain.CHECKOUT: + this.registerSubmit(domain, paymentSessionId, vcr); + this.registerSubmitComplete(domain, paymentSessionId, vcr); + this.registerThrowPaymentError(domain, vcr); + this.registerStartLoadIndicator(domain, vcr); + this.registerStopLoadIndicator(domain); + break; + case GlobalFunctionsDomain.REDIRECT: + this.registerSubmitCompleteRedirect(domain, paymentSessionId, vcr); + this.registerGetRedirectParams(domain, paramsMap ?? []); + break; + case GlobalFunctionsDomain.GLOBAL: + this.registerCtaScriptReady(domain); + break; + default: + break; + } + } + + removeGlobalFunctions(domain: GlobalFunctionsDomain): void { + // SSR not supported + if (!this.winRef.isBrowser()) { + return; + } + const window = this.winRef.nativeWindow as any; + if (window?.Opf?.payments[domain]) { + window.Opf.payments[domain] = undefined; + } + } + + protected getGlobalFunctionContainer( + domain: GlobalFunctionsDomain + ): GlobalOpfPaymentMethods { + const window = this.winRef.nativeWindow as any; + if (!window.Opf?.payments[domain]) { + window.Opf = window?.Opf ?? {}; + window.Opf.payments = {}; + window.Opf.payments[domain] = {}; + } + return window.Opf.payments[domain]; + } + + protected registerStartLoadIndicator( + domain: GlobalFunctionsDomain, + vcr?: ViewContainerRef + ): void { + this.getGlobalFunctionContainer(domain).startLoadIndicator = (): void => { + if (!vcr) { + return; + } + this.ngZone.run(() => { + if (this.loaderSpinnerCpntRef) { + this.stopLoaderSpinner(this.loaderSpinnerCpntRef); + } + this.loaderSpinnerCpntRef = this.startLoaderSpinner(vcr); + }); + }; + } + + protected registerStopLoadIndicator(domain: GlobalFunctionsDomain): void { + this.getGlobalFunctionContainer(domain).stopLoadIndicator = (): void => { + this.ngZone.run(() => { + this.stopLoaderSpinner(this.loaderSpinnerCpntRef); + }); + }; + } + + protected startLoaderSpinner( + vcr: ViewContainerRef + ): void | Observable | undefined> { + return this.launchDialogService.launch( + LAUNCH_CALLER.PLACE_ORDER_SPINNER, + vcr + ); + } + + protected stopLoaderSpinner( + overlayedSpinner: void | Observable | undefined> + ): void { + if (!overlayedSpinner) { + return; + } + overlayedSpinner + .subscribe((component) => { + this.launchDialogService.clear(LAUNCH_CALLER.PLACE_ORDER_SPINNER); + if (component) { + component.destroy(); + } + }) + .unsubscribe(); + } + + protected registerGetRedirectParams( + domain: GlobalFunctionsDomain, + paramsMap: Array = [] + ): void { + this.getGlobalFunctionContainer(domain).getRedirectParams = () => + paramsMap.map((p) => { + return { key: p.key, value: p.value }; + }); + } + + protected registerThrowPaymentError( + domain: GlobalFunctionsDomain, + vcr?: ViewContainerRef + ): void { + this.getGlobalFunctionContainer(domain).throwPaymentError = ( + errorDialogOptions: ErrorDialogOptions = defaultErrorDialogOptions + ): void => { + if (!vcr) { + return; + } + this.ngZone.run(() => { + const dialog = this.launchDialogService.openDialog( + LAUNCH_CALLER.OPF_ERROR, + undefined, + vcr, + errorDialogOptions + ); + + if (dialog) { + dialog.pipe(take(1)).subscribe(); + } + }); + }; + } + + protected registerSubmit( + domain: GlobalFunctionsDomain, + paymentSessionId?: string, + vcr?: ViewContainerRef + ): void { + this.getGlobalFunctionContainer(domain).submit = ({ + cartId, + additionalData, + submitSuccess = (): void => { + // this is intentional + }, + submitPending = (): void => { + // this is intentional + }, + submitFailure = (): void => { + // this is intentional + }, + paymentMethod, + }: { + cartId: string; + additionalData: Array; + submitSuccess: MerchantCallback; + submitPending: MerchantCallback; + submitFailure: MerchantCallback; + paymentMethod: PaymentMethod; + }): Promise => { + return this.ngZone.run(() => { + let overlayedSpinner: void | Observable | undefined>; + if (vcr) { + overlayedSpinner = this.startLoaderSpinner(vcr); + } + const callbackArray: [ + MerchantCallback, + MerchantCallback, + MerchantCallback, + ] = [submitSuccess, submitPending, submitFailure]; + + return lastValueFrom( + this.opfPaymentFacade + .submitPayment({ + additionalData, + paymentSessionId, + cartId, + callbackArray, + paymentMethod, + returnPath: undefined, + }) + .pipe( + finalize(() => { + if (overlayedSpinner) { + this.stopLoaderSpinner(overlayedSpinner); + } + }) + ) + ); + }); + }; + } + + protected runSubmitComplete( + cartId: string, + additionalData: Array, + callbackArray: [MerchantCallback, MerchantCallback, MerchantCallback], + paymentSessionId: string, + returnPath?: string | undefined, + vcr?: ViewContainerRef + ) { + return this.ngZone.run(() => { + let overlayedSpinner: void | Observable | undefined>; + if (vcr) { + overlayedSpinner = this.startLoaderSpinner(vcr); + } + + return lastValueFrom( + this.opfPaymentFacade + .submitCompletePayment({ + additionalData, + paymentSessionId, + cartId, + callbackArray, + returnPath, + }) + .pipe( + finalize(() => { + if (overlayedSpinner) { + this.stopLoaderSpinner(overlayedSpinner); + } + }) + ) + ); + }); + } + + protected registerSubmitComplete( + domain: GlobalFunctionsDomain, + paymentSessionId: string, + vcr?: ViewContainerRef + ): void { + this.getGlobalFunctionContainer(domain).submitComplete = ({ + cartId, + additionalData, + submitSuccess = (): void => { + // this is intentional + }, + submitPending = (): void => { + // this is intentional + }, + submitFailure = (): void => { + // this is intentional + }, + }: { + cartId: string; + additionalData: Array; + submitSuccess: MerchantCallback; + submitPending: MerchantCallback; + submitFailure: MerchantCallback; + }): Promise => { + return this.runSubmitComplete( + cartId, + additionalData, + [submitSuccess, submitPending, submitFailure], + paymentSessionId, + undefined, + vcr + ); + }; + } + + protected registerSubmitCompleteRedirect( + domain: GlobalFunctionsDomain, + paymentSessionId: string, + vcr?: ViewContainerRef + ): void { + this.getGlobalFunctionContainer(domain).submitCompleteRedirect = ({ + cartId, + additionalData, + submitSuccess = (): void => { + // this is intentional + }, + submitPending = (): void => { + // this is intentional + }, + submitFailure = (): void => { + // this is intentional + }, + }: { + cartId: string; + additionalData: Array; + submitSuccess: MerchantCallback; + submitPending: MerchantCallback; + submitFailure: MerchantCallback; + }): Promise => { + return this.runSubmitComplete( + cartId, + additionalData, + [submitSuccess, submitPending, submitFailure], + paymentSessionId, + OpfPage.CHECKOUT_REVIEW_PAGE, + vcr + ); + }; + } + protected registerCtaScriptReady(domain: GlobalFunctionsDomain): void { + this.getGlobalFunctionContainer(domain).scriptReady = ( + scriptIdentifier: string + ): void => { + this.ngZone.run(() => { + this.opfCtaFacade.emitScriptReadyEvent(scriptIdentifier); + }); + }; + } +} diff --git a/integration-libs/opf/global-functions/core/ng-package.json b/integration-libs/opf/global-functions/core/ng-package.json new file mode 100644 index 00000000000..38e01ac17de --- /dev/null +++ b/integration-libs/opf/global-functions/core/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/global-functions/core/opf-global-functions-core.module.ts b/integration-libs/opf/global-functions/core/opf-global-functions-core.module.ts new file mode 100644 index 00000000000..d610acc1b5f --- /dev/null +++ b/integration-libs/opf/global-functions/core/opf-global-functions-core.module.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { facadeProviders } from './facade/facade-providers'; + +@NgModule({ + imports: [], + providers: [...facadeProviders], +}) +export class OpfGlobalFunctionsCoreModule {} diff --git a/integration-libs/opf/global-functions/core/public_api.ts b/integration-libs/opf/global-functions/core/public_api.ts new file mode 100644 index 00000000000..cab77f367fd --- /dev/null +++ b/integration-libs/opf/global-functions/core/public_api.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './facade/index'; +export * from './opf-global-functions-core.module'; diff --git a/integration-libs/opf/global-functions/ng-package.json b/integration-libs/opf/global-functions/ng-package.json new file mode 100644 index 00000000000..38e01ac17de --- /dev/null +++ b/integration-libs/opf/global-functions/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/global-functions/opf-global-functions.module.ts b/integration-libs/opf/global-functions/opf-global-functions.module.ts new file mode 100644 index 00000000000..7280e3a0eb0 --- /dev/null +++ b/integration-libs/opf/global-functions/opf-global-functions.module.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfGlobalFunctionsCoreModule } from '@spartacus/opf/global-functions/core'; + +@NgModule({ + imports: [OpfGlobalFunctionsCoreModule], +}) +export class OpfGlobalFunctionsModule {} diff --git a/integration-libs/opf/global-functions/public_api.ts b/integration-libs/opf/global-functions/public_api.ts new file mode 100644 index 00000000000..5f808633d84 --- /dev/null +++ b/integration-libs/opf/global-functions/public_api.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-global-functions.module'; diff --git a/integration-libs/opf/global-functions/root/facade/index.ts b/integration-libs/opf/global-functions/root/facade/index.ts new file mode 100644 index 00000000000..b7dcd186311 --- /dev/null +++ b/integration-libs/opf/global-functions/root/facade/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-global-functions.facade'; diff --git a/integration-libs/opf/global-functions/root/facade/opf-global-functions.facade.ts b/integration-libs/opf/global-functions/root/facade/opf-global-functions.facade.ts new file mode 100644 index 00000000000..f1ca26d953f --- /dev/null +++ b/integration-libs/opf/global-functions/root/facade/opf-global-functions.facade.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { facadeFactory } from '@spartacus/core'; +import { OPF_GLOBAL_FUNCTIONS_FEATURE } from '../feature-name'; +import { GlobalFunctionsDomain, GlobalFunctionsInput } from '../model'; + +@Injectable({ + providedIn: 'root', + useFactory: () => + facadeFactory({ + facade: OpfGlobalFunctionsFacade, + feature: OPF_GLOBAL_FUNCTIONS_FEATURE, + methods: ['registerGlobalFunctions', 'removeGlobalFunctions'], + }), +}) +export abstract class OpfGlobalFunctionsFacade { + /** + * Abstract method to register global functions used in Hosted-Fields pattern. + * Optional vcr ViewcontainerRef param is used to display an overlayed loader spinner. + * + * @param {string} paymentSessionId + * @param {ViewContainerRef} vcr + */ + abstract registerGlobalFunctions(gfInput: GlobalFunctionsInput): void; + + /** + * Abstract method to remove global functions used in Hosted-Fields pattern + */ + abstract removeGlobalFunctions(domain: GlobalFunctionsDomain): void; +} diff --git a/integration-libs/opf/global-functions/root/feature-name.ts b/integration-libs/opf/global-functions/root/feature-name.ts new file mode 100644 index 00000000000..11c0e85cfd4 --- /dev/null +++ b/integration-libs/opf/global-functions/root/feature-name.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export const OPF_GLOBAL_FUNCTIONS_FEATURE = 'opfGlobalFunctions'; diff --git a/integration-libs/opf/global-functions/root/model/index.ts b/integration-libs/opf/global-functions/root/model/index.ts new file mode 100644 index 00000000000..762460a7482 --- /dev/null +++ b/integration-libs/opf/global-functions/root/model/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-global-functions.model'; diff --git a/integration-libs/opf/global-functions/root/model/opf-global-functions.model.ts b/integration-libs/opf/global-functions/root/model/opf-global-functions.model.ts new file mode 100644 index 00000000000..2b9feec18fb --- /dev/null +++ b/integration-libs/opf/global-functions/root/model/opf-global-functions.model.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ViewContainerRef } from '@angular/core'; +import { KeyValuePair } from '@spartacus/opf/base/root'; + +export enum GlobalFunctionsDomain { + CHECKOUT = 'checkout', + GLOBAL = 'global', + REDIRECT = 'redirect', +} + +export interface GlobalFunctionsInput { + paymentSessionId: string; + vcr?: ViewContainerRef; + paramsMap?: Array; + domain: GlobalFunctionsDomain; +} diff --git a/integration-libs/opf/global-functions/root/ng-package.json b/integration-libs/opf/global-functions/root/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/global-functions/root/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/global-functions/root/opf-global-functions-root.module.ts b/integration-libs/opf/global-functions/root/opf-global-functions-root.module.ts new file mode 100644 index 00000000000..12fa648a036 --- /dev/null +++ b/integration-libs/opf/global-functions/root/opf-global-functions-root.module.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; + +@NgModule() +export class OpfGlobalFunctionsRootModule {} diff --git a/integration-libs/opf/global-functions/root/public_api.ts b/integration-libs/opf/global-functions/root/public_api.ts new file mode 100644 index 00000000000..5f1b9ecadab --- /dev/null +++ b/integration-libs/opf/global-functions/root/public_api.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './facade/index'; +export * from './feature-name'; +export * from './model/index'; +export * from './opf-global-functions-root.module'; diff --git a/integration-libs/opf/jest.schematics.config.js b/integration-libs/opf/jest.schematics.config.js new file mode 100644 index 00000000000..868a72cdaa2 --- /dev/null +++ b/integration-libs/opf/jest.schematics.config.js @@ -0,0 +1,35 @@ +const { pathsToModuleNameMapper } = require('ts-jest'); +const { compilerOptions } = require('./tsconfig.schematics.json'); +const { defaultTransformerOptions } = require('jest-preset-angular/presets'); + +/** @type {import('ts-jest/dist/types').JestConfigWithTsJest} */ +module.exports = { + preset: 'jest-preset-angular', + globalSetup: 'jest-preset-angular/global-setup', + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths || {}, { + prefix: '/', + }), + setupFilesAfterEnv: ['/setup-jest.ts'], + testMatch: ['**/+(*_)+(spec).+(ts)'], + transform: { + '^.+\\.(ts|js|mjs|html|svg)$': [ + 'jest-preset-angular', + { + ...defaultTransformerOptions, + tsconfig: '/tsconfig.schematics.json', + }, + ], + }, + + collectCoverage: false, + coverageReporters: ['json', 'lcov', 'text', 'clover'], + coverageDirectory: '/../../coverage/opf/schematics', + coverageThreshold: { + global: { + statements: 90, + branches: 90, + functions: 90, + lines: 90, + }, + }, +}; diff --git a/integration-libs/opf/karma.conf.js b/integration-libs/opf/karma.conf.js new file mode 100644 index 00000000000..6798486351b --- /dev/null +++ b/integration-libs/opf/karma.conf.js @@ -0,0 +1,45 @@ +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-coverage'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('@angular-devkit/build-angular/plugins/karma'), + require('karma-junit-reporter'), + ], + client: { + clearContext: false, // leave Jasmine Spec Runner output visible in browser + }, + reporters: ['progress', 'kjhtml', 'dots', 'junit'], + junitReporter: { + outputFile: 'unit-test-opf.xml', + outputDir: require('path').join(__dirname, '../../unit-tests-reports'), + useBrowserName: false, + }, + coverageReporter: { + dir: require('path').join(__dirname, '../../coverage/opf'), + reporters: [{ type: 'lcov', subdir: '.' }, { type: 'text-summary' }], + check: { + global: { + statements: 80, + lines: 80, + branches: 70, + functions: 80, + }, + }, + }, + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + singleRun: false, + restartOnFileChange: true, + }); +}; diff --git a/integration-libs/opf/ng-package.json b/integration-libs/opf/ng-package.json new file mode 100644 index 00000000000..c0e59fc8853 --- /dev/null +++ b/integration-libs/opf/ng-package.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/opf", + "lib": { + "entryFile": "./public_api.ts" + }, + "assets": ["**/*.scss", "schematics/**/*.json", "schematics/**/*.js"] +} diff --git a/integration-libs/opf/package.json b/integration-libs/opf/package.json new file mode 100644 index 00000000000..e2ecb8010a3 --- /dev/null +++ b/integration-libs/opf/package.json @@ -0,0 +1,51 @@ +{ + "name": "@spartacus/opf", + "version": "2211.29.0", + "description": "SAP Open Payment Framework integration library for Spartacus", + "keywords": [ + "spartacus", + "framework", + "storefront", + "opf" + ], + "homepage": "https://github.com/SAP/spartacus", + "repository": "https://github.com/SAP/spartacus/tree/develop/integration-libs/opf", + "license": "Apache-2.0", + "exports": { + ".": { + "sass": "./_index.scss" + } + }, + "scripts": { + "build:schematics": "npm run clean:schematics && ../../node_modules/.bin/tsc -p ./tsconfig.schematics.json", + "clean:schematics": "../../node_modules/.bin/rimraf --glob \"schematics/**/*.js\" \"schematics/**/*.js.map\" \"schematics/**/*.d.ts\"", + "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" + }, + "dependencies": { + "tslib": "^2.6.2" + }, + "peerDependencies": { + "@angular-devkit/schematics": "^17.0.5", + "@angular/common": "^17.0.5", + "@angular/core": "^17.0.5", + "@angular/forms": "^17.0.5", + "@angular/platform-browser": "^17.0.5", + "@angular/router": "^17.0.5", + "@ng-select/ng-select": "^12.0.4", + "@ngrx/store": "^17.0.1", + "@spartacus/cart": "2211.29.0", + "@spartacus/checkout": "2211.29.0", + "@spartacus/core": "2211.29.0", + "@spartacus/order": "2211.29.0", + "@spartacus/schematics": "2211.29.0", + "@spartacus/storefront": "2211.29.0", + "@spartacus/styles": "2211.29.0", + "@spartacus/user": "2211.29.0", + "bootstrap": "^4.6.2", + "rxjs": "^7.8.0" + }, + "publishConfig": { + "access": "public" + }, + "schematics": "./schematics/collection.json" +} diff --git a/integration-libs/opf/payment/assets/ng-package.json b/integration-libs/opf/payment/assets/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/payment/assets/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/payment/assets/public_api.ts b/integration-libs/opf/payment/assets/public_api.ts new file mode 100644 index 00000000000..eafa913ea0c --- /dev/null +++ b/integration-libs/opf/payment/assets/public_api.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './translations/index'; diff --git a/integration-libs/opf/payment/assets/translations/en/index.ts b/integration-libs/opf/payment/assets/translations/en/index.ts new file mode 100644 index 00000000000..d246d5baaf6 --- /dev/null +++ b/integration-libs/opf/payment/assets/translations/en/index.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import opfPayment from './opfPayment.json'; + +export const en = { + opfPayment, +}; diff --git a/integration-libs/opf/payment/assets/translations/en/opfPayment.json b/integration-libs/opf/payment/assets/translations/en/opfPayment.json new file mode 100644 index 00000000000..9580b68569b --- /dev/null +++ b/integration-libs/opf/payment/assets/translations/en/opfPayment.json @@ -0,0 +1,13 @@ +{ + "opfPayment": { + "errors": { + "proceedPayment": "We are unable to proceed with this payment method at this time. Please try again later or choose a different payment option.", + "cancelPayment": "You have cancelled your payment. To proceed with the order, try again, or choose a different payment option.", + "cardExpired": "Card is expired.", + "insufficientFunds": "Insufficient funds.", + "invalidCreditCard": "Invalid credit card. Please review card details.", + "unknown": "Unknown error occurred while fetching payment. Please contact support", + "cardReportedLost": "Card is reported lost." + } + } +} diff --git a/integration-libs/opf/payment/assets/translations/index.ts b/integration-libs/opf/payment/assets/translations/index.ts new file mode 100644 index 00000000000..659170bb76a --- /dev/null +++ b/integration-libs/opf/payment/assets/translations/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './translations'; diff --git a/integration-libs/opf/payment/assets/translations/translations.ts b/integration-libs/opf/payment/assets/translations/translations.ts new file mode 100644 index 00000000000..add198ef94d --- /dev/null +++ b/integration-libs/opf/payment/assets/translations/translations.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TranslationChunksConfig, TranslationResources } from '@spartacus/core'; +import { en } from './en/index'; + +export const opfPaymentTranslations: TranslationResources = { + en, +}; + +export const opfPaymentTranslationChunksConfig: TranslationChunksConfig = { + opfPayment: ['opfPayment'], +}; diff --git a/integration-libs/opf/payment/core/connectors/converters.ts b/integration-libs/opf/payment/core/connectors/converters.ts new file mode 100644 index 00000000000..12dc432900d --- /dev/null +++ b/integration-libs/opf/payment/core/connectors/converters.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InjectionToken } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { PaymentInitiationConfig } from '@spartacus/opf/payment/root'; + +export const OPF_PAYMENT_CONFIG_SERIALIZER = new InjectionToken< + Converter +>('OpfPaymentConfigSerializer'); diff --git a/integration-libs/opf/payment/core/connectors/index.ts b/integration-libs/opf/payment/core/connectors/index.ts new file mode 100644 index 00000000000..3532641c01d --- /dev/null +++ b/integration-libs/opf/payment/core/connectors/index.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './converters'; +export * from './opf-payment.adapter'; +export * from './opf-payment.connector'; diff --git a/integration-libs/opf/payment/core/connectors/opf-payment.adapter.ts b/integration-libs/opf/payment/core/connectors/opf-payment.adapter.ts new file mode 100644 index 00000000000..59079dc90d0 --- /dev/null +++ b/integration-libs/opf/payment/core/connectors/opf-payment.adapter.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AfterRedirectScriptResponse, + OpfPaymentVerificationPayload, + OpfPaymentVerificationResponse, + PaymentInitiationConfig, + PaymentSessionData, + SubmitCompleteRequest, + SubmitCompleteResponse, + SubmitRequest, + SubmitResponse, +} from '@spartacus/opf/payment/root'; +import { Observable } from 'rxjs'; + +export abstract class OpfPaymentAdapter { + /** + * Abstract method to verify a response from PSP for Full Page Redirect + * and iframe integration patterns. + * + */ + abstract verifyPayment( + paymentSessionId: string, + payload: OpfPaymentVerificationPayload + ): Observable; + + /** + * Abstract method used to submit payment for hosted-fields pattern. + * + */ + abstract submitPayment( + submitRequest: SubmitRequest, + otpKey: string, + paymentSessionId: string + ): Observable; + + /** + * Abstract method to submit-complete payment + * for Hosted Fields pattern. + * + */ + abstract submitCompletePayment( + submitRequest: SubmitCompleteRequest, + otpKey: string, + paymentSessionId: string + ): Observable; + + /** + * Abstract method to retrieve the dynamic scripts after redirect + * used in hosted-fields pattern. + * + */ + abstract afterRedirectScripts( + paymentSessionId: string + ): Observable; + + /** + * Abstract method used to initiate payment session + * or call the PSP to initiate. + * + */ + abstract initiatePayment( + paymentConfig: PaymentInitiationConfig + ): Observable; +} diff --git a/integration-libs/opf/payment/core/connectors/opf-payment.connector.spec.ts b/integration-libs/opf/payment/core/connectors/opf-payment.connector.spec.ts new file mode 100644 index 00000000000..bf151419690 --- /dev/null +++ b/integration-libs/opf/payment/core/connectors/opf-payment.connector.spec.ts @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { + AfterRedirectScriptResponse, + OpfPaymentVerificationPayload, + OpfPaymentVerificationResponse, + PaymentInitiationConfig, + PaymentMethod, + PaymentSessionData, + SubmitCompleteRequest, + SubmitCompleteResponse, + SubmitRequest, + SubmitResponse, + SubmitStatus, +} from '@spartacus/opf/payment/root'; +import { of } from 'rxjs'; +import { OpfPaymentAdapter } from './opf-payment.adapter'; +import { OpfPaymentConnector } from './opf-payment.connector'; +import createSpy = jasmine.createSpy; + +const mockOpfPaymentVerificationPayload: OpfPaymentVerificationPayload = { + responseMap: [], +}; + +const mockOpfPaymentVerificationResponse: OpfPaymentVerificationResponse = { + result: 'test', +}; + +const mockSubmitPaymentRequest: SubmitRequest = { + paymentMethod: 'card', + encryptedToken: 'token', + cartId: '123', +}; + +const mockSubmitPaymentResponse: SubmitResponse = { + cartId: 'test', + status: SubmitStatus.ACCEPTED, + reasonCode: 'ok', + paymentMethod: PaymentMethod.CREDIT_CARD, + authorizedAmount: 123, + customFields: [], +}; + +const mockSubmitCompleteRequest: SubmitCompleteRequest = { + paymentSessionId: 'test', +}; + +const mockSubmitCompleteResponse: SubmitCompleteResponse = { + cartId: 'test', + status: SubmitStatus.ACCEPTED, + reasonCode: 1, + customFields: [], +}; + +const mockPaymentInitiationConfig: PaymentInitiationConfig = { + config: {}, +}; + +const mockInitiatePayment: PaymentSessionData = { + paymentSessionId: 'test', +}; + +const mockAfterRedirectScriptsResponse: AfterRedirectScriptResponse = { + afterRedirectScript: {}, +}; + +class MockOpfPaymentAdapter implements OpfPaymentAdapter { + verifyPayment = createSpy('verifyPayment').and.callFake(() => + of(mockOpfPaymentVerificationResponse) + ); + submitPayment = createSpy('submitPayment').and.callFake(() => + of(mockSubmitPaymentResponse) + ); + submitCompletePayment = createSpy('submitCompletePayment').and.callFake(() => + of(mockSubmitCompleteResponse) + ); + initiatePayment = createSpy('initiatePayment').and.callFake(() => + of(mockInitiatePayment) + ); + afterRedirectScripts = createSpy('afterRedirectScripts').and.callFake(() => + of(mockAfterRedirectScriptsResponse) + ); +} + +describe('OpfPaymentConnector', () => { + let service: OpfPaymentConnector; + let adapter: OpfPaymentAdapter; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OpfPaymentConnector, + { provide: OpfPaymentAdapter, useClass: MockOpfPaymentAdapter }, + ], + }); + + service = TestBed.inject(OpfPaymentConnector); + adapter = TestBed.inject(OpfPaymentAdapter); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('verifyPayment should call adapter', () => { + let result; + service + .verifyPayment('paymentSessionId', mockOpfPaymentVerificationPayload) + .subscribe((res) => (result = res)); + expect(result).toEqual(mockOpfPaymentVerificationResponse); + expect(adapter.verifyPayment).toHaveBeenCalledWith( + 'paymentSessionId', + mockOpfPaymentVerificationPayload + ); + }); + + it('submitPayment should call adapter', () => { + let result; + service + .submitPayment(mockSubmitPaymentRequest, 'accessCode', 'paymentSessionId') + .subscribe((res) => (result = res)); + expect(result).toEqual(mockSubmitPaymentResponse); + expect(adapter.submitPayment).toHaveBeenCalledWith( + mockSubmitPaymentRequest, + 'accessCode', + 'paymentSessionId' + ); + }); + + it('initiatePayment should call adapter', () => { + let result; + service + .initiatePayment(mockPaymentInitiationConfig) + .subscribe((res) => (result = res)); + expect(result).toEqual(mockInitiatePayment); + expect(adapter.initiatePayment).toHaveBeenCalledWith( + mockPaymentInitiationConfig + ); + }); + + it('submitCompletePayment should call adapter', () => { + let result; + service + .submitCompletePayment( + mockSubmitCompleteRequest, + 'accessCode', + 'paymentSessionId' + ) + .subscribe((res) => (result = res)); + expect(result).toEqual(mockSubmitCompleteResponse); + expect(adapter.submitCompletePayment).toHaveBeenCalledWith( + mockSubmitCompleteRequest, + 'accessCode', + 'paymentSessionId' + ); + }); + + it('afterRedirectScripts should call adapter', () => { + let result; + service + .afterRedirectScripts('paymentSessionId') + .subscribe((res) => (result = res)); + expect(result).toEqual(mockAfterRedirectScriptsResponse); + expect(adapter.afterRedirectScripts).toHaveBeenCalledWith( + 'paymentSessionId' + ); + }); +}); diff --git a/integration-libs/opf/payment/core/connectors/opf-payment.connector.ts b/integration-libs/opf/payment/core/connectors/opf-payment.connector.ts new file mode 100644 index 00000000000..0c3d732b594 --- /dev/null +++ b/integration-libs/opf/payment/core/connectors/opf-payment.connector.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { + AfterRedirectScriptResponse, + OpfPaymentVerificationPayload, + OpfPaymentVerificationResponse, + PaymentInitiationConfig, + PaymentSessionData, + SubmitCompleteRequest, + SubmitCompleteResponse, + SubmitRequest, + SubmitResponse, +} from '@spartacus/opf/payment/root'; + +import { Observable } from 'rxjs'; +import { OpfPaymentAdapter } from './opf-payment.adapter'; + +@Injectable() +export class OpfPaymentConnector { + constructor(protected adapter: OpfPaymentAdapter) {} + + public verifyPayment( + paymentSessionId: string, + payload: OpfPaymentVerificationPayload + ): Observable { + return this.adapter.verifyPayment(paymentSessionId, payload); + } + + public submitPayment( + submitRequest: SubmitRequest, + otpKey: string, + paymentSessionId: string + ): Observable { + return this.adapter.submitPayment(submitRequest, otpKey, paymentSessionId); + } + + public submitCompletePayment( + submitCompleteRequest: SubmitCompleteRequest, + otpKey: string, + paymentSessionId: string + ): Observable { + return this.adapter.submitCompletePayment( + submitCompleteRequest, + otpKey, + paymentSessionId + ); + } + + public afterRedirectScripts( + paymentSessionId: string + ): Observable { + return this.adapter.afterRedirectScripts(paymentSessionId); + } + + public initiatePayment( + paymentConfig: PaymentInitiationConfig + ): Observable { + return this.adapter.initiatePayment(paymentConfig); + } +} diff --git a/integration-libs/opf/payment/core/facade/facade-providers.ts b/integration-libs/opf/payment/core/facade/facade-providers.ts new file mode 100644 index 00000000000..6721cb03b3a --- /dev/null +++ b/integration-libs/opf/payment/core/facade/facade-providers.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Provider } from '@angular/core'; +import { OpfPaymentFacade } from '@spartacus/opf/payment/root'; +import { OpfPaymentHostedFieldsService } from '../services/opf-payment-hosted-fields.service'; +import { OpfPaymentService } from './opf-payment.service'; + +export const facadeProviders: Provider[] = [ + OpfPaymentService, + OpfPaymentHostedFieldsService, + { + provide: OpfPaymentFacade, + useExisting: OpfPaymentService, + }, +]; diff --git a/integration-libs/opf/payment/core/facade/index.ts b/integration-libs/opf/payment/core/facade/index.ts new file mode 100644 index 00000000000..de1e5cc11e5 --- /dev/null +++ b/integration-libs/opf/payment/core/facade/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-payment.service'; diff --git a/integration-libs/opf/payment/core/facade/opf-payment.service.spec.ts b/integration-libs/opf/payment/core/facade/opf-payment.service.spec.ts new file mode 100644 index 00000000000..d453c46fa10 --- /dev/null +++ b/integration-libs/opf/payment/core/facade/opf-payment.service.spec.ts @@ -0,0 +1,257 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { CommandService, QueryService } from '@spartacus/core'; +import { Observable, of } from 'rxjs'; +import { + AfterRedirectScriptResponse, + OpfPaymentVerificationPayload, + OpfPaymentVerificationResponse, + SubmitCompleteInput, + SubmitInput, +} from '../../root/model'; +import { OpfPaymentConnector } from '../connectors'; +import { OpfPaymentHostedFieldsService } from '../services'; +import { OpfPaymentService } from './opf-payment.service'; + +class MockPaymentConnector implements Partial { + verifyPayment( + _paymentSessionId: string, + _payload: OpfPaymentVerificationPayload + ): Observable { + return of({ + result: 'result', + }) as Observable; + } + afterRedirectScripts( + _paymentSessionId: string + ): Observable { + return of({ afterRedirectScript: {} }); + } +} + +class MockOpfPaymentHostedFieldsService { + submitPayment(): Observable { + return of(true); + } + + submitCompletePayment(): Observable { + return of(true); + } +} + +const mockSubmitInput = { + cartId: '123', +} as SubmitInput; + +const mockSubmitCompleteInput: SubmitCompleteInput = { + cartId: 'mockCartId', + additionalData: [{ key: 'key', value: 'value' }], + paymentSessionId: 'sessionId', + returnPath: 'checkout', + callbackArray: [() => {}, () => {}, () => {}], +}; + +describe('OpfPaymentService', () => { + let service: OpfPaymentService; + let paymentConnector: MockPaymentConnector; + let opfPaymentHostedFieldsServiceSpy: OpfPaymentHostedFieldsService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OpfPaymentService, + QueryService, + CommandService, + { + provide: OpfPaymentConnector, + useClass: MockPaymentConnector, + }, + { + provide: OpfPaymentHostedFieldsService, + useClass: MockOpfPaymentHostedFieldsService, + }, + ], + }); + + service = TestBed.inject(OpfPaymentService); + paymentConnector = TestBed.inject(OpfPaymentConnector); + opfPaymentHostedFieldsServiceSpy = TestBed.inject( + OpfPaymentHostedFieldsService + ); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call submitPayment with proper payload after submitPaymentCommand execution', () => { + const submitPaymentSpy = spyOn( + opfPaymentHostedFieldsServiceSpy, + 'submitPayment' + ).and.callThrough(); + + const submitInput: SubmitInput = { + cartId: 'testCart', + } as SubmitInput; + + service['submitPaymentCommand'].execute({ submitInput }); + + expect(submitPaymentSpy).toHaveBeenCalledWith(submitInput); + }); + + it('should call verifyPayment with proper payload after verifyPaymentCommand execution', () => { + const verifyPaymentSpy = spyOn( + paymentConnector, + 'verifyPayment' + ).and.callThrough(); + + const verifyPaymentPayload = { + paymentSessionId: 'exampleSessionId', + paymentVerificationPayload: { + responseMap: [], + }, + }; + + service['verifyPaymentCommand'].execute(verifyPaymentPayload); + + expect(verifyPaymentSpy).toHaveBeenCalledWith( + verifyPaymentPayload.paymentSessionId, + verifyPaymentPayload.paymentVerificationPayload + ); + }); + + it('should call submitCompletePayment with proper payload after submitCompletePaymentCommand execution', () => { + const submitCompletePaymentSpy = spyOn( + opfPaymentHostedFieldsServiceSpy, + 'submitCompletePayment' + ).and.callThrough(); + + const submitCompleteInput: SubmitCompleteInput = { + cartId: 'testCart', + } as SubmitCompleteInput; + + service['submitCompletePaymentCommand'].execute({ submitCompleteInput }); + + expect(submitCompletePaymentSpy).toHaveBeenCalledWith(submitCompleteInput); + }); + + it('should call verifyPayment from connector with the correct payload', () => { + const paymentSessionId = 'exampleSessionId'; + const paymentVerificationPayload = { + responseMap: [ + { + key: 'key', + value: 'value', + }, + ], + } as OpfPaymentVerificationPayload; + const connectorVerifySpy = spyOn( + paymentConnector, + 'verifyPayment' + ).and.callThrough(); + + service.verifyPayment(paymentSessionId, paymentVerificationPayload); + + expect(connectorVerifySpy).toHaveBeenCalledWith( + paymentSessionId, + paymentVerificationPayload + ); + }); + + it('should call submitPayment from opfPaymentHostedFieldsService with the correct payload', () => { + const submitPaymentSpy = spyOn( + opfPaymentHostedFieldsServiceSpy, + 'submitPayment' + ).and.callThrough(); + + service.submitPayment(mockSubmitInput); + + expect(submitPaymentSpy).toHaveBeenCalledWith(mockSubmitInput); + }); + + it('should call submitCompletePayment from opfPaymentHostedFieldsService with the correct payload', () => { + const submitCompletePaymentSpy = spyOn( + opfPaymentHostedFieldsServiceSpy, + 'submitCompletePayment' + ).and.callThrough(); + + service.submitCompletePayment(mockSubmitCompleteInput); + + expect(submitCompletePaymentSpy).toHaveBeenCalledWith( + mockSubmitCompleteInput + ); + }); + + it('should return true when payment submission is successful', (done) => { + const result = service.submitPayment(mockSubmitInput); + + result.subscribe((response) => { + expect(response).toBe(true); + done(); + }); + }); + + it('should return a successful payment verification response', (done) => { + const paymentSessionId = 'exampleSessionId'; + const paymentVerificationPayload = { + responseMap: [ + { + key: 'key', + value: 'value', + }, + ], + } as OpfPaymentVerificationPayload; + + const expectedResult = { + result: 'result', + } as OpfPaymentVerificationResponse; + + const result = service.verifyPayment( + paymentSessionId, + paymentVerificationPayload + ); + + result.subscribe((response) => { + expect(response).toEqual(expectedResult); + done(); + }); + }); + + it('should return true when payment submission is completed successfully', (done) => { + const result = service.submitCompletePayment(mockSubmitCompleteInput); + + result.subscribe((response) => { + expect(response).toBe(true); + done(); + }); + }); + + it('should call afterRedirectScripts from connector with the correct payload', () => { + const paymentSessionId = 'exampleSessionId'; + + const connectorSpy = spyOn( + paymentConnector, + 'afterRedirectScripts' + ).and.callThrough(); + + service.afterRedirectScripts(paymentSessionId); + + expect(connectorSpy).toHaveBeenCalledWith(paymentSessionId); + }); + + // const connectorCtaSpy = spyOn( + // paymentConnector, + // 'getCtaScripts' + // ).and.callThrough(); + + // service.getCtaScripts(MockCtaRequest).subscribe(() => { + // expect(connectorCtaSpy).toHaveBeenCalledWith(MockCtaRequest); + // done(); + // }); + // }); +}); diff --git a/integration-libs/opf/payment/core/facade/opf-payment.service.ts b/integration-libs/opf/payment/core/facade/opf-payment.service.ts new file mode 100644 index 00000000000..c646eb2e683 --- /dev/null +++ b/integration-libs/opf/payment/core/facade/opf-payment.service.ts @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { Command, CommandService, QueryService } from '@spartacus/core'; +import { + AfterRedirectScriptResponse, + OpfPaymentFacade, + OpfPaymentVerificationPayload, + OpfPaymentVerificationResponse, + PaymentInitiationConfig, + PaymentSessionData, + SubmitCompleteInput, + SubmitInput, +} from '@spartacus/opf/payment/root'; +import { Observable } from 'rxjs'; +import { OpfPaymentConnector } from '../connectors/opf-payment.connector'; +import { OpfPaymentHostedFieldsService } from '../services/opf-payment-hosted-fields.service'; + +@Injectable() +export class OpfPaymentService implements OpfPaymentFacade { + protected verifyPaymentCommand: Command< + { + paymentSessionId: string; + paymentVerificationPayload: OpfPaymentVerificationPayload; + }, + OpfPaymentVerificationResponse + > = this.commandService.create((payload) => + this.opfPaymentConnector.verifyPayment( + payload.paymentSessionId, + payload.paymentVerificationPayload + ) + ); + + protected submitPaymentCommand: Command< + { + submitInput: SubmitInput; + }, + boolean + > = this.commandService.create((payload) => { + return this.opfPaymentHostedFieldsService.submitPayment( + payload.submitInput + ); + }); + + protected submitCompletePaymentCommand: Command< + { + submitCompleteInput: SubmitCompleteInput; + }, + boolean + > = this.commandService.create((payload) => { + return this.opfPaymentHostedFieldsService.submitCompletePayment( + payload.submitCompleteInput + ); + }); + + protected afterRedirectScriptsCommand: Command< + { + paymentSessionId: string; + }, + AfterRedirectScriptResponse + > = this.commandService.create((payload) => { + return this.opfPaymentConnector.afterRedirectScripts( + payload.paymentSessionId + ); + }); + + protected initiatePaymentCommand: Command< + { + paymentConfig: PaymentInitiationConfig; + }, + PaymentSessionData + > = this.commandService.create((payload) => + this.opfPaymentConnector.initiatePayment(payload.paymentConfig) + ); + + constructor( + protected queryService: QueryService, + protected commandService: CommandService, + protected opfPaymentConnector: OpfPaymentConnector, + protected opfPaymentHostedFieldsService: OpfPaymentHostedFieldsService + ) {} + + verifyPayment( + paymentSessionId: string, + paymentVerificationPayload: OpfPaymentVerificationPayload + ): Observable { + return this.verifyPaymentCommand.execute({ + paymentSessionId, + paymentVerificationPayload, + }); + } + + submitPayment(submitInput: SubmitInput): Observable { + return this.submitPaymentCommand.execute({ + submitInput, + }); + } + + submitCompletePayment( + submitCompleteInput: SubmitCompleteInput + ): Observable { + return this.submitCompletePaymentCommand.execute({ submitCompleteInput }); + } + + afterRedirectScripts(paymentSessionId: string) { + return this.afterRedirectScriptsCommand.execute({ paymentSessionId }); + } + + initiatePayment( + paymentConfig: PaymentInitiationConfig + ): Observable { + return this.initiatePaymentCommand.execute({ paymentConfig }); + } +} diff --git a/integration-libs/opf/payment/core/ng-package.json b/integration-libs/opf/payment/core/ng-package.json new file mode 100644 index 00000000000..38e01ac17de --- /dev/null +++ b/integration-libs/opf/payment/core/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/payment/core/opf-payment-core.module.ts b/integration-libs/opf/payment/core/opf-payment-core.module.ts new file mode 100644 index 00000000000..b5adf61c1e6 --- /dev/null +++ b/integration-libs/opf/payment/core/opf-payment-core.module.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfPaymentConnector } from './connectors'; +import { facadeProviders } from './facade/facade-providers'; + +@NgModule({ + imports: [], + providers: [...facadeProviders, OpfPaymentConnector], +}) +export class OpfPaymentCoreModule {} diff --git a/integration-libs/opf/payment/core/public_api.ts b/integration-libs/opf/payment/core/public_api.ts new file mode 100644 index 00000000000..35056cbc27f --- /dev/null +++ b/integration-libs/opf/payment/core/public_api.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './connectors/index'; +export * from './facade/index'; +export * from './opf-payment-core.module'; +export * from './services/index'; +export * from './tokens/index'; +export * from './utils/index'; diff --git a/integration-libs/opf/payment/core/services/index.ts b/integration-libs/opf/payment/core/services/index.ts new file mode 100644 index 00000000000..a0b11726638 --- /dev/null +++ b/integration-libs/opf/payment/core/services/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-payment-error-handler.service'; +export * from './opf-payment-hosted-fields.service'; diff --git a/integration-libs/opf/payment/core/services/opf-payment-error-handler.service.spec.ts b/integration-libs/opf/payment/core/services/opf-payment-error-handler.service.spec.ts new file mode 100644 index 00000000000..3ff7dd17483 --- /dev/null +++ b/integration-libs/opf/payment/core/services/opf-payment-error-handler.service.spec.ts @@ -0,0 +1,146 @@ +/* + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { + GlobalMessageService, + GlobalMessageType, + HttpResponseStatus, + RoutingService, +} from '@spartacus/core'; +import { OpfPaymentError, PaymentErrorType } from '../../root/model'; +import { OpfPaymentErrorHandlerService } from './opf-payment-error-handler.service'; + +describe('OpfPaymentErrorHandlerService', () => { + let service: OpfPaymentErrorHandlerService; + + const mockGlobalMessageService = { + add: jasmine.createSpy('add'), + }; + + const mockRoutingService = { + go: jasmine.createSpy('go'), + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OpfPaymentErrorHandlerService, + { provide: GlobalMessageService, useValue: mockGlobalMessageService }, + { provide: RoutingService, useValue: mockRoutingService }, + ], + }); + service = TestBed.inject(OpfPaymentErrorHandlerService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('displayError', () => { + it('should add error message to global message service', () => { + const error: OpfPaymentError = { + type: 'type', + message: 'Test error message', + }; + service['displayError'](error); + expect(mockGlobalMessageService.add).toHaveBeenCalledWith( + { key: error.message }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }); + }); + + describe('handlePaymentError', () => { + it('should handle payment bad request error', () => { + const error: OpfPaymentError = { + type: PaymentErrorType.INVALID_CVV, + message: 'Test error message', + status: HttpResponseStatus.BAD_REQUEST, + }; + service.handlePaymentError(error); + expect(mockGlobalMessageService.add).toHaveBeenCalledWith( + { key: 'opfPayment.errors.invalidCreditCard' }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }); + + it('should handle payment cancelled error', () => { + const error: OpfPaymentError = { + type: PaymentErrorType.PAYMENT_CANCELLED, + message: 'Test error message', + }; + + service.handlePaymentError(error); + expect(mockGlobalMessageService.add).toHaveBeenCalledWith( + { key: 'opfPayment.errors.cancelPayment' }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }); + + it('should handle other payment errors with returnPath', () => { + const error: OpfPaymentError = { + type: 'type', + message: 'Test error message', + }; + const returnPath = 'checkout'; + service.handlePaymentError(error, returnPath); + expect(mockGlobalMessageService.add).toHaveBeenCalled(); + expect(mockRoutingService.go).toHaveBeenCalledWith({ + cxRoute: returnPath, + }); + }); + }); + + describe('handleBadRequestError', () => { + it('should handle INSUFFICENT_FUNDS error type', () => { + const errorType = PaymentErrorType.INSUFFICENT_FUNDS; + + const message = service['handleBadRequestError'](errorType); + + expect(message).toBe('opfPayment.errors.insufficientFunds'); + }); + + it('should handle INVALID_CARD error type', () => { + const errorType = PaymentErrorType.INVALID_CARD; + + const message = service['handleBadRequestError'](errorType); + + expect(message).toBe('opfPayment.errors.invalidCreditCard'); + }); + + it('should handle LOST_CARD error type', () => { + const errorType = PaymentErrorType.LOST_CARD; + + const message = service['handleBadRequestError'](errorType); + + expect(message).toBe('opfPayment.errors.cardReportedLost'); + }); + + it('should handle EXPIRED error type', () => { + const errorType = PaymentErrorType.EXPIRED; + + const message = service['handleBadRequestError'](errorType); + + expect(message).toBe('opfPayment.errors.cardExpired'); + }); + + it('should handle INVALID_CVV error type', () => { + const errorType = PaymentErrorType.INVALID_CVV; + + const message = service['handleBadRequestError'](errorType); + + expect(message).toBe('opfPayment.errors.invalidCreditCard'); + }); + + it('should handle CREDIT_LIMIT error type', () => { + const errorType = PaymentErrorType.CREDIT_LIMIT; + + const message = service['handleBadRequestError'](errorType); + + expect(message).toBe('opfPayment.errors.insufficientFunds'); + }); + }); +}); diff --git a/integration-libs/opf/payment/core/services/opf-payment-error-handler.service.ts b/integration-libs/opf/payment/core/services/opf-payment-error-handler.service.ts new file mode 100644 index 00000000000..1edfd75d5c5 --- /dev/null +++ b/integration-libs/opf/payment/core/services/opf-payment-error-handler.service.ts @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { + GlobalMessageService, + GlobalMessageType, + HttpResponseStatus, + RoutingService, +} from '@spartacus/core'; +import { + OpfPaymentError, + PaymentErrorType, + defaultError, +} from '@spartacus/opf/payment/root'; + +@Injectable({ providedIn: 'root' }) +export class OpfPaymentErrorHandlerService { + constructor( + protected globalMessageService: GlobalMessageService, + protected routingService: RoutingService + ) {} + + protected displayError(error: OpfPaymentError | undefined): void { + this.globalMessageService.add( + { + key: error?.message ? error.message : defaultError.message, + }, + GlobalMessageType.MSG_TYPE_ERROR + ); + } + + protected handleBadRequestError(errorType?: string): string { + let message = defaultError.message; + switch (errorType) { + case PaymentErrorType.EXPIRED: + message = 'opfPayment.errors.cardExpired'; + break; + case PaymentErrorType.INSUFFICENT_FUNDS: + case PaymentErrorType.CREDIT_LIMIT: + message = 'opfPayment.errors.insufficientFunds'; + break; + case PaymentErrorType.INVALID_CARD: + case PaymentErrorType.INVALID_CVV: + message = 'opfPayment.errors.invalidCreditCard'; + break; + case PaymentErrorType.LOST_CARD: + message = 'opfPayment.errors.cardReportedLost'; + break; + } + return message; + } + + handlePaymentError( + error: OpfPaymentError | undefined, + returnPath?: string + ): void { + let message = defaultError.message; + if (error?.status === HttpResponseStatus.BAD_REQUEST) { + message = this.handleBadRequestError(error?.type); + } else { + if (error?.type === PaymentErrorType.PAYMENT_CANCELLED) { + message = 'opfPayment.errors.cancelPayment'; + } + } + this.displayError(error ? { ...error, message } : undefined); + if (returnPath?.length) { + this.routingService.go({ cxRoute: returnPath }); + } + } +} diff --git a/integration-libs/opf/payment/core/services/opf-payment-hosted-fields.service.spec.ts b/integration-libs/opf/payment/core/services/opf-payment-hosted-fields.service.spec.ts new file mode 100644 index 00000000000..e550678eb8c --- /dev/null +++ b/integration-libs/opf/payment/core/services/opf-payment-hosted-fields.service.spec.ts @@ -0,0 +1,334 @@ +/* + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fakeAsync, flush, TestBed } from '@angular/core/testing'; +import { + ActiveCartFacade, + CartAccessCodeFacade, +} from '@spartacus/cart/base/root'; +import { + GlobalMessageService, + RoutingService, + UserIdService, + WindowRef, +} from '@spartacus/core'; +import { Order, OrderFacade } from '@spartacus/order/root'; +import { of } from 'rxjs'; +import { + PaymentErrorType, + PaymentMethod, + SubmitCompleteInput, + SubmitInput, + SubmitResponse, + SubmitStatus, +} from '../../root/model'; +import { OpfPaymentConnector } from '../connectors'; +import { OpfPaymentErrorHandlerService } from './opf-payment-error-handler.service'; +import { OpfPaymentHostedFieldsService } from './opf-payment-hosted-fields.service'; + +describe('OpfPaymentHostedFieldsService', () => { + let service: OpfPaymentHostedFieldsService; + let routingService: RoutingService; + let orderFacade: OrderFacade; + let opfPaymentErrorHandlerService: OpfPaymentErrorHandlerService; + + const mockOpfPaymentConnector = { + submitPayment: jasmine.createSpy('submitPayment'), + submitCompletePayment: jasmine.createSpy('submitCompletePayment'), + }; + + const mockCartAccessCodeFacade = { + getCartAccessCode: jasmine + .createSpy('getCartAccessCode') + .and.returnValue(of({ accessCode: 'mockAccessCode' })), + }; + + const mockActiveCartFacade = { + takeActiveCartId: jasmine + .createSpy('takeActiveCartId') + .and.returnValue(of('mockActiveCartId')), + }; + + const mockUserIdService = { + takeUserId: jasmine + .createSpy('takeUserId') + .and.returnValue(of('mockUserId')), + }; + + const mockRoutingService = { + go: jasmine.createSpy('go'), + }; + + const mockOrderFacade = { + placePaymentAuthorizedOrder: jasmine + .createSpy('placePaymentAuthorizedOrder') + .and.returnValue(of({ id: 'testOrder' } as Order)), + }; + + const mockGlobalMessageService = { + add: jasmine.createSpy('add'), + }; + + const mockOpfPaymentErrorHandlerService = { + handlePaymentError: jasmine.createSpy('handlePaymentError'), + }; + + const mockInput: SubmitInput = { + paymentMethod: PaymentMethod.CREDIT_CARD, + cartId: 'mockCartId', + additionalData: [{ key: 'key', value: 'value' }], + paymentSessionId: 'sessionId', + returnPath: 'checkout', + callbackArray: [() => {}, () => {}, () => {}], + }; + + const mockSubmitCompleteInput: SubmitCompleteInput = { + cartId: 'mockCartId', + additionalData: [{ key: 'key', value: 'value' }], + paymentSessionId: 'sessionId', + returnPath: 'checkout', + callbackArray: [() => {}, () => {}, () => {}], + }; + + const mockSubmitResponse = { + status: SubmitStatus.ACCEPTED, + cartId: 'cartId', + reasonCode: 'code', + paymentMethod: PaymentMethod.CREDIT_CARD, + authorizedAmount: 10, + customFields: [{ key: 'key', value: 'value' }], + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OpfPaymentHostedFieldsService, + WindowRef, + { provide: OpfPaymentConnector, useValue: mockOpfPaymentConnector }, + { provide: CartAccessCodeFacade, useValue: mockCartAccessCodeFacade }, + { provide: ActiveCartFacade, useValue: mockActiveCartFacade }, + { provide: UserIdService, useValue: mockUserIdService }, + { provide: RoutingService, useValue: mockRoutingService }, + { provide: OrderFacade, useValue: mockOrderFacade }, + { provide: GlobalMessageService, useValue: mockGlobalMessageService }, + { + provide: OpfPaymentErrorHandlerService, + useValue: mockOpfPaymentErrorHandlerService, + }, + ], + }); + + service = TestBed.inject(OpfPaymentHostedFieldsService); + routingService = TestBed.inject(RoutingService); + orderFacade = TestBed.inject(OrderFacade); + opfPaymentErrorHandlerService = TestBed.inject( + OpfPaymentErrorHandlerService + ); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('submitPayment', () => { + it('should submit payment and handle success', (done) => { + mockUserIdService.takeUserId.and.returnValue(of('mockUserId')); + mockActiveCartFacade.takeActiveCartId.and.returnValue( + of('mockActiveCartId') + ); + mockOpfPaymentConnector.submitPayment.and.returnValue( + of({ status: SubmitStatus.ACCEPTED }) + ); + + service.submitPayment(mockInput).subscribe((result) => { + expect(result).toBeTruthy(); + expect(mockOpfPaymentConnector.submitPayment).toHaveBeenCalled(); + expect(orderFacade.placePaymentAuthorizedOrder).toHaveBeenCalled(); + expect(routingService.go).toHaveBeenCalledWith({ + cxRoute: 'orderConfirmation', + }); + done(); + }); + }); + + it('should handle rejected payment', (done) => { + const res: Partial = { + status: SubmitStatus.REJECTED, + }; + mockOpfPaymentConnector.submitPayment.and.returnValue(of(res)); + + service.submitPayment(mockInput).subscribe({ + error: (error) => { + expect(error.type).toBe(PaymentErrorType.PAYMENT_REJECTED); + expect(mockOpfPaymentConnector.submitPayment).toHaveBeenCalled(); + expect( + opfPaymentErrorHandlerService.handlePaymentError + ).toHaveBeenCalled(); + done(); + }, + }); + }); + }); + + describe('submitCompletePayment', () => { + it('should submit complete payment and handle success', (done) => { + mockUserIdService.takeUserId.and.returnValue(of('mockUserId')); + mockActiveCartFacade.takeActiveCartId.and.returnValue( + of('mockActiveCartId') + ); + mockOpfPaymentConnector.submitCompletePayment.and.returnValue( + of({ status: SubmitStatus.ACCEPTED }) + ); + + service + .submitCompletePayment(mockSubmitCompleteInput) + .subscribe((result) => { + expect(result).toBeTruthy(); + expect( + mockOpfPaymentConnector.submitCompletePayment + ).toHaveBeenCalled(); + expect(orderFacade.placePaymentAuthorizedOrder).toHaveBeenCalled(); + expect(routingService.go).toHaveBeenCalledWith({ + cxRoute: 'orderConfirmation', + }); + done(); + }); + }); + + it('should handle rejected complete payment', (done) => { + const res: Partial = { + status: SubmitStatus.REJECTED, + }; + + mockOpfPaymentConnector.submitCompletePayment.and.returnValue(of(res)); + + service.submitCompletePayment(mockSubmitCompleteInput).subscribe({ + error: (error) => { + expect(error.type).toBe(PaymentErrorType.PAYMENT_REJECTED); + expect( + mockOpfPaymentConnector.submitCompletePayment + ).toHaveBeenCalled(); + expect( + opfPaymentErrorHandlerService.handlePaymentError + ).toHaveBeenCalled(); + done(); + }, + }); + }); + }); + + describe('paymentResponseHandler', () => { + const mockSubmitSuccess = jasmine + .createSpy('mockSubmitSuccess') + .and.returnValue(() => {}); + const mockSubmitPending = jasmine + .createSpy('mockSubmitPending') + .and.returnValue(() => {}); + const mockSubmitFailure = jasmine + .createSpy('mockSubmitFailure') + .and.returnValue(() => {}); + + it('should handle accepted payment response', fakeAsync(() => { + const response: SubmitResponse = { + ...mockSubmitResponse, + status: SubmitStatus.ACCEPTED, + }; + + spyOn(service as any, 'paymentResponseHandler').and.callThrough(); + + service['paymentResponseHandler'](response, [ + mockSubmitSuccess, + mockSubmitPending, + mockSubmitFailure, + ]).subscribe((result) => { + expect(result).toBeTruthy(); + expect(mockSubmitSuccess).toHaveBeenCalled(); + expect(orderFacade.placePaymentAuthorizedOrder).toHaveBeenCalled(); + flush(); + }); + })); + + it('should handle delayed payment response', fakeAsync(() => { + const response: SubmitResponse = { + ...mockSubmitResponse, + status: SubmitStatus.DELAYED, + }; + spyOn(service as any, 'paymentResponseHandler').and.callThrough(); + + service['paymentResponseHandler'](response, [ + mockSubmitSuccess, + mockSubmitPending, + mockSubmitFailure, + ]).subscribe((result) => { + expect(result).toBeTruthy(); + expect(mockSubmitSuccess).toHaveBeenCalled(); + expect(orderFacade.placePaymentAuthorizedOrder).toHaveBeenCalled(); + flush(); + }); + })); + + it('should handle pending payment response', fakeAsync(() => { + const response: SubmitResponse = { + ...mockSubmitResponse, + status: SubmitStatus.PENDING, + }; + spyOn(service as any, 'paymentResponseHandler').and.callThrough(); + + let result; + + service['paymentResponseHandler'](response, [ + mockSubmitSuccess, + mockSubmitPending, + mockSubmitFailure, + ]).subscribe((res) => { + result = res; + }); + + expect(result).toBeUndefined(); + expect(mockSubmitPending).toHaveBeenCalled(); + flush(); + })); + + it('should handle rejected payment response', fakeAsync(() => { + const response: SubmitResponse = { + ...mockSubmitResponse, + status: SubmitStatus.REJECTED, + }; + spyOn(service as any, 'paymentResponseHandler').and.callThrough(); + + service['paymentResponseHandler'](response, [ + mockSubmitSuccess, + mockSubmitPending, + mockSubmitFailure, + ]).subscribe({ + error: (error) => { + expect(error.type).toBe(PaymentErrorType.PAYMENT_REJECTED); + expect(mockSubmitFailure).toHaveBeenCalled(); + flush(); + }, + }); + })); + + it('should handle unrecognized payment response status', fakeAsync(() => { + const response: SubmitResponse = { + ...mockSubmitResponse, + status: 'UNKNOWN_STATUS' as SubmitStatus, + }; + spyOn(service as any, 'paymentResponseHandler').and.callThrough(); + + service['paymentResponseHandler'](response, [ + mockSubmitSuccess, + mockSubmitPending, + mockSubmitFailure, + ]).subscribe({ + error: (error) => { + expect(error.type).toBe(PaymentErrorType.STATUS_NOT_RECOGNIZED); + expect(mockSubmitFailure).toHaveBeenCalled(); + flush(); + }, + }); + })); + }); +}); diff --git a/integration-libs/opf/payment/core/services/opf-payment-hosted-fields.service.ts b/integration-libs/opf/payment/core/services/opf-payment-hosted-fields.service.ts new file mode 100644 index 00000000000..59570d96665 --- /dev/null +++ b/integration-libs/opf/payment/core/services/opf-payment-hosted-fields.service.ts @@ -0,0 +1,221 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { + ActiveCartFacade, + CartAccessCodeFacade, +} from '@spartacus/cart/base/root'; +import { + backOff, + DEFAULT_AUTHORIZATION_ERROR_RETRIES_COUNT, + GlobalMessageService, + isAuthorizationError, + RoutingService, + UserIdService, + WindowRef, +} from '@spartacus/core'; +import { Order, OrderFacade } from '@spartacus/order/root'; + +import { combineLatest, EMPTY, from, Observable, of, throwError } from 'rxjs'; +import { catchError, concatMap, map, switchMap, tap } from 'rxjs/operators'; + +import { + defaultError, + MerchantCallback, + OpfPaymentError, + PaymentErrorType, + PaymentMethod, + SubmitCompleteInput, + SubmitCompleteRequest, + SubmitCompleteResponse, + SubmitInput, + SubmitRequest, + SubmitResponse, + SubmitStatus, +} from '@spartacus/opf/payment/root'; +import { OpfPaymentConnector } from '../connectors/opf-payment.connector'; +import { OpfPaymentErrorHandlerService } from '../services/opf-payment-error-handler.service'; + +import { getBrowserInfo } from '../utils/opf-payment-utils'; + +@Injectable() +export class OpfPaymentHostedFieldsService { + constructor( + protected opfPaymentConnector: OpfPaymentConnector, + protected winRef: WindowRef, + protected cartAccessCodeFacade: CartAccessCodeFacade, + protected activeCartFacade: ActiveCartFacade, + protected userIdService: UserIdService, + protected routingService: RoutingService, + protected orderFacade: OrderFacade, + protected globalMessageService: GlobalMessageService, + protected opfPaymentErrorHandlerService: OpfPaymentErrorHandlerService + ) {} + + submitPayment(submitInput: SubmitInput): Observable { + const { + paymentMethod, + cartId, + additionalData, + paymentSessionId, + returnPath, + encryptedToken, + } = submitInput; + + const submitRequest: SubmitRequest = { + paymentMethod, + cartId, + additionalData, + channel: 'BROWSER', + browserInfo: getBrowserInfo(this.winRef.nativeWindow), + }; + if (paymentMethod !== PaymentMethod.CREDIT_CARD) { + submitRequest.encryptedToken = ''; + } + if (encryptedToken) { + submitRequest.encryptedToken = encryptedToken; + } + + return this.getCartAccessCode(submitRequest).pipe( + concatMap(([request, { accessCode: otpKey }]) => + this.opfPaymentConnector.submitPayment( + request, + otpKey, + paymentSessionId as string + ) + ), + concatMap((response: SubmitResponse) => + this.paymentResponseHandler(response, submitInput.callbackArray) + ), + tap((order: Order) => { + if (order) { + this.routingService.go({ cxRoute: 'orderConfirmation' }); + } + }), + map((order: Order) => (order ? true : false)), + catchError((error: OpfPaymentError | undefined) => { + this.opfPaymentErrorHandlerService.handlePaymentError( + error, + returnPath + ); + return throwError(error); + }), + backOff({ + /** + * We should retry this sequence only if the error is an authorization error. + * It means that `accessCode` (OTP signature) is not valid or expired and we need to refresh it. + */ + shouldRetry: isAuthorizationError, + maxTries: DEFAULT_AUTHORIZATION_ERROR_RETRIES_COUNT, + }) + ); + } + + submitCompletePayment( + submitCompleteInput: SubmitCompleteInput + ): Observable { + const { cartId, additionalData, paymentSessionId, returnPath } = + submitCompleteInput; + + const submitCompleteRequest: SubmitCompleteRequest = { + cartId, + additionalData, + paymentSessionId, + }; + return this.getCartAccessCode(submitCompleteRequest).pipe( + concatMap(([request, { accessCode: otpKey }]) => + this.opfPaymentConnector.submitCompletePayment( + request, + otpKey, + paymentSessionId + ) + ), + concatMap((response: SubmitCompleteResponse) => + this.paymentResponseHandler(response, submitCompleteInput.callbackArray) + ), + tap((order: Order) => { + if (order) { + this.routingService.go({ cxRoute: 'orderConfirmation' }); + } + }), + map((order: Order) => (order ? true : false)), + catchError((error: OpfPaymentError | undefined) => { + this.opfPaymentErrorHandlerService.handlePaymentError( + error, + returnPath + ); + return throwError(() => error); + }), + backOff({ + /** + * We should retry this sequence only if the error is an authorization error. + * It means that `accessCode` (OTP signature) is not valid or expired and we need to refresh it. + */ + shouldRetry: isAuthorizationError, + maxTries: DEFAULT_AUTHORIZATION_ERROR_RETRIES_COUNT, + }) + ); + } + + protected paymentResponseHandler( + response: SubmitResponse | SubmitCompleteResponse, + [submitSuccess, submitPending, submitFailure]: [ + MerchantCallback, + MerchantCallback, + MerchantCallback, + ] + ): Observable { + if ( + response.status === SubmitStatus.ACCEPTED || + response.status === SubmitStatus.DELAYED + ) { + return from(Promise.resolve(submitSuccess(response))).pipe( + concatMap(() => this.orderFacade.placePaymentAuthorizedOrder(true)) + ); + } else if (response.status === SubmitStatus.PENDING) { + return from(Promise.resolve(submitPending(response))).pipe( + concatMap(() => EMPTY) + ); + } else if (response.status === SubmitStatus.REJECTED) { + return from(Promise.resolve(submitFailure(response))).pipe( + concatMap(() => + throwError({ + ...defaultError, + type: PaymentErrorType.PAYMENT_REJECTED, + }) + ) + ); + } else { + return from(Promise.resolve(submitFailure(response))).pipe( + concatMap(() => + throwError({ + ...defaultError, + type: PaymentErrorType.STATUS_NOT_RECOGNIZED, + }) + ) + ); + } + } + protected getCartAccessCode( + submitRequest: SubmitRequest | SubmitCompleteRequest + ): Observable< + [SubmitRequest | SubmitCompleteRequest, { accessCode: string }] + > { + return combineLatest([ + this.userIdService.takeUserId(), + this.activeCartFacade.takeActiveCartId(), + ]).pipe( + switchMap(([userId, activeCartId]: [string, string]) => { + submitRequest.cartId = activeCartId; + return combineLatest([ + of(submitRequest), + this.cartAccessCodeFacade.getCartAccessCode(userId, activeCartId), + ]); + }) + ); + } +} diff --git a/integration-libs/opf/payment/core/tokens/index.ts b/integration-libs/opf/payment/core/tokens/index.ts new file mode 100644 index 00000000000..62a40ab0d42 --- /dev/null +++ b/integration-libs/opf/payment/core/tokens/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './tokens'; diff --git a/integration-libs/opf/payment/core/tokens/tokens.ts b/integration-libs/opf/payment/core/tokens/tokens.ts new file mode 100644 index 00000000000..914ff86de27 --- /dev/null +++ b/integration-libs/opf/payment/core/tokens/tokens.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InjectionToken } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { + AfterRedirectScriptResponse, + OpfPaymentVerificationResponse, + SubmitCompleteResponse, + SubmitResponse, +} from '@spartacus/opf/payment/root'; + +export const OPF_PAYMENT_VERIFICATION_NORMALIZER = new InjectionToken< + Converter +>('OpfPaymentVerificationNormalizer'); + +export const OPF_PAYMENT_SUBMIT_NORMALIZER = new InjectionToken< + Converter +>('OpfPaymentSubmitNormalizer'); + +export const OPF_PAYMENT_SUBMIT_COMPLETE_NORMALIZER = new InjectionToken< + Converter +>('OpfPaymentSubmitCompleteNormalizer'); + +export const OPF_AFTER_REDIRECT_SCRIPTS_NORMALIZER = new InjectionToken< + Converter +>('OpfAfterRedirectScriptsNormalizer'); diff --git a/integration-libs/opf/payment/core/utils/index.ts b/integration-libs/opf/payment/core/utils/index.ts new file mode 100644 index 00000000000..be7069d8bf5 --- /dev/null +++ b/integration-libs/opf/payment/core/utils/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-payment-utils'; diff --git a/integration-libs/opf/payment/core/utils/opf-payment-utils.ts b/integration-libs/opf/payment/core/utils/opf-payment-utils.ts new file mode 100644 index 00000000000..036077894de --- /dev/null +++ b/integration-libs/opf/payment/core/utils/opf-payment-utils.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PaymentBrowserInfo } from '@spartacus/opf/payment/root'; + +export function getBrowserInfo( + nativeWindow: Window | undefined +): PaymentBrowserInfo { + return { + acceptHeader: 'application/json', + colorDepth: nativeWindow?.screen?.colorDepth, + javaEnabled: false, + javaScriptEnabled: true, + language: nativeWindow?.navigator?.language, + screenHeight: nativeWindow?.screen?.height, + screenWidth: nativeWindow?.screen?.width, + userAgent: nativeWindow?.navigator?.userAgent, + originUrl: nativeWindow?.location?.origin, + timeZoneOffset: new Date().getTimezoneOffset(), + }; +} diff --git a/integration-libs/opf/payment/ng-package.json b/integration-libs/opf/payment/ng-package.json new file mode 100644 index 00000000000..38e01ac17de --- /dev/null +++ b/integration-libs/opf/payment/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/payment/opf-api/adapters/index.ts b/integration-libs/opf/payment/opf-api/adapters/index.ts new file mode 100644 index 00000000000..219ba65b857 --- /dev/null +++ b/integration-libs/opf/payment/opf-api/adapters/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-api-payment.adapter'; diff --git a/integration-libs/opf/payment/opf-api/adapters/opf-api-payment.adapter.spec.ts b/integration-libs/opf/payment/opf-api/adapters/opf-api-payment.adapter.spec.ts new file mode 100644 index 00000000000..30146adebe6 --- /dev/null +++ b/integration-libs/opf/payment/opf-api/adapters/opf-api-payment.adapter.spec.ts @@ -0,0 +1,225 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + HttpClient, + HttpErrorResponse, + HttpHeaders, +} from '@angular/common/http'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { + BaseOccUrlProperties, + ConverterService, + DynamicAttributes, + HttpErrorModel, + LoggerService, + tryNormalizeHttpError, +} from '@spartacus/core'; +import { defer, of, throwError } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { OpfEndpointsService } from '@spartacus/opf/base/core'; +import { OPF_PAYMENT_VERIFICATION_NORMALIZER } from '../../core/tokens'; +import { OpfConfig } from '@spartacus/opf/base/root'; +import { OpfPaymentVerificationResponse } from '../../root/model'; +import { OpfApiPaymentAdapter } from './opf-api-payment.adapter'; + +class MockLoggerService implements Partial { + log(): void {} + warn(): void {} + error(): void {} + info(): void {} + debug(): void {} +} + +const mockJaloError = new HttpErrorResponse({ + error: { + errors: [ + { + message: 'The application has encountered an error', + type: 'JaloObjectNoLongerValidError', + }, + ], + }, +}); + +const mockOpfConfig: OpfConfig = {}; + +const mockPayload = { + responseMap: [ + { + key: 'key', + value: 'value', + }, + ], +}; + +const mockResult: OpfPaymentVerificationResponse = { + result: 'mockResult', +}; + +export class MockOpfEndpointsService implements Partial { + buildUrl( + endpoint: string, + _attributes?: DynamicAttributes, + _propertiesToOmit?: BaseOccUrlProperties + ) { + return this.getEndpoint(endpoint); + } + getEndpoint(endpoint: string) { + if (!endpoint.startsWith('/')) { + endpoint = '/' + endpoint; + } + return endpoint; + } +} + +const mockPaymentSessionId = '123'; + +const mockNormalizedJaloError = tryNormalizeHttpError( + mockJaloError, + new MockLoggerService() +); + +const mock500Error = new HttpErrorResponse({ + error: 'error', + headers: new HttpHeaders().set('xxx', 'xxx'), + status: 500, + statusText: 'Unknown error', + url: '/xxx', +}); + +const mockNormalized500Error = tryNormalizeHttpError( + mock500Error, + new MockLoggerService() +); + +describe(`OpfApiPaymentAdapter`, () => { + let service: OpfApiPaymentAdapter; + let httpMock: HttpTestingController; + let converter: ConverterService; + let opfEndpointsService: OpfEndpointsService; + let httpClient: HttpClient; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + OpfApiPaymentAdapter, + { + provide: OpfEndpointsService, + useClass: MockOpfEndpointsService, + }, + { + provide: OpfConfig, + useValue: mockOpfConfig, + }, + ], + }); + + service = TestBed.inject(OpfApiPaymentAdapter); + httpMock = TestBed.inject(HttpTestingController); + httpClient = TestBed.inject(HttpClient); + converter = TestBed.inject(ConverterService); + opfEndpointsService = TestBed.inject(OpfEndpointsService); + spyOn(converter, 'convert').and.callThrough(); + spyOn(converter, 'pipeable').and.callThrough(); + spyOn(opfEndpointsService, 'buildUrl').and.callThrough(); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe(`verifyPayment`, () => { + it(`should get all supported delivery modes for cart for given user id and cart id`, (done) => { + service + .verifyPayment(mockPaymentSessionId, mockPayload) + .pipe(take(1)) + .subscribe((result) => { + expect(result).toEqual(mockResult); + done(); + }); + + const url = service['verifyPaymentEndpoint'](mockPaymentSessionId); + const mockReq = httpMock.expectOne(url); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.responseType).toEqual('json'); + mockReq.flush(mockResult); + expect(converter.pipeable).toHaveBeenCalledWith( + OPF_PAYMENT_VERIFICATION_NORMALIZER + ); + }); + + describe(`back-off`, () => { + it(`should unsuccessfully backOff on Jalo error`, fakeAsync(() => { + spyOn(httpClient, 'post').and.returnValue(throwError(mockJaloError)); + + let result: HttpErrorModel | undefined; + const subscription = service + .verifyPayment(mockPaymentSessionId, mockPayload) + .subscribe({ error: (err) => (result = err) }); + + tick(4200); + + expect(result).toEqual(mockNormalizedJaloError); + + subscription.unsubscribe(); + })); + + it(`should successfully backOff on 500 error and recover after the 2nd retry`, fakeAsync(() => { + let calledTimes = -1; + + spyOn(httpClient, 'post').and.returnValue( + defer(() => { + calledTimes++; + if (calledTimes === 2) { + return of(mockResult); + } + return throwError(mock500Error); + }) + ); + + let result: OpfPaymentVerificationResponse | undefined; + const subscription = service + .verifyPayment(mockPaymentSessionId, mockPayload) + .pipe(take(1)) + .subscribe((res) => (result = res)); + + tick(300); + expect(result).toEqual(undefined); + + tick(1200); + + expect(result).toEqual(mockResult); + subscription.unsubscribe(); + })); + + it(`should unsuccessfully backOff on 500 error`, fakeAsync(() => { + spyOn(httpClient, 'post').and.returnValue(throwError(mock500Error)); + + let result: HttpErrorModel | undefined; + const subscription = service + .verifyPayment(mockPaymentSessionId, mockPayload) + .subscribe({ error: (err) => (result = err) }); + + tick(4200); + + expect(result).toEqual(mockNormalized500Error); + + subscription.unsubscribe(); + })); + }); + }); +}); diff --git a/integration-libs/opf/payment/opf-api/adapters/opf-api-payment.adapter.ts b/integration-libs/opf/payment/opf-api/adapters/opf-api-payment.adapter.ts new file mode 100644 index 00000000000..f14b8b46aa5 --- /dev/null +++ b/integration-libs/opf/payment/opf-api/adapters/opf-api-payment.adapter.ts @@ -0,0 +1,231 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { + ConverterService, + LoggerService, + backOff, + isServerError, + tryNormalizeHttpError, +} from '@spartacus/core'; +import { OpfEndpointsService } from '@spartacus/opf/base/core'; +import { + OPF_CC_ACCESS_CODE_HEADER, + OPF_CC_PUBLIC_KEY_HEADER, + OpfConfig, +} from '@spartacus/opf/base/root'; +import { + OPF_AFTER_REDIRECT_SCRIPTS_NORMALIZER, + OPF_PAYMENT_CONFIG_SERIALIZER, + OPF_PAYMENT_SUBMIT_COMPLETE_NORMALIZER, + OPF_PAYMENT_SUBMIT_NORMALIZER, + OPF_PAYMENT_VERIFICATION_NORMALIZER, + OpfPaymentAdapter, +} from '@spartacus/opf/payment/core'; +import { + AfterRedirectScriptResponse, + OpfPaymentVerificationPayload, + OpfPaymentVerificationResponse, + PaymentInitiationConfig, + PaymentSessionData, + SubmitCompleteRequest, + SubmitCompleteResponse, + SubmitRequest, + SubmitResponse, +} from '@spartacus/opf/payment/root'; +import { Observable } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +@Injectable() +export class OpfApiPaymentAdapter implements OpfPaymentAdapter { + protected logger = inject(LoggerService); + + constructor( + protected http: HttpClient, + protected converter: ConverterService, + protected opfEndpointsService: OpfEndpointsService, + protected config: OpfConfig + ) {} + + protected headerWithNoLanguage: { [name: string]: string } = { + accept: 'application/json', + 'Content-Type': 'application/json', + }; + protected header: { [name: string]: string } = { + ...this.headerWithNoLanguage, + 'Accept-Language': 'en-us', + }; + + protected headerWithContentLanguage: { [name: string]: string } = { + ...this.headerWithNoLanguage, + 'Content-Language': 'en-us', + }; + + verifyPayment( + paymentSessionId: string, + payload: OpfPaymentVerificationPayload + ): Observable { + const headers = new HttpHeaders(this.headerWithNoLanguage).set( + OPF_CC_PUBLIC_KEY_HEADER, + this.config.opf?.commerceCloudPublicKey || '' + ); + + return this.http + .post( + this.verifyPaymentEndpoint(paymentSessionId), + JSON.stringify(payload), + { + headers, + } + ) + .pipe( + catchError((error) => { + throw tryNormalizeHttpError(error, this.logger); + }), + backOff({ + shouldRetry: isServerError, + maxTries: 2, + }), + this.converter.pipeable(OPF_PAYMENT_VERIFICATION_NORMALIZER) + ); + } + + submitPayment( + submitRequest: SubmitRequest, + otpKey: string, + paymentSessionId: string + ): Observable { + const headers = new HttpHeaders(this.header) + .set( + OPF_CC_PUBLIC_KEY_HEADER, + this.config.opf?.commerceCloudPublicKey || '' + ) + .set(OPF_CC_ACCESS_CODE_HEADER, otpKey || ''); + + const url = this.getSubmitPaymentEndpoint(paymentSessionId); + + return this.http.post(url, submitRequest, { headers }).pipe( + catchError((error) => { + throw tryNormalizeHttpError(error, this.logger); + }), + backOff({ + shouldRetry: isServerError, + maxTries: 2, + }), + this.converter.pipeable(OPF_PAYMENT_SUBMIT_NORMALIZER) + ); + } + + submitCompletePayment( + submitCompleteRequest: SubmitCompleteRequest, + otpKey: string, + paymentSessionId: string + ): Observable { + const headers = new HttpHeaders(this.headerWithContentLanguage) + .set( + OPF_CC_PUBLIC_KEY_HEADER, + this.config.opf?.commerceCloudPublicKey || '' + ) + .set(OPF_CC_ACCESS_CODE_HEADER, otpKey || ''); + + const url = this.getSubmitCompletePaymentEndpoint(paymentSessionId); + + return this.http + .post(url, submitCompleteRequest, { headers }) + .pipe( + catchError((error) => { + throw tryNormalizeHttpError(error, this.logger); + }), + backOff({ + shouldRetry: isServerError, + maxTries: 2, + }), + this.converter.pipeable(OPF_PAYMENT_SUBMIT_COMPLETE_NORMALIZER) + ); + } + + afterRedirectScripts( + paymentSessionId: string + ): Observable { + const headers = new HttpHeaders(this.header).set( + OPF_CC_PUBLIC_KEY_HEADER, + this.config.opf?.commerceCloudPublicKey || '' + ); + + const url = this.getAfterRedirectScriptsEndpoint(paymentSessionId); + + return this.http.get(url, { headers }).pipe( + catchError((error) => { + throw tryNormalizeHttpError(error, this.logger); + }), + backOff({ + shouldRetry: isServerError, + maxTries: 2, + }), + this.converter.pipeable(OPF_AFTER_REDIRECT_SCRIPTS_NORMALIZER) + ); + } + + initiatePayment( + paymentConfig: PaymentInitiationConfig + ): Observable { + const headers = new HttpHeaders({ + 'Accept-Language': 'en-us', + }) + .set( + OPF_CC_PUBLIC_KEY_HEADER, + this.config.opf?.commerceCloudPublicKey || '' + ) + .set(OPF_CC_ACCESS_CODE_HEADER, paymentConfig?.otpKey || ''); + + const url = this.getInitiatePaymentEndpoint(); + + paymentConfig = this.converter.convert( + paymentConfig, + OPF_PAYMENT_CONFIG_SERIALIZER + ); + + return this.http + .post(url, paymentConfig?.config, { headers }) + .pipe( + catchError((error) => { + throw tryNormalizeHttpError(error, this.logger); + }) + ); + } + + protected verifyPaymentEndpoint(paymentSessionId: string): string { + return this.opfEndpointsService.buildUrl('verifyPayment', { + urlParams: { paymentSessionId }, + }); + } + + protected getSubmitPaymentEndpoint(paymentSessionId: string): string { + return this.opfEndpointsService + .buildUrl('submitPayment', { + urlParams: { paymentSessionId }, + }) + .replace('//submit', '/submit'); + } + + protected getSubmitCompletePaymentEndpoint(paymentSessionId: string): string { + return this.opfEndpointsService.buildUrl('submitCompletePayment', { + urlParams: { paymentSessionId }, + }); + } + + protected getAfterRedirectScriptsEndpoint(paymentSessionId: string): string { + return this.opfEndpointsService.buildUrl('afterRedirectScripts', { + urlParams: { paymentSessionId }, + }); + } + + protected getInitiatePaymentEndpoint(): string { + return this.opfEndpointsService.buildUrl('initiatePayment'); + } +} diff --git a/integration-libs/opf/payment/opf-api/config/default-opf-api-payment-config.ts b/integration-libs/opf/payment/opf-api/config/default-opf-api-payment-config.ts new file mode 100644 index 00000000000..ab8d9cb6505 --- /dev/null +++ b/integration-libs/opf/payment/opf-api/config/default-opf-api-payment-config.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpfApiConfig } from '@spartacus/opf/base/root'; + +export const defaultOpfApiPaymentConfig: OpfApiConfig = { + backend: { + opfApi: { + endpoints: { + verifyPayment: 'payments/${paymentSessionId}/verify', + submitPayment: 'payments/${paymentSessionId}/submit', + submitCompletePayment: 'payments/${paymentSessionId}/submit-complete', + afterRedirectScripts: + 'payments/${paymentSessionId}/after-redirect-scripts', + initiatePayment: 'payments', + }, + }, + }, +}; diff --git a/integration-libs/opf/payment/opf-api/model/index.ts b/integration-libs/opf/payment/opf-api/model/index.ts new file mode 100644 index 00000000000..3580a29a0c4 --- /dev/null +++ b/integration-libs/opf/payment/opf-api/model/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-api-payment-endpoints.model'; diff --git a/integration-libs/opf/payment/opf-api/model/opf-api-payment-endpoints.model.ts b/integration-libs/opf/payment/opf-api/model/opf-api-payment-endpoints.model.ts new file mode 100644 index 00000000000..a0239593558 --- /dev/null +++ b/integration-libs/opf/payment/opf-api/model/opf-api-payment-endpoints.model.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpfApiEndpoint } from '@spartacus/opf/base/root'; + +declare module '@spartacus/opf/base/root' { + interface OpfApiEndpoints { + /** + * Endpoint to verify a response from PSP for Full Page Redirect + * and iFrame integration patterns. + */ + verifyPayment?: string | OpfApiEndpoint; + /** + * Endpoint to submit payment for Hosted Fields pattern. + */ + submitPayment?: string | OpfApiEndpoint; + /** + * Endpoint to submit-complete payment for Hosted Fields pattern. + */ + submitCompletePayment?: string | OpfApiEndpoint; + /** + * Endpoint to fetch dynamic script for Hosted Fields pattern and PageRedirection sub-pattern. + */ + afterRedirectScripts?: string | OpfApiEndpoint; + /** + * Endpoint to initiate payment provider. + */ + initiatePayment?: string | OpfApiEndpoint; + } +} diff --git a/integration-libs/opf/payment/opf-api/ng-package.json b/integration-libs/opf/payment/opf-api/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/payment/opf-api/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/payment/opf-api/opf-api-payment.module.ts b/integration-libs/opf/payment/opf-api/opf-api-payment.module.ts new file mode 100644 index 00000000000..9d1f43e4c94 --- /dev/null +++ b/integration-libs/opf/payment/opf-api/opf-api-payment.module.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { provideDefaultConfig } from '@spartacus/core'; +import { OpfPaymentAdapter } from '@spartacus/opf/payment/core'; +import { OpfApiPaymentAdapter } from './adapters'; +import { defaultOpfApiPaymentConfig } from './config/default-opf-api-payment-config'; + +@NgModule({ + imports: [CommonModule], + providers: [ + provideDefaultConfig(defaultOpfApiPaymentConfig), + { + provide: OpfPaymentAdapter, + useClass: OpfApiPaymentAdapter, + }, + ], +}) +export class OpfApiPaymentModule {} diff --git a/integration-libs/opf/payment/opf-api/public_api.ts b/integration-libs/opf/payment/opf-api/public_api.ts new file mode 100644 index 00000000000..0c8f22cbbb1 --- /dev/null +++ b/integration-libs/opf/payment/opf-api/public_api.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './model/index'; +export * from './opf-api-payment.module'; diff --git a/integration-libs/opf/payment/opf-payment.module.ts b/integration-libs/opf/payment/opf-payment.module.ts new file mode 100644 index 00000000000..cea734c2b65 --- /dev/null +++ b/integration-libs/opf/payment/opf-payment.module.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfPaymentCoreModule } from '@spartacus/opf/payment/core'; +import { OpfApiPaymentModule } from '@spartacus/opf/payment/opf-api'; + +@NgModule({ + imports: [OpfPaymentCoreModule, OpfApiPaymentModule], +}) +export class OpfPaymentModule {} diff --git a/integration-libs/opf/payment/public_api.ts b/integration-libs/opf/payment/public_api.ts new file mode 100644 index 00000000000..558d7a407d8 --- /dev/null +++ b/integration-libs/opf/payment/public_api.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-payment.module'; diff --git a/integration-libs/opf/payment/root/components/opf-payment-verification/index.ts b/integration-libs/opf/payment/root/components/opf-payment-verification/index.ts new file mode 100644 index 00000000000..1b670910e92 --- /dev/null +++ b/integration-libs/opf/payment/root/components/opf-payment-verification/index.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-payment-verification.component'; +export * from './opf-payment-verification.module'; +export * from './opf-payment-verification.service'; diff --git a/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.component.html b/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.component.html new file mode 100644 index 00000000000..4181f85b28e --- /dev/null +++ b/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.component.spec.ts b/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.component.spec.ts new file mode 100644 index 00000000000..d3d54b02773 --- /dev/null +++ b/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.component.spec.ts @@ -0,0 +1,213 @@ +/* + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { HttpErrorModel } from '@spartacus/core'; +import { of, throwError } from 'rxjs'; +import { KeyValuePair, OpfPage } from '../../model'; +import { OpfPaymentVerificationComponent } from './opf-payment-verification.component'; +import { OpfPaymentVerificationService } from './opf-payment-verification.service'; + +@Component({ + selector: 'cx-spinner', + template: '', +}) +class MockSpinnerComponent {} + +describe('OpfPaymentVerificationComponent', () => { + let component: OpfPaymentVerificationComponent; + let fixture: ComponentFixture; + let routeMock: jasmine.SpyObj; + let opfPaymentVerificationServiceMock: jasmine.SpyObj; + + beforeEach(() => { + routeMock = jasmine.createSpyObj('ActivatedRoute', [], { + snapshot: { queryParamMap: new Map() }, + }); + opfPaymentVerificationServiceMock = jasmine.createSpyObj( + 'OpfPaymentVerificationService', + [ + 'checkIfProcessingCartIdExist', + 'verifyResultUrl', + 'goToPage', + 'displayError', + 'removeResourcesAndGlobalFunctions', + 'runHostedFieldsPattern', + 'runHostedPagePattern', + ] + ); + + TestBed.configureTestingModule({ + declarations: [OpfPaymentVerificationComponent, MockSpinnerComponent], + providers: [ + { provide: ActivatedRoute, useValue: routeMock }, + { + provide: OpfPaymentVerificationService, + useValue: opfPaymentVerificationServiceMock, + }, + ], + }); + + fixture = TestBed.createComponent(OpfPaymentVerificationComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('ngOnInit', () => { + it('should call checkIfProcessingCartIdExist', () => { + opfPaymentVerificationServiceMock.verifyResultUrl.and.returnValue(of()); + + component.ngOnInit(); + expect( + opfPaymentVerificationServiceMock.checkIfProcessingCartIdExist + ).toHaveBeenCalled(); + }); + + it('should handle success scenario', () => { + const mockPaymentSessionId = 'sessionId'; + const mockResponseMap: Array = []; + const mockAfterRedirectScriptFlag: string = 'false'; + const mockVerifyResult: { + paymentSessionId: string; + paramsMap: Array; + afterRedirectScriptFlag: string; + } = { + paymentSessionId: mockPaymentSessionId, + paramsMap: mockResponseMap, + afterRedirectScriptFlag: mockAfterRedirectScriptFlag, + }; + + opfPaymentVerificationServiceMock.verifyResultUrl.and.returnValue( + of(mockVerifyResult) + ); + opfPaymentVerificationServiceMock.runHostedFieldsPattern.and.returnValue( + of(true) + ); + opfPaymentVerificationServiceMock.runHostedPagePattern.and.returnValue( + of(true) + ); + + component.ngOnInit(); + + expect( + opfPaymentVerificationServiceMock.verifyResultUrl + ).toHaveBeenCalledWith(routeMock); + expect( + opfPaymentVerificationServiceMock.runHostedPagePattern + ).toHaveBeenCalledWith(mockPaymentSessionId, mockResponseMap); + }); + + it('should handle error scenario', () => { + const mockError: HttpErrorModel = { status: 500, message: 'Error' }; + + const mockVerifyResult = { + paymentSessionId: '1', + paramsMap: [], + afterRedirectScriptFlag: 'false', + }; + + opfPaymentVerificationServiceMock.verifyResultUrl.and.returnValue( + of(mockVerifyResult) + ); + opfPaymentVerificationServiceMock.runHostedPagePattern.and.returnValue( + throwError(mockError) + ); + + spyOn(component, 'onError'); + + component.ngOnInit(); + + expect(component.onError).toHaveBeenCalledWith(mockError); + }); + + it('should call onError when payment fails', () => { + const mockVerifyResult = { + paymentSessionId: '1', + paramsMap: [], + afterRedirectScriptFlag: 'false', + }; + + opfPaymentVerificationServiceMock.verifyResultUrl.and.returnValue( + of(mockVerifyResult) + ); + opfPaymentVerificationServiceMock.runHostedPagePattern.and.returnValue( + of(false) + ); + + spyOn(component, 'onError'); + + component.ngOnInit(); + + expect(component.onError).toHaveBeenCalledWith(undefined); + }); + + it('should handle HostedField pattern successful scenario', () => { + const mockVerifyResultWithFlag = { + paymentSessionId: '1', + paramsMap: [], + afterRedirectScriptFlag: 'true', + }; + + opfPaymentVerificationServiceMock.verifyResultUrl.and.returnValue( + of(mockVerifyResultWithFlag) + ); + opfPaymentVerificationServiceMock.runHostedFieldsPattern.and.returnValue( + of(true) + ); + component.ngOnInit(); + + expect( + opfPaymentVerificationServiceMock.runHostedFieldsPattern + ).toHaveBeenCalled(); + expect( + opfPaymentVerificationServiceMock.runHostedPagePattern + ).not.toHaveBeenCalled(); + }); + }); + + describe('onError', () => { + it('should call paymentService.displayError with the provided error and paymentService.goToPage with OpfPage.CHECKOUT_REVIEW_PAGE', () => { + const mockError: HttpErrorModel = { status: 404, message: 'Not Found' }; + + component.onError(mockError); + + expect( + opfPaymentVerificationServiceMock.displayError + ).toHaveBeenCalledWith(mockError); + expect(opfPaymentVerificationServiceMock.goToPage).toHaveBeenCalledWith( + OpfPage.CHECKOUT_REVIEW_PAGE + ); + }); + }); + + describe('ngOnDestroy', () => { + it('should call removeResourcesAndGlobalFunctions in HostedField pattern', () => { + const mockVerifyResultWithFlag = { + paymentSessionId: '1', + paramsMap: [], + afterRedirectScriptFlag: 'true', + }; + + opfPaymentVerificationServiceMock.verifyResultUrl.and.returnValue( + of(mockVerifyResultWithFlag) + ); + opfPaymentVerificationServiceMock.runHostedFieldsPattern.and.returnValue( + of(true) + ); + component.ngOnInit(); + + component.ngOnDestroy(); + + expect( + opfPaymentVerificationServiceMock.removeResourcesAndGlobalFunctions + ).toHaveBeenCalled(); + }); + }); +}); diff --git a/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.component.ts b/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.component.ts new file mode 100644 index 00000000000..2f0980b785c --- /dev/null +++ b/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.component.ts @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Component, OnDestroy, OnInit, ViewContainerRef } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { HttpErrorModel } from '@spartacus/core'; + +import { Observable, Subscription } from 'rxjs'; +import { concatMap } from 'rxjs/operators'; + +import { GlobalFunctionsDomain } from '@spartacus/opf/global-functions/root'; +import { KeyValuePair, OpfPage } from '../../model'; +import { OpfPaymentVerificationService } from './opf-payment-verification.service'; + +@Component({ + selector: 'cx-opf-verify-payment', + templateUrl: './opf-payment-verification.component.html', +}) +export class OpfPaymentVerificationComponent implements OnInit, OnDestroy { + protected subscription?: Subscription; + protected isHostedFieldPattern = false; + + constructor( + protected route: ActivatedRoute, + protected opfPaymentVerificationService: OpfPaymentVerificationService, + protected vcr: ViewContainerRef + ) {} + + ngOnInit(): void { + this.opfPaymentVerificationService.checkIfProcessingCartIdExist(); + + this.subscription = this.opfPaymentVerificationService + .verifyResultUrl(this.route) + .pipe( + concatMap( + ({ + paymentSessionId, + paramsMap: paramsMap, + afterRedirectScriptFlag, + }) => + this.runPaymentPattern({ + paymentSessionId, + paramsMap, + afterRedirectScriptFlag, + }) + ) + ) + .subscribe({ + error: (error: HttpErrorModel | undefined) => this.onError(error), + next: (success: boolean) => { + if (!success) { + this.onError(undefined); + } + }, + }); + } + + protected runPaymentPattern({ + paymentSessionId, + paramsMap, + afterRedirectScriptFlag, + }: { + paymentSessionId: string; + paramsMap: KeyValuePair[]; + afterRedirectScriptFlag?: string; + }): Observable { + if (afterRedirectScriptFlag === 'true') { + this.isHostedFieldPattern = true; + return this.opfPaymentVerificationService.runHostedFieldsPattern( + GlobalFunctionsDomain.REDIRECT, + paymentSessionId, + this.vcr, + paramsMap + ); + } else { + return this.opfPaymentVerificationService.runHostedPagePattern( + paymentSessionId, + paramsMap + ); + } + } + + onError(error: HttpErrorModel | undefined): void { + this.opfPaymentVerificationService.displayError(error); + this.opfPaymentVerificationService.goToPage(OpfPage.CHECKOUT_REVIEW_PAGE); + } + + ngOnDestroy(): void { + if (this.subscription) { + this.subscription.unsubscribe(); + } + + if (this.isHostedFieldPattern) { + this.opfPaymentVerificationService.removeResourcesAndGlobalFunctions(); + } + } +} diff --git a/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.module.ts b/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.module.ts new file mode 100644 index 00000000000..92060d0e130 --- /dev/null +++ b/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.module.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SpinnerModule } from '@spartacus/storefront'; +import { OpfPaymentVerificationComponent } from './opf-payment-verification.component'; + +@NgModule({ + declarations: [OpfPaymentVerificationComponent], + imports: [CommonModule, SpinnerModule], + exports: [OpfPaymentVerificationComponent], +}) +export class OpfPaymentVerificationModule {} diff --git a/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.service.spec.ts b/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.service.spec.ts new file mode 100644 index 00000000000..d1535c22acd --- /dev/null +++ b/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.service.spec.ts @@ -0,0 +1,557 @@ +/* + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ViewContainerRef } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { + GlobalMessageService, + GlobalMessageType, + HttpErrorModel, + RoutingService, +} from '@spartacus/core'; +import { + OpfDynamicScript, + OpfMetadataModel, + OpfMetadataStoreService, + OpfResourceLoaderService, +} from '@spartacus/opf/base/root'; +import { Order, OrderFacade } from '@spartacus/order/root'; +import { of } from 'rxjs'; +import { OpfPaymentFacade } from '../../facade'; +import { + GlobalFunctionsDomain, + OpfPaymentVerificationResponse, + OpfPaymentVerificationResult, +} from '../../model'; + +import { OpfGlobalFunctionsFacade } from '@spartacus/opf/global-functions/root'; +import { OpfPaymentVerificationService } from './opf-payment-verification.service'; + +describe('OpfPaymentVerificationService', () => { + let service: OpfPaymentVerificationService; + let orderFacadeMock: jasmine.SpyObj; + let routingServiceMock: jasmine.SpyObj; + let globalMessageServiceMock: jasmine.SpyObj; + let opfPaymentServiceMock: jasmine.SpyObj; + let opfMetadataStoreServiceMock: jasmine.SpyObj; + let opfResourceLoaderServiceMock: jasmine.SpyObj; + let globalFunctionsServiceMock: jasmine.SpyObj; + + beforeEach(() => { + orderFacadeMock = jasmine.createSpyObj('OrderFacade', [ + 'placePaymentAuthorizedOrder', + ]); + routingServiceMock = jasmine.createSpyObj('RoutingService', ['go']); + globalMessageServiceMock = jasmine.createSpyObj('GlobalMessageService', [ + 'add', + ]); + opfPaymentServiceMock = jasmine.createSpyObj('OpfPaymentFacade', [ + 'verifyPayment', + 'afterRedirectScripts', + ]); + opfMetadataStoreServiceMock = jasmine.createSpyObj( + 'OpfMetadataStoreService', + ['getOpfMetadataState'] + ); + opfResourceLoaderServiceMock = jasmine.createSpyObj( + 'OpfResourceLoaderService', + [ + 'clearAllProviderResources', + 'executeScriptFromHtml', + 'loadProviderResources', + ] + ); + + globalFunctionsServiceMock = jasmine.createSpyObj( + 'OpfGlobalFunctionsFacade', + ['registerGlobalFunctions', 'removeGlobalFunctions'] + ); + + TestBed.configureTestingModule({ + providers: [ + OpfPaymentVerificationService, + { provide: OrderFacade, useValue: orderFacadeMock }, + { provide: RoutingService, useValue: routingServiceMock }, + { provide: GlobalMessageService, useValue: globalMessageServiceMock }, + { provide: OpfPaymentFacade, useValue: opfPaymentServiceMock }, + { + provide: OpfMetadataStoreService, + useValue: opfMetadataStoreServiceMock, + }, + { + provide: OpfResourceLoaderService, + useValue: opfResourceLoaderServiceMock, + }, + { + provide: OpfGlobalFunctionsFacade, + useValue: globalFunctionsServiceMock, + }, + ], + }); + + service = TestBed.inject(OpfPaymentVerificationService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('goToPage', () => { + it('should call routingService.go with the provided cxRoute', () => { + service.goToPage('orderConfirmation'); + + expect(routingServiceMock.go).toHaveBeenCalledWith({ + cxRoute: 'orderConfirmation', + }); + }); + }); + + describe('verifyResultUrl', () => { + const mockPaymentSessionId = 'sessionId'; + const mockRouteSnapshot: ActivatedRoute = { + routeConfig: { + data: { + cxRoute: 'paymentVerificationResult', + }, + }, + queryParams: of({ paymentSessionId: mockPaymentSessionId }), + } as unknown as ActivatedRoute; + + it('should verify the result URL and return the response map if the route cxRoute is "paymentVerificationResult"', (done) => { + service.verifyResultUrl(mockRouteSnapshot).subscribe((result) => { + expect(result.paymentSessionId).toEqual(mockPaymentSessionId); + expect(result.paramsMap).toEqual([ + { key: 'paymentSessionId', value: mockPaymentSessionId }, + ]); + done(); + }); + }); + + it('should return paymentSessionId from local storage if not in params', (done) => { + const mockPaymentSessionId = 'sessionIdFromLocalStorage'; + const mockRouteSnapshot: ActivatedRoute = { + routeConfig: { + data: { + cxRoute: 'paymentVerificationResult', + }, + }, + queryParams: of({ afterRedirectScriptFlag: 'true' }), + } as unknown as ActivatedRoute; + + const mockOpfMetadata: OpfMetadataModel = { + isPaymentInProgress: true, + selectedPaymentOptionId: 111, + termsAndConditionsChecked: true, + paymentSessionId: mockPaymentSessionId, + }; + + opfMetadataStoreServiceMock.getOpfMetadataState.and.returnValue( + of(mockOpfMetadata) + ); + + service.verifyResultUrl(mockRouteSnapshot).subscribe((result) => { + expect(result.paymentSessionId).toEqual(mockPaymentSessionId); + expect(result.paramsMap).toEqual([ + { key: 'afterRedirectScriptFlag', value: 'true' }, + ]); + expect(result.afterRedirectScriptFlag).toEqual('true'); + done(); + }); + }); + + it('should throw an error if the route cxRoute is not "paymentVerificationResult"', (done) => { + const mockOtherRouteSnapshot: ActivatedRoute = { + routeConfig: { + data: { cxRoute: 'otherRoute' }, + }, + queryParams: of(), + } as unknown as ActivatedRoute; + + service.verifyResultUrl(mockOtherRouteSnapshot).subscribe( + () => {}, + (error) => { + expect(error.message).toEqual('opfPayment.errors.cancelPayment'); + done(); + } + ); + }); + + it('should throw an error if queryParams is undefined and paymentSessionId not in local storage', (done) => { + const mockOpfMetadata: OpfMetadataModel = { + isPaymentInProgress: true, + selectedPaymentOptionId: 111, + termsAndConditionsChecked: true, + paymentSessionId: undefined, + }; + + opfMetadataStoreServiceMock.getOpfMetadataState.and.returnValue( + of(mockOpfMetadata) + ); + + const mockRoute: ActivatedRoute = { + routeConfig: { + data: { + cxRoute: 'paymentVerificationResult', + }, + }, + queryParams: of(undefined), + } as unknown as ActivatedRoute; + + service.verifyResultUrl(mockRoute).subscribe( + () => {}, + (error) => { + expect(error).toBeDefined(); + expect(error.message).toEqual('opfPayment.errors.proceedPayment'); + done(); + } + ); + }); + + it('should throw an error if paymentSessionId is missing in url params and local storage', (done) => { + const mockOpfMetadata: OpfMetadataModel = { + isPaymentInProgress: true, + selectedPaymentOptionId: 111, + termsAndConditionsChecked: true, + paymentSessionId: undefined, + }; + + opfMetadataStoreServiceMock.getOpfMetadataState.and.returnValue( + of(mockOpfMetadata) + ); + + const mockRoute: ActivatedRoute = { + routeConfig: { + data: { + cxRoute: 'paymentVerificationResult', + }, + }, + queryParams: of({ mockKey: 'testKey' }), + } as unknown as ActivatedRoute; + + service.verifyResultUrl(mockRoute).subscribe( + () => {}, + (error) => { + expect(error).toBeDefined(); + expect(error.message).toEqual('opfPayment.errors.proceedPayment'); + done(); + } + ); + }); + }); + + describe('verifyPayment', () => { + it('should call opfCheckoutService.verifyPayment and return true if the result is AUTHORIZED', (done) => { + const mockPaymentSessionId = 'sessionId'; + const mockResponseMap = [{ key: 'key', value: 'value' }]; + const mockVerificationResponse: OpfPaymentVerificationResponse = { + result: OpfPaymentVerificationResult.AUTHORIZED, + }; + + opfPaymentServiceMock.verifyPayment.and.returnValue( + of(mockVerificationResponse) + ); + + const mockPlaceOrderResult: Order = { guid: 'placeOrderResult' }; + orderFacadeMock.placePaymentAuthorizedOrder.and.returnValue( + of(mockPlaceOrderResult) + ); + + service + .runHostedPagePattern(mockPaymentSessionId, mockResponseMap) + .subscribe((result) => { + expect(result).toBeTruthy(); + expect(opfPaymentServiceMock.verifyPayment).toHaveBeenCalledWith( + mockPaymentSessionId, + { responseMap: mockResponseMap } + ); + done(); + }); + }); + + it('should call opfCheckoutService.verifyPayment and return true if the result is DELAYED', (done) => { + const mockPaymentSessionId = 'sessionId'; + const mockResponseMap = [{ key: 'key', value: 'value' }]; + const mockVerificationResponse: OpfPaymentVerificationResponse = { + result: OpfPaymentVerificationResult.DELAYED, + }; + + const mockPlaceOrderResult: Order = { guid: 'placeOrderResult' }; + orderFacadeMock.placePaymentAuthorizedOrder.and.returnValue( + of(mockPlaceOrderResult) + ); + + opfPaymentServiceMock.verifyPayment.and.returnValue( + of(mockVerificationResponse) + ); + + service + .runHostedPagePattern(mockPaymentSessionId, mockResponseMap) + .subscribe((result) => { + expect(result).toBeTruthy(); + expect(opfPaymentServiceMock.verifyPayment).toHaveBeenCalledWith( + mockPaymentSessionId, + { responseMap: mockResponseMap } + ); + done(); + }); + }); + + it('should throw an error with "opfPayment.errors.cancelPayment" if the result is CANCELLED', (done) => { + const mockPaymentSessionId = 'sessionId'; + const mockResponseMap = [{ key: 'key', value: 'value' }]; + const mockVerificationResponse: OpfPaymentVerificationResponse = { + result: OpfPaymentVerificationResult.CANCELLED, + }; + + opfPaymentServiceMock.verifyPayment.and.returnValue( + of(mockVerificationResponse) + ); + + service + .runHostedPagePattern(mockPaymentSessionId, mockResponseMap) + .subscribe( + () => {}, + (error) => { + expect(error.message).toEqual('opfPayment.errors.cancelPayment'); + done(); + } + ); + }); + + it('should throw an error with defaultError if the result is not AUTHORIZED, DELAYED, or CANCELLED', (done) => { + const mockPaymentSessionId = 'sessionId'; + const mockResponseMap = [{ key: 'key', value: 'value' }]; + const mockVerificationResponse: OpfPaymentVerificationResponse = { + result: 'ERROR', + }; + + opfPaymentServiceMock.verifyPayment.and.returnValue( + of(mockVerificationResponse) + ); + + service + .runHostedPagePattern(mockPaymentSessionId, mockResponseMap) + .subscribe( + () => {}, + (error) => { + expect(error).toEqual(service.defaultError); + done(); + } + ); + }); + }); + + describe('runHostedFieldsPattern', () => { + const dynamicScriptMock: OpfDynamicScript = { + cssUrls: [{ url: 'css url test', sri: 'css sri test' }], + jsUrls: [{ url: 'js url test', sri: 'js sri test' }], + html: 'html test', + }; + + it('should call renderAfterRedirectScripts', (done) => { + opfPaymentServiceMock.afterRedirectScripts.and.returnValue( + of({ afterRedirectScript: dynamicScriptMock }) + ); + globalFunctionsServiceMock.registerGlobalFunctions.and.returnValue(); + opfResourceLoaderServiceMock.loadProviderResources.and.returnValue( + Promise.resolve() + ); + + opfResourceLoaderServiceMock.executeScriptFromHtml.and.returnValue(); + + service + .runHostedFieldsPattern( + GlobalFunctionsDomain.REDIRECT, + 'paymentSessionIdTest', + {} as ViewContainerRef, + [{ key: 'key test', value: 'value test' }] + ) + .subscribe((result) => { + expect( + opfResourceLoaderServiceMock.loadProviderResources + ).toHaveBeenCalled(); + expect( + opfResourceLoaderServiceMock.executeScriptFromHtml + ).toHaveBeenCalled(); + expect(result).toBeTruthy(); + done(); + }); + }); + + it('should not executeScriptFromHtml when no html snippet', (done) => { + opfPaymentServiceMock.afterRedirectScripts.and.returnValue( + of({ afterRedirectScript: { dynamicScriptMock, html: undefined } }) + ); + globalFunctionsServiceMock.registerGlobalFunctions.and.returnValue(); + opfResourceLoaderServiceMock.loadProviderResources.and.returnValue( + Promise.resolve() + ); + + opfResourceLoaderServiceMock.executeScriptFromHtml.and.returnValue(); + + service + .runHostedFieldsPattern( + GlobalFunctionsDomain.REDIRECT, + 'paymentSessionIdTest', + {} as ViewContainerRef, + [{ key: 'key test', value: 'value test' }] + ) + .subscribe((result) => { + expect( + opfResourceLoaderServiceMock.loadProviderResources + ).toHaveBeenCalled(); + expect( + opfResourceLoaderServiceMock.executeScriptFromHtml + ).not.toHaveBeenCalled(); + expect(result).toBeFalsy(); + done(); + }); + }); + + it('should failed when loadProviderResources fails', (done) => { + opfPaymentServiceMock.afterRedirectScripts.and.returnValue( + of({ afterRedirectScript: { dynamicScriptMock, html: undefined } }) + ); + globalFunctionsServiceMock.registerGlobalFunctions.and.returnValue(); + opfResourceLoaderServiceMock.loadProviderResources.and.returnValue( + Promise.reject() + ); + + opfResourceLoaderServiceMock.executeScriptFromHtml.and.returnValue(); + + service + .runHostedFieldsPattern( + GlobalFunctionsDomain.REDIRECT, + 'paymentSessionIdTest', + {} as ViewContainerRef, + [{ key: 'key test', value: 'value test' }] + ) + .subscribe((result) => { + expect( + opfResourceLoaderServiceMock.loadProviderResources + ).toHaveBeenCalled(); + expect( + opfResourceLoaderServiceMock.executeScriptFromHtml + ).not.toHaveBeenCalled(); + expect(result).toBeFalsy(); + done(); + }); + }); + + it('should throw error when missing afterRedirectScript property', (done) => { + opfPaymentServiceMock.afterRedirectScripts.and.returnValue( + of({ afterRedirectScript: undefined }) + ); + globalFunctionsServiceMock.registerGlobalFunctions.and.returnValue(); + spyOn(service, 'renderAfterRedirectScripts').and.returnValue( + Promise.resolve(true) + ); + + service + .runHostedFieldsPattern( + GlobalFunctionsDomain.REDIRECT, + 'paymentSessionIdTest', + {} as ViewContainerRef, + [{ key: 'key test', value: 'value test' }] + ) + .subscribe( + () => {}, + (error) => { + expect(error).toBeDefined(); + expect(error.message).toEqual('opfPayment.errors.proceedPayment'); + done(); + } + ); + }); + }); + + describe('removeResourcesAndGlobalFunctions', () => { + it('should should call psp resource clearing service and remove global functions', (done) => { + service.removeResourcesAndGlobalFunctions(); + expect( + globalFunctionsServiceMock.removeGlobalFunctions + ).toHaveBeenCalled(); + expect( + opfResourceLoaderServiceMock.clearAllProviderResources + ).toHaveBeenCalled(); + done(); + }); + }); + + describe('displayError', () => { + it('should display the provided error message as an error global message', () => { + const mockError: HttpErrorModel = { status: -1, message: 'Custom Error' }; + + service.displayError(mockError); + + expect(globalMessageServiceMock.add).toHaveBeenCalledWith( + { key: mockError.message }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }); + + it('should display default error message as an error global message when the provided error does not have status -1', () => { + const mockError: HttpErrorModel = { + status: 500, + message: 'Internal Server Error', + }; + + service.displayError(mockError); + + expect(globalMessageServiceMock.add).toHaveBeenCalledWith( + { key: 'opfPayment.errors.proceedPayment' }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }); + }); + + describe('checkIfProcessingCartIdExist', () => { + it('should not do anything if the opfMetadata isPaymentInProgress is true', () => { + const mockOpfMetadata: OpfMetadataModel = { + isPaymentInProgress: true, + selectedPaymentOptionId: 111, + termsAndConditionsChecked: true, + paymentSessionId: '111111', + }; + + opfMetadataStoreServiceMock.getOpfMetadataState.and.returnValue( + of(mockOpfMetadata) + ); + + service.checkIfProcessingCartIdExist(); + + expect( + opfMetadataStoreServiceMock.getOpfMetadataState + ).toHaveBeenCalled(); + expect(globalMessageServiceMock.add).not.toHaveBeenCalled(); + expect(routingServiceMock.go).not.toHaveBeenCalled(); + }); + + it('should go to "cart" page and add global error message if the opfMetadata isPaymentInProgress is false', () => { + const mockOpfMetadata: OpfMetadataModel = { + isPaymentInProgress: false, + selectedPaymentOptionId: 111, + termsAndConditionsChecked: true, + paymentSessionId: '111111', + }; + + opfMetadataStoreServiceMock.getOpfMetadataState.and.returnValue( + of(mockOpfMetadata) + ); + + service.checkIfProcessingCartIdExist(); + + expect( + opfMetadataStoreServiceMock.getOpfMetadataState + ).toHaveBeenCalled(); + expect(globalMessageServiceMock.add).toHaveBeenCalledWith( + { key: 'httpHandlers.cartNotFound' }, + GlobalMessageType.MSG_TYPE_ERROR + ); + expect(routingServiceMock.go).toHaveBeenCalledWith({ cxRoute: 'cart' }); + }); + }); +}); diff --git a/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.service.ts b/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.service.ts new file mode 100644 index 00000000000..87ac8dfc4a9 --- /dev/null +++ b/integration-libs/opf/payment/root/components/opf-payment-verification/opf-payment-verification.service.ts @@ -0,0 +1,265 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable, ViewContainerRef } from '@angular/core'; +import { ActivatedRoute, Params } from '@angular/router'; +import { + GlobalMessageService, + GlobalMessageType, + HttpErrorModel, + RoutingService, +} from '@spartacus/core'; + +import { + OpfDynamicScript, + OpfMetadataModel, + OpfMetadataStoreService, + OpfResourceLoaderService, +} from '@spartacus/opf/base/root'; +import { + GlobalFunctionsDomain, + OpfGlobalFunctionsFacade, +} from '@spartacus/opf/global-functions/root'; +import { Order, OrderFacade } from '@spartacus/order/root'; +import { Observable, from, of, throwError } from 'rxjs'; +import { concatMap, filter, map, take, tap } from 'rxjs/operators'; +import { OpfPaymentFacade } from '../../facade'; +import { + KeyValuePair, + OpfPage, + OpfPaymenVerificationUrlInput, + OpfPaymentVerificationResponse, + OpfPaymentVerificationResult, +} from '../../model'; + +@Injectable({ + providedIn: 'root', +}) +export class OpfPaymentVerificationService { + constructor( + protected orderFacade: OrderFacade, + protected routingService: RoutingService, + protected globalMessageService: GlobalMessageService, + protected opfPaymentFacade: OpfPaymentFacade, + protected opfMetadataStoreService: OpfMetadataStoreService, + protected opfResourceLoaderService: OpfResourceLoaderService, + protected globalFunctionsService: OpfGlobalFunctionsFacade + ) {} + + defaultError: HttpErrorModel = { + statusText: 'Payment Verification Error', + message: 'opfPayment.errors.proceedPayment', + status: -1, + }; + + protected getParamsMap(params: Params): Array { + return params + ? Object.entries(params).map((pair) => { + return { key: pair[0], value: pair[1] as string }; + }) + : []; + } + + protected findInParamsMap( + key: string, + list: Array + ): string | undefined { + return list.find((pair) => pair.key === key)?.value ?? undefined; + } + goToPage(cxRoute: string): void { + this.routingService.go({ cxRoute }); + } + + verifyResultUrl(route: ActivatedRoute): Observable<{ + paymentSessionId: string; + paramsMap: Array; + afterRedirectScriptFlag: string | undefined; + }> { + let paramsMap: Array; + return route?.routeConfig?.data?.cxRoute === OpfPage.RESULT_PAGE + ? route.queryParams.pipe( + concatMap((params: Params) => { + paramsMap = this.getParamsMap(params); + return this.getPaymentSessionId(paramsMap); + }), + concatMap((paymentSessionId: string | undefined) => { + if (!paymentSessionId) { + return throwError(this.defaultError); + } + return of({ + paymentSessionId, + paramsMap, + afterRedirectScriptFlag: this.findInParamsMap( + 'afterRedirectScriptFlag', + paramsMap + ), + }); + }) + ) + : throwError({ + ...this.defaultError, + message: 'opfPayment.errors.cancelPayment', + }); + } + + protected getPaymentSessionId( + paramMap: Array + ): Observable { + if (paramMap?.length) { + const paymentSessionId = this.findInParamsMap( + OpfPaymenVerificationUrlInput.PAYMENT_SESSION_ID, + paramMap + ); + return paymentSessionId + ? of(paymentSessionId) + : this.getPaymentSessionIdFromStorage(); + } + return this.getPaymentSessionIdFromStorage(); + } + + protected getPaymentSessionIdFromStorage(): Observable { + return this.opfMetadataStoreService.getOpfMetadataState().pipe( + take(1), + map((opfMetaData) => opfMetaData?.paymentSessionId) + ); + } + + protected placeOrder(): Observable { + return this.orderFacade.placePaymentAuthorizedOrder(true); + } + + protected verifyPayment( + paymentSessionId: string, + responseMap: Array + ): Observable { + return this.opfPaymentFacade + .verifyPayment(paymentSessionId, { + responseMap: [...responseMap], + }) + .pipe( + concatMap((response: OpfPaymentVerificationResponse) => + this.isPaymentSuccessful(response) + ) + ); + } + + protected isPaymentSuccessful( + response: OpfPaymentVerificationResponse + ): Observable { + if ( + response.result === OpfPaymentVerificationResult.AUTHORIZED || + response.result === OpfPaymentVerificationResult.DELAYED + ) { + return of(true); + } else if (response.result === OpfPaymentVerificationResult.CANCELLED) { + return throwError({ + ...this.defaultError, + message: 'opfPayment.errors.cancelPayment', + }); + } else { + return throwError(this.defaultError); + } + } + + displayError(error: HttpErrorModel | undefined): void { + this.globalMessageService.add( + { + key: + error?.message && error?.status === -1 + ? error.message + : 'opfPayment.errors.proceedPayment', + }, + GlobalMessageType.MSG_TYPE_ERROR + ); + } + + checkIfProcessingCartIdExist(): void { + this.opfMetadataStoreService + .getOpfMetadataState() + .pipe( + take(1), + filter((state: OpfMetadataModel) => state.isPaymentInProgress === false) + ) + .subscribe(() => { + this.goToPage(OpfPage.CART_PAGE); + + this.globalMessageService.add( + { + key: 'httpHandlers.cartNotFound', + }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }); + } + + runHostedPagePattern(paymentSessionId: string, paramsMap: KeyValuePair[]) { + return this.verifyPayment(paymentSessionId, paramsMap).pipe( + concatMap(() => { + return this.placeOrder(); + }), + map((order) => !!order), + tap((success: boolean) => { + if (success) { + this.goToPage(OpfPage.CONFIRMATION_PAGE); + } + }) + ); + } + + runHostedFieldsPattern( + domain: GlobalFunctionsDomain, + paymentSessionId: string, + vcr: ViewContainerRef, + paramsMap: Array + ): Observable { + this.globalFunctionsService.registerGlobalFunctions({ + domain, + paymentSessionId, + vcr, + paramsMap, + }); + + return this.opfPaymentFacade.afterRedirectScripts(paymentSessionId).pipe( + concatMap((response) => { + if (!response?.afterRedirectScript) { + return throwError(this.defaultError); + } + return from( + this.renderAfterRedirectScripts(response.afterRedirectScript) + ); + }) + ); + } + + protected renderAfterRedirectScripts( + script: OpfDynamicScript + ): Promise { + const html = script?.html; + + return new Promise((resolve: (value: boolean) => void) => { + this.opfResourceLoaderService + .loadProviderResources(script.jsUrls, script.cssUrls) + .then(() => { + if (html) { + this.opfResourceLoaderService.executeScriptFromHtml(html); + resolve(true); + } else { + resolve(false); + } + }) + .catch(() => { + resolve(false); + }); + }); + } + + removeResourcesAndGlobalFunctions(): void { + this.globalFunctionsService.removeGlobalFunctions( + GlobalFunctionsDomain.REDIRECT + ); + this.opfResourceLoaderService.clearAllProviderResources(); + } +} diff --git a/integration-libs/opf/payment/root/config/default-opf-payment-routing-config.ts b/integration-libs/opf/payment/root/config/default-opf-payment-routing-config.ts new file mode 100644 index 00000000000..2bf61866958 --- /dev/null +++ b/integration-libs/opf/payment/root/config/default-opf-payment-routing-config.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RoutingConfig } from '@spartacus/core'; + +export const defaultOpfPaymentRoutingConfig: RoutingConfig = { + routing: { + routes: { + paymentVerificationResult: { + paths: ['redirect/success'], + }, + paymentVerificationCancel: { + paths: ['redirect/failure'], + }, + }, + }, +}; diff --git a/integration-libs/opf/payment/root/config/index.ts b/integration-libs/opf/payment/root/config/index.ts new file mode 100644 index 00000000000..929f225e0e1 --- /dev/null +++ b/integration-libs/opf/payment/root/config/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './default-opf-payment-routing-config'; diff --git a/integration-libs/opf/payment/root/facade/index.ts b/integration-libs/opf/payment/root/facade/index.ts new file mode 100644 index 00000000000..caae084ff29 --- /dev/null +++ b/integration-libs/opf/payment/root/facade/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-payment.facade'; diff --git a/integration-libs/opf/payment/root/facade/opf-payment.facade.ts b/integration-libs/opf/payment/root/facade/opf-payment.facade.ts new file mode 100644 index 00000000000..95d7f7ef9c1 --- /dev/null +++ b/integration-libs/opf/payment/root/facade/opf-payment.facade.ts @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { facadeFactory } from '@spartacus/core'; +import { Observable } from 'rxjs'; +import { OPF_PAYMENT_FEATURE } from '../feature-name'; +import { + AfterRedirectScriptResponse, + OpfPaymentVerificationPayload, + OpfPaymentVerificationResponse, + PaymentInitiationConfig, + PaymentSessionData, + SubmitCompleteInput, + SubmitInput, +} from '../model'; + +@Injectable({ + providedIn: 'root', + useFactory: () => + facadeFactory({ + facade: OpfPaymentFacade, + feature: OPF_PAYMENT_FEATURE, + methods: [ + 'verifyPayment', + 'submitPayment', + 'submitCompletePayment', + 'afterRedirectScripts', + 'initiatePayment', + ], + }), +}) +export abstract class OpfPaymentFacade { + /** + * Abstract method to verify a response from PSP for Full Page Redirect + * and iframe integration patterns. + * + * @param {string} paymentSessionId + * @param {OpfPaymentVerificationPayload} paymentVerificationPayload + * + */ + abstract verifyPayment( + paymentSessionId: string, + paymentVerificationPayload: OpfPaymentVerificationPayload + ): Observable; + + /** + * Abstract method to submit payment for Hosted Fields pattern. + * + * @param {SubmitInput} submitInput + * + */ + abstract submitPayment(submitInput: SubmitInput): Observable; + + /** + * Abstract method to submit-complete payment + * for Hosted Fields pattern. + * + * @param {SubmitCompleteInput} submitCompleteInput + * + */ + abstract submitCompletePayment( + submitCompleteInput: SubmitCompleteInput + ): Observable; + + /** + * Abstract method to retrieve the dynamic scripts after redirect + * used in hosted-fields pattern. + * + * @param {string} paymentSessionId + * + */ + abstract afterRedirectScripts( + paymentSessionId: string + ): Observable; + + /** + * Abstract method used to initiate payment session + * or call the PSP to initiate. + * + * @param {PaymentInitiationConfig} paymentConfig + * + */ + abstract initiatePayment( + paymentConfig: PaymentInitiationConfig + ): Observable; +} diff --git a/integration-libs/opf/payment/root/feature-name.ts b/integration-libs/opf/payment/root/feature-name.ts new file mode 100644 index 00000000000..fc0dcaf1880 --- /dev/null +++ b/integration-libs/opf/payment/root/feature-name.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export const OPF_PAYMENT_FEATURE = 'opfPayment'; diff --git a/integration-libs/opf/payment/root/model/index.ts b/integration-libs/opf/payment/root/model/index.ts new file mode 100644 index 00000000000..e529a18c92f --- /dev/null +++ b/integration-libs/opf/payment/root/model/index.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-payment-error.model'; +export * from './opf-payment-verification.model'; +export * from './opf-payment.model'; diff --git a/integration-libs/opf/payment/root/model/opf-payment-error.model.ts b/integration-libs/opf/payment/root/model/opf-payment-error.model.ts new file mode 100644 index 00000000000..a58200bcb2a --- /dev/null +++ b/integration-libs/opf/payment/root/model/opf-payment-error.model.ts @@ -0,0 +1,72 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpErrorModel } from '@spartacus/core'; + +export const defaultError: OpfPaymentError = { + statusText: 'Payment Error', + message: 'opfPayment.errors.proceedPayment', + status: -1, + type: '', +}; + +export interface OpfPaymentError extends HttpErrorModel { + /** + * The type of error message for further clarity, lower case with underscore eg validation_failure + */ + type?: string; + /** + * The description of the error and, in some cases, a solution to the API consumer to resolve the issue. + */ + message: string; + /** + * An error can occur for multiple reasons, or it can be specified in more detail using a more precise error. + */ + details?: Array; + moreInfo?: string; + checkoutValidationMessage?: string; +} + +export interface ValidationFailedProduct { + productId?: string; + quantity?: number; + maxQuantity?: number; + minQuantity?: number; +} +export interface MoreInfo { + validationFailedProducts?: Array; + maxQuantity?: number; + currentOrderAmount?: number; + minOrderAmount?: number; +} +export interface PaymentErrorDetails { + /** + * The specific payload attribute or query parameter causing the error. + */ + field?: string; + /** + * Classification of the error detail type, lower case with underscore eg missing_value, + * this value must be always interpreted in context of the general error type. + */ + type: string; + /** + * The description of the error and, in some cases, a solution to the API consumer to resolve the issue. + */ + message?: string; + moreInfo?: string | MoreInfo; +} + +export const enum PaymentErrorType { + EXPIRED = 'EXPIRED', + INSUFFICENT_FUNDS = 'INSUFFICENT_FUNDS', + CREDIT_LIMIT = 'CREDIT_LIMIT', + INVALID_CARD = 'INVALID_CARD', + INVALID_CVV = 'INVALID_CVV', + LOST_CARD = 'LOST_CARD', + PAYMENT_REJECTED = 'PAYMENT_REJECTED', + PAYMENT_CANCELLED = 'PAYMENT_CANCELLED', + STATUS_NOT_RECOGNIZED = 'STATUS_NOT_RECOGNIZED', +} diff --git a/integration-libs/opf/payment/root/model/opf-payment-verification.model.ts b/integration-libs/opf/payment/root/model/opf-payment-verification.model.ts new file mode 100644 index 00000000000..2ae578ee4de --- /dev/null +++ b/integration-libs/opf/payment/root/model/opf-payment-verification.model.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { KeyValuePair } from '@spartacus/opf/base/root'; + +export interface OpfPaymentVerificationPayload { + responseMap: Array; +} + +export interface OpfPaymentVerificationResponse { + result: string; +} +export enum OpfPaymentVerificationResult { + AUTHORIZED = 'AUTHORIZED', + UNAUTHORIZED = 'UNAUTHORIZED', + CANCELLED = 'CANCELLED', + DELAYED = 'DELAYED', +} + +export enum OpfPaymenVerificationUrlInput { + PAYMENT_SESSION_ID = 'paymentSessionId', + ORDER_ID = 'orderId', +} diff --git a/integration-libs/opf/payment/root/model/opf-payment.model.ts b/integration-libs/opf/payment/root/model/opf-payment.model.ts new file mode 100644 index 00000000000..49d438c5171 --- /dev/null +++ b/integration-libs/opf/payment/root/model/opf-payment.model.ts @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ErrorDialogOptions, OpfDynamicScript } from '@spartacus/opf/base/root'; + +export interface KeyValuePair { + key: string; + value: string; +} + +export type MerchantCallback = ( + response?: SubmitResponse | SubmitCompleteResponse +) => void | Promise; + +export interface GlobalOpfPaymentMethods { + getRedirectParams?(): Array; + submit?(options: { + cartId: string; + additionalData: Array; + submitSuccess: MerchantCallback; + submitPending: MerchantCallback; + submitFailure: MerchantCallback; + paymentMethod: PaymentMethod; + }): Promise; + submitComplete?(options: { + cartId: string; + additionalData: Array; + submitSuccess: MerchantCallback; + submitPending: MerchantCallback; + submitFailure: MerchantCallback; + }): Promise; + submitCompleteRedirect?(options: { + cartId: string; + additionalData: Array; + submitSuccess: MerchantCallback; + submitPending: MerchantCallback; + submitFailure: MerchantCallback; + }): Promise; + throwPaymentError?(errorOptions?: ErrorDialogOptions): void; + startLoadIndicator?(): void; + stopLoadIndicator?(): void; + scriptReady?(scriptIdentifier: string): void; +} + +export interface PaymentBrowserInfo { + acceptHeader?: string; + colorDepth?: number; + javaEnabled?: boolean; + javaScriptEnabled?: boolean; + language?: string; + screenHeight?: number; + screenWidth?: number; + userAgent?: string; + timeZoneOffset?: number; + ipAddress?: string; + originUrl?: string; +} + +export interface SubmitRequest { + browserInfo?: PaymentBrowserInfo; + paymentMethod?: string; + encryptedToken?: string; + cartId?: string; + channel?: string; + additionalData?: Array; +} + +export interface SubmitInput { + additionalData: Array; + paymentSessionId?: string; + cartId: string; + callbackArray: [MerchantCallback, MerchantCallback, MerchantCallback]; + returnPath?: string; + paymentMethod: PaymentMethod; + encryptedToken?: string; +} + +export enum SubmitStatus { + REJECTED = 'REJECTED', + ACCEPTED = 'ACCEPTED', + PENDING = 'PENDING', + DELAYED = 'DELAYED', +} +export enum PaymentMethod { + CREDIT_CARD = 'CREDIT_CARD', +} +export interface SubmitResponse { + cartId: string; + status: SubmitStatus; + reasonCode: string; + paymentMethod: PaymentMethod; + authorizedAmount: number; + customFields: Array; +} + +export interface SubmitCompleteResponse { + cartId: string; + status: SubmitStatus; + reasonCode: number; + customFields: Array; +} + +export interface SubmitCompleteRequest { + paymentSessionId?: string; + additionalData?: Array; + cartId?: string; +} +export interface SubmitCompleteInput { + additionalData: Array; + paymentSessionId: string; + cartId: string; + callbackArray: [MerchantCallback, MerchantCallback, MerchantCallback]; + returnPath?: string; +} + +export interface AfterRedirectScriptResponse { + afterRedirectScript: OpfDynamicScript; +} + +export const defaultErrorDialogOptions: ErrorDialogOptions = { + messageKey: 'opfPayment.errors.proceedPayment', + confirmKey: 'common.continue', +}; + +export enum OpfPage { + CHECKOUT_REVIEW_PAGE = 'opfCheckoutPaymentAndReview', + CONFIRMATION_PAGE = 'orderConfirmation', + RESULT_PAGE = 'paymentVerificationResult', + CART_PAGE = 'cart', +} + +export enum GlobalFunctionsDomain { + CHECKOUT = 'checkout', + GLOBAL = 'global', + REDIRECT = 'redirect', +} + +export interface PaymentInitiationConfig { + otpKey?: string; + config?: PaymentConfig; +} + +export interface PaymentConfig { + configurationId?: string; + cartId?: string; + resultURL?: string; + cancelURL?: string; + channel?: string; + browserInfo?: PaymentBrowserInfo; +} + +export interface PaymentBrowserInfo { + acceptHeader?: string; + colorDepth?: number; + javaEnabled?: boolean; + javaScriptEnabled?: boolean; + language?: string; + screenHeight?: number; + screenWidth?: number; + userAgent?: string; + timeZoneOffset?: number; + ipAddress?: string; + originUrl?: string; +} + +export interface PaymentSessionFormField { + name?: string; + value?: string; +} + +export interface PaymentSessionData { + paymentSessionId?: string; + relayResultUrl?: string; + relayCancelUrl?: string; + paymentIntent?: string; + pattern?: PaymentPattern; + destination?: PaymentDestination; + dynamicScript?: OpfDynamicScript; +} + +export interface PaymentDestination { + url?: string; + method?: string; + contentType?: string; + body?: string; + authenticationIds?: number[]; + form?: PaymentSessionFormField[]; +} + +export enum PaymentPattern { + IFRAME = 'IFRAME', + FULL_PAGE = 'FULL_PAGE', + HOSTED_FIELDS = 'HOSTED_FIELDS', +} + +export interface OpfRenderPaymentMethodEvent { + isLoading: boolean; + isError: boolean; + renderType?: PaymentPattern; + data?: string | null; + destination?: PaymentDestination; +} diff --git a/integration-libs/opf/payment/root/ng-package.json b/integration-libs/opf/payment/root/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/payment/root/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/payment/root/opf-payment-root.module.ts b/integration-libs/opf/payment/root/opf-payment-root.module.ts new file mode 100644 index 00000000000..9aa53e719b5 --- /dev/null +++ b/integration-libs/opf/payment/root/opf-payment-root.module.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { provideDefaultConfig } from '@spartacus/core'; +import { OpfPaymentVerificationComponent } from './components/opf-payment-verification'; +import { defaultOpfPaymentRoutingConfig } from './config'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + // @ts-ignore + path: null, + component: OpfPaymentVerificationComponent, + data: { + cxRoute: 'paymentVerificationResult', + }, + }, + { + // @ts-ignore + path: null, + component: OpfPaymentVerificationComponent, + data: { + cxRoute: 'paymentVerificationCancel', + }, + }, + ]), + ], + providers: [provideDefaultConfig(defaultOpfPaymentRoutingConfig)], +}) +export class OpfPaymentRootModule {} diff --git a/integration-libs/opf/payment/root/public_api.ts b/integration-libs/opf/payment/root/public_api.ts new file mode 100644 index 00000000000..9c3305a1ea5 --- /dev/null +++ b/integration-libs/opf/payment/root/public_api.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './config/index'; +export * from './facade/index'; +export * from './feature-name'; +export * from './model/index'; +export * from './opf-payment-root.module'; diff --git a/integration-libs/opf/project.json b/integration-libs/opf/project.json new file mode 100644 index 00000000000..7e4bed2ba64 --- /dev/null +++ b/integration-libs/opf/project.json @@ -0,0 +1,47 @@ +{ + "name": "opf", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "integration-libs/opf", + "prefix": "cx", + "targets": { + "build": { + "executor": "@angular-devkit/build-angular:ng-packagr", + "options": { + "tsConfig": "integration-libs/opf/tsconfig.lib.json", + "project": "integration-libs/opf/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "integration-libs/opf/tsconfig.lib.prod.json" + } + } + }, + "test": { + "executor": "@angular-devkit/build-angular:karma", + "options": { + "main": "integration-libs/opf/test.ts", + "tsConfig": "integration-libs/opf/tsconfig.spec.json", + "polyfills": ["zone.js", "zone.js/testing"], + "karmaConfig": "integration-libs/opf/karma.conf.js" + } + }, + "test-jest": { + "executor": "nx:run-commands", + "options": { + "command": "npm run test:schematics", + "cwd": "integration-libs/opf" + } + }, + "lint": { + "executor": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": [ + "integration-libs/opf/**/*.ts", + "integration-libs/opf/**/*.html" + ] + } + } + }, + "tags": ["type:feature", "type:integration"] +} diff --git a/integration-libs/opf/public_api.ts b/integration-libs/opf/public_api.ts new file mode 100644 index 00000000000..b47535672ea --- /dev/null +++ b/integration-libs/opf/public_api.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export {}; diff --git a/integration-libs/opf/quick-buy/components/ng-package.json b/integration-libs/opf/quick-buy/components/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/quick-buy/components/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay-session/apple-pay-session.factory.spec.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay-session/apple-pay-session.factory.spec.ts new file mode 100644 index 00000000000..f9a4e26eb46 --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay-session/apple-pay-session.factory.spec.ts @@ -0,0 +1,216 @@ +/* + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { WindowRef } from '@spartacus/core'; +import { ApplePaySessionFactory } from './apple-pay-session.factory'; + +class MockApplePaySession + extends EventTarget + implements Partial +{ + static readonly STATUS_SUCCESS: number = 2; + + static readonly STATUS_FAILURE: number = 3; + + oncancel: (event: ApplePayJS.Event) => void; + + onpaymentauthorized: ( + event: ApplePayJS.ApplePayPaymentAuthorizedEvent + ) => void; + + onpaymentmethodselected: ( + event: ApplePayJS.ApplePayPaymentMethodSelectedEvent + ) => void; + + onshippingcontactselected: ( + event: ApplePayJS.ApplePayShippingContactSelectedEvent + ) => void; + + onshippingmethodselected: ( + event: ApplePayJS.ApplePayShippingMethodSelectedEvent + ) => void; + + onvalidatemerchant: (event: ApplePayJS.ApplePayValidateMerchantEvent) => void; + + _stubConstructorArguments: Array; + + constructor( + version: number, + paymentRequest: ApplePayJS.ApplePayPaymentRequest + ) { + super(); + this._stubConstructorArguments = [version, paymentRequest]; + } + + static canMakePayments(): boolean { + return true; + } + + static canMakePaymentsWithActiveCard( + _merchantIdentifier: string + ): Promise { + return Promise.resolve(true); + } + + static openPaymentSetup(_merchantIdentifier: string): Promise { + return Promise.resolve(true); + } + + static supportsVersion(_dialogCloseversion: number): boolean { + return true; + } + + abort(): void {} + + begin(): void {} + + completeMerchantValidation(_eventmerchantSession: any): void {} + + completePayment( + _result: number | ApplePayJS.ApplePayPaymentAuthorizationResult + ): void {} + + completePaymentMethodSelection( + _newTotal: ApplePayJS.ApplePayLineItem, + _newLineItems: Array + ): void; + completePaymentMethodSelection( + _update: ApplePayJS.ApplePayPaymentMethodUpdate + ): void; + completePaymentMethodSelection(_newTotal: any, _newLineItems?: any): void {} + + completeShippingContactSelection( + _status: number, + _newShippingMethods: Array, + _newTotal: ApplePayJS.ApplePayLineItem, + _newLineItems: Array + ): void; + completeShippingContactSelection( + _update: ApplePayJS.ApplePayShippingContactUpdate + ): void; + completeShippingContactSelection( + _status: any, + _newShippingMethods?: any, + _newTotal?: any, + _newLineItems?: any + ): void {} + + completeShippingMethodSelection( + status: number, + newTotal: ApplePayJS.ApplePayLineItem, + newLineItems: Array + ): void; + completeShippingMethodSelection( + update: ApplePayJS.ApplePayShippingMethodUpdate + ): void; + completeShippingMethodSelection( + _status: any, + _newTotal?: any, + _openElementnewLineItems?: any + ): void {} +} +@Injectable() +class ApplePaySessionFactoryExt extends ApplePaySessionFactory { + setIsDeviceSupported(newValue: boolean) { + this.isDeviceSupported = newValue; + } +} + +describe('ApplePaySessionFactory', () => { + let applePaySessionFactory: ApplePaySessionFactoryExt; + let windowRef: WindowRef; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: WindowRef, + useValue: { nativeWindow: { ApplePaySession: MockApplePaySession } }, + }, + ApplePaySessionFactoryExt, + ], + }); + + applePaySessionFactory = TestBed.inject(ApplePaySessionFactoryExt); + windowRef = TestBed.inject(WindowRef); + }); + + it('should be created', () => { + expect(applePaySessionFactory).toBeTruthy(); + }); + + it('should create ApplePaySession if available', () => { + const applePaySession = applePaySessionFactory['createApplePaySession'](); + expect(applePaySession).toBeDefined(); + }); + + it('should not create ApplePaySession if not available', () => { + (windowRef as any).nativeWindow['ApplePaySession'] = null; + const applePaySession = applePaySessionFactory['createApplePaySession'](); + expect(applePaySession).not.toBeDefined(); + }); + + it('should return STATUS_SUCCESS for statusSuccess when device is supported', () => { + applePaySessionFactory.setIsDeviceSupported(true); + expect(applePaySessionFactory.statusSuccess).toEqual( + MockApplePaySession.STATUS_SUCCESS + ); + }); + + it('should return 1 for statusSuccess when device is not supported', () => { + applePaySessionFactory.setIsDeviceSupported(false); + expect(applePaySessionFactory.statusSuccess).toEqual(1); + }); + + it('should return STATUS_FAILURE for statusFailure when device is supported', () => { + applePaySessionFactory.setIsDeviceSupported(true); + expect(applePaySessionFactory.statusFailure).toEqual( + MockApplePaySession.STATUS_FAILURE + ); + }); + + it('should return 1 for statusFailure when device is not supported', () => { + applePaySessionFactory.setIsDeviceSupported(false); + expect(applePaySessionFactory.statusFailure).toEqual(1); + }); + + it('should return true for isApplePaySupported$ when device is supported', (done: DoneFn) => { + applePaySessionFactory.setIsDeviceSupported(true); + const merchantId = 'merchantId'; + applePaySessionFactory + .isApplePaySupported$(merchantId) + .subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + it('should return false for isApplePaySupported$ when device is not supported', (done: DoneFn) => { + applePaySessionFactory.setIsDeviceSupported(false); + const merchantId = 'merchantId'; + applePaySessionFactory + .isApplePaySupported$(merchantId) + .subscribe((result) => { + expect(result).toEqual(false); + done(); + }); + }); + + it('should return ApplePaySession when device is supported', () => { + applePaySessionFactory.setIsDeviceSupported(true); + const startSession = applePaySessionFactory.startApplePaySession( + {} as ApplePayJS.ApplePayPaymentRequest + ); + expect(startSession).not.toEqual(undefined); + }); + + it('should not return ApplePaySession when device is not supported', () => { + applePaySessionFactory.setIsDeviceSupported(false); + const startSession = applePaySessionFactory.startApplePaySession( + {} as ApplePayJS.ApplePayPaymentRequest + ); + expect(startSession).toEqual(undefined); + }); +}); diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay-session/apple-pay-session.factory.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay-session/apple-pay-session.factory.ts new file mode 100644 index 00000000000..32088e1c73f --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay-session/apple-pay-session.factory.ts @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/// +import { Injectable, inject } from '@angular/core'; +import { WindowRef } from '@spartacus/core'; +import { Observable, from, of } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class ApplePaySessionFactory { + protected winRef = inject(WindowRef); + protected isDeviceSupported = false; + private applePaySession: typeof ApplePaySession; + protected applePayApiVersion = 3; + + constructor() { + // @ts-ignore + this.applePaySession = this.createApplePaySession() as ApplePaySession; + if (this.applePaySession) { + this.isDeviceSupported = this.applePaySession.canMakePayments(); + } + } + + private createApplePaySession(): ApplePaySession | undefined { + const window = this.winRef.nativeWindow as any; + if (!window['ApplePaySession']) { + return undefined; + } + return window['ApplePaySession'] as ApplePaySession; + } + + get statusSuccess(): number { + return this.isDeviceSupported ? this.applePaySession.STATUS_SUCCESS : 1; + } + + get statusFailure(): number { + return this.isDeviceSupported ? this.applePaySession.STATUS_FAILURE : 1; + } + + isApplePaySupported$(merchantIdentifier: string): Observable { + return this.isDeviceSupported && + this.supportsVersion(this.applePayApiVersion) + ? this.canMakePaymentsWithActiveCard(merchantIdentifier) + : of(false); + } + + protected supportsVersion(version: number): boolean { + try { + return ( + this.isDeviceSupported && this.applePaySession.supportsVersion(version) + ); + } catch (err) { + return false; + } + } + + protected canMakePayments(): boolean { + try { + return this.isDeviceSupported && this.applePaySession.canMakePayments(); + } catch (err) { + return false; + } + } + + protected canMakePaymentsWithActiveCard( + merchantId: string + ): Observable { + return this.isDeviceSupported + ? from(this.applePaySession.canMakePaymentsWithActiveCard(merchantId)) + : of(false); + } + + startApplePaySession(paymentRequest: any): any { + return this.isDeviceSupported + ? new this.applePaySession(this.applePayApiVersion, paymentRequest) + : undefined; + } +} diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay-session/index.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay-session/index.ts new file mode 100644 index 00000000000..fe475c2d8c8 --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay-session/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './apple-pay-session.factory'; diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.component.html b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.component.html new file mode 100644 index 00000000000..89e019cd8cd --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.component.html @@ -0,0 +1,3 @@ + +
+
diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.component.spec.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.component.spec.ts new file mode 100644 index 00000000000..02c7600384b --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.component.spec.ts @@ -0,0 +1,172 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Cart } from '@spartacus/cart/base/root'; +import { Product } from '@spartacus/core'; +import { ActiveConfiguration } from '@spartacus/opf/base/root'; +import { OpfPaymentErrorHandlerService } from '@spartacus/opf/payment/core'; +import { OpfQuickBuyTransactionService } from '@spartacus/opf/quick-buy/core'; +import { + OpfProviderType, + OpfQuickBuyDigitalWallet, + OpfQuickBuyLocation, +} from '@spartacus/opf/quick-buy/root'; +import { CurrentProductService } from '@spartacus/storefront'; +import { of } from 'rxjs'; +import { ApplePaySessionFactory } from './apple-pay-session'; +import { ApplePayComponent } from './apple-pay.component'; +import { ApplePayService } from './apple-pay.service'; + +const mockProduct: Product = { + name: 'mockProduct', + code: 'code1', + stock: { + stockLevel: 333, + stockLevelStatus: 'inStock', + }, +}; + +const mockCart: Cart = { + code: '123', +}; + +const mockActiveConfiguration: ActiveConfiguration = { + digitalWalletQuickBuy: [ + { + merchantId: 'merchant.com.adyen.upscale.test', + provider: OpfProviderType.APPLE_PAY, + countryCode: 'US', + }, + { merchantId: 'merchant.test.example' }, + ], +}; + +describe('ApplePayComponent', () => { + let component: ApplePayComponent; + let fixture: ComponentFixture; + let mockApplePayService: jasmine.SpyObj; + let mockCurrentProductService: jasmine.SpyObj; + let mockApplePaySessionFactory: jasmine.SpyObj; + let mockOpfPaymentErrorHandlerService: jasmine.SpyObj; + let mockOpfQuickBuyTransactionService: jasmine.SpyObj; + const mockCountryCode = 'US'; + + beforeEach(() => { + mockApplePayService = jasmine.createSpyObj('ApplePayService', ['start']); + mockCurrentProductService = jasmine.createSpyObj('CurrentProductService', [ + 'getProduct', + ]); + mockApplePaySessionFactory = jasmine.createSpyObj( + 'ApplePaySessionFactory', + ['isApplePaySupported$'] + ); + mockOpfPaymentErrorHandlerService = jasmine.createSpyObj( + 'OpfPaymentErrorHandlerService', + ['handlePaymentError'] + ); + mockOpfQuickBuyTransactionService = jasmine.createSpyObj( + 'OpfQuickBuyTransactionService', + ['getTransactionLocationContext', 'checkStableCart', 'getCurrentCart'] + ); + + TestBed.configureTestingModule({ + declarations: [ApplePayComponent], + providers: [ + { provide: ApplePayService, useValue: mockApplePayService }, + { provide: CurrentProductService, useValue: mockCurrentProductService }, + { + provide: ApplePaySessionFactory, + useValue: mockApplePaySessionFactory, + }, + { + provide: OpfPaymentErrorHandlerService, + useValue: mockOpfPaymentErrorHandlerService, + }, + { + provide: OpfQuickBuyTransactionService, + useValue: mockOpfQuickBuyTransactionService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ApplePayComponent); + component = fixture.componentInstance; + + const mockProductObservable = of(mockProduct); + const mockCartObservable = of(mockCart); + + mockCurrentProductService.getProduct.and.returnValue(mockProductObservable); + mockOpfQuickBuyTransactionService.getTransactionLocationContext.and.returnValue( + of(OpfQuickBuyLocation.PRODUCT) + ); + mockOpfQuickBuyTransactionService.getCurrentCart.and.returnValue( + mockCartObservable + ); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize isApplePaySupported$ provider is Apple pay', () => { + const digitalWallet: OpfQuickBuyDigitalWallet = { + provider: OpfProviderType.APPLE_PAY, + countryCode: mockCountryCode, + merchantId: 'merchant.com.adyen.upscale.test', + }; + component.activeConfiguration = { digitalWalletQuickBuy: [digitalWallet] }; + + const mockObservable = of(true); + mockApplePaySessionFactory.isApplePaySupported$.and.returnValue( + mockObservable + ); + + fixture.detectChanges(); + expect(component.isApplePaySupported$).toBe(mockObservable); + }); + + it('should not initialize isApplePaySupported$ provider is not Apple pay', () => { + const digitalWallet: OpfQuickBuyDigitalWallet = { + provider: OpfProviderType.GOOGLE_PAY, + countryCode: mockCountryCode, + merchantId: 'merchant.com.adyen.upscale.test', + }; + component.activeConfiguration = { digitalWalletQuickBuy: [digitalWallet] }; + + const mockObservable = of(true); + mockApplePaySessionFactory.isApplePaySupported$.and.returnValue( + mockObservable + ); + + fixture.detectChanges(); + expect( + mockApplePaySessionFactory.isApplePaySupported$ + ).not.toHaveBeenCalled(); + }); + + it('should start applePayService', () => { + mockApplePayService.start.and.returnValue( + of({ status: 1 }) + ); + component.activeConfiguration = { + digitalWalletQuickBuy: [ + { + provider: OpfProviderType.APPLE_PAY, + countryCode: mockCountryCode, + merchantId: 'merchant.com.adyen.upscale.test', + }, + ], + }; + component.activeConfiguration = mockActiveConfiguration; + fixture.detectChanges(); + + component.initTransaction(); + expect( + mockOpfPaymentErrorHandlerService.handlePaymentError + ).not.toHaveBeenCalled(); + }); +}); diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.component.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.component.ts new file mode 100644 index 00000000000..968cdc769b2 --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.component.ts @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ChangeDetectionStrategy, + Component, + Input, + OnInit, + inject, +} from '@angular/core'; +import { Cart } from '@spartacus/cart/base/root'; +import { ActiveConfiguration } from '@spartacus/opf/base/root'; +import { OpfQuickBuyTransactionService } from '@spartacus/opf/quick-buy/core'; +import { + OpfProviderType, + OpfQuickBuyDigitalWallet, +} from '@spartacus/opf/quick-buy/root'; +import { CurrentProductService } from '@spartacus/storefront'; +import { Observable } from 'rxjs'; +import { switchMap, take } from 'rxjs/operators'; +import { ApplePaySessionFactory } from './apple-pay-session'; +import { ApplePayService } from './apple-pay.service'; + +@Component({ + selector: 'cx-opf-apple-pay', + templateUrl: './apple-pay.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ApplePayComponent implements OnInit { + @Input() activeConfiguration: ActiveConfiguration; + + protected applePayService = inject(ApplePayService); + protected currentProductService = inject(CurrentProductService); + protected opfQuickBuyTransactionService = inject( + OpfQuickBuyTransactionService + ); + protected applePaySession = inject(ApplePaySessionFactory); + + isApplePaySupported$: Observable; + applePayDigitalWallet?: OpfQuickBuyDigitalWallet; + + ngOnInit(): void { + this.applePayDigitalWallet = + this.activeConfiguration?.digitalWalletQuickBuy?.find( + (digitalWallet) => digitalWallet.provider === OpfProviderType.APPLE_PAY + ); + if ( + !this.applePayDigitalWallet?.merchantId || + !this.applePayDigitalWallet?.countryCode + ) { + return; + } + + this.isApplePaySupported$ = this.applePaySession.isApplePaySupported$( + this.applePayDigitalWallet.merchantId + ); + } + + initActiveCartTransaction(): Observable { + return this.opfQuickBuyTransactionService.getCurrentCart().pipe( + take(1), + switchMap((cart: Cart) => { + return this.applePayService.start({ + cart: cart, + countryCode: this.applePayDigitalWallet?.countryCode as string, + }); + }) + ); + } + + initTransaction(): void { + this.opfQuickBuyTransactionService + .getTransactionLocationContext() + .pipe( + take(1), + switchMap(() => { + return this.initActiveCartTransaction(); + }) + ) + .subscribe(); + } +} diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.module.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.module.ts new file mode 100644 index 00000000000..6453df610fd --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.module.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { ApplePayComponent } from './apple-pay.component'; + +@NgModule({ + imports: [CommonModule], + declarations: [ApplePayComponent], + exports: [ApplePayComponent], +}) +export class OpfApplePayModule {} diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.service.spec.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.service.spec.ts new file mode 100644 index 00000000000..9f174446000 --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.service.spec.ts @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { Product, WindowRef } from '@spartacus/core'; +import { OpfPaymentService } from '@spartacus/opf/payment/core'; +import { OpfPaymentFacade } from '@spartacus/opf/payment/root'; +import { + OpfQuickBuyService, + OpfQuickBuyTransactionService, +} from '@spartacus/opf/quick-buy/core'; +import { OpfQuickBuyDeliveryType } from '@spartacus/opf/quick-buy/root'; +import { Subject, of, throwError } from 'rxjs'; +import { OpfQuickBuyButtonsService } from '../opf-quick-buy-buttons.service'; +import { ApplePaySessionFactory } from './apple-pay-session/apple-pay-session.factory'; +import { ApplePayService } from './apple-pay.service'; +import { ApplePayObservableFactory } from './observable/apple-pay-observable.factory'; + +const mockProduct: Product = { + name: 'Product Name', + code: 'PRODUCT_CODE', + images: { + PRIMARY: { + thumbnail: { + url: 'url', + altText: 'alt', + }, + }, + }, + price: { + formattedValue: '$1.500', + value: 1.5, + }, + priceRange: { + maxPrice: { + formattedValue: '$1.500', + }, + minPrice: { + formattedValue: '$1.000', + }, + }, +}; + +const MockWindowRef = { + nativeWindow: { + location: { + hostname: 'testHost', + }, + }, +}; + +describe('ApplePayService', () => { + let service: ApplePayService; + let opfPaymentFacadeMock: jasmine.SpyObj; + let opfQuickBuyTransactionServiceMock: jasmine.SpyObj; + let applePayObservableFactoryMock: jasmine.SpyObj; + let applePaySessionFactoryMock: jasmine.SpyObj; + let applePayObservableTestController: Subject; + let opfQuickBuyButtonsServiceMock: jasmine.SpyObj; + let opfQuickBuyServiceMock: jasmine.SpyObj; + + beforeEach(() => { + opfQuickBuyButtonsServiceMock = jasmine.createSpyObj( + 'OpfQuickBuyButtonsService', + ['getQuickBuyProviderConfig'] + ); + + applePaySessionFactoryMock = jasmine.createSpyObj( + 'ApplePaySessionFactory', + ['startApplePaySession'] + ); + opfPaymentFacadeMock = jasmine.createSpyObj('OpfPaymentService', [ + 'submitPayment', + ]); + opfQuickBuyServiceMock = jasmine.createSpyObj('OpfQuickBuyService', [ + 'getApplePayWebSession', + ]); + applePayObservableFactoryMock = jasmine.createSpyObj( + 'ApplePayObservableFactory', + ['initApplePayEventsHandler'] + ); + opfQuickBuyTransactionServiceMock = jasmine.createSpyObj( + 'OpfQuickBuyTransactionService', + [ + 'deleteUserAddresses', + 'getTransactionLocationContext', + 'getTransactionDeliveryInfo', + 'getMerchantName', + 'checkStableCart', + 'getSupportedDeliveryModes', + 'setDeliveryAddress', + 'setBillingAddress', + 'getDeliveryAddress', + 'getCurrentCart', + 'getCurrentCartId', + 'getCurrentCartTotalPrice', + 'setDeliveryMode', + 'getSelectedDeliveryMode', + 'deleteUserAddresses', + ] + ); + + TestBed.configureTestingModule({ + providers: [ + ApplePayService, + { provide: OpfPaymentFacade, useValue: opfPaymentFacadeMock }, + { provide: WindowRef, useValue: MockWindowRef }, + { + provide: OpfQuickBuyTransactionService, + useValue: opfQuickBuyTransactionServiceMock, + }, + { + provide: ApplePayObservableFactory, + useValue: applePayObservableFactoryMock, + }, + { + provide: ApplePaySessionFactory, + useValue: applePaySessionFactoryMock, + }, + { + provide: OpfQuickBuyButtonsService, + useValue: opfQuickBuyButtonsServiceMock, + }, + { + provide: OpfQuickBuyService, + useValue: opfQuickBuyServiceMock, + }, + ], + }); + service = TestBed.inject(ApplePayService); + + applePayObservableTestController = new Subject(); + applePayObservableFactoryMock.initApplePayEventsHandler.and.returnValue( + applePayObservableTestController + ); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should handle errors during ApplePay session start', () => { + service = TestBed.inject(ApplePayService); + const merchantNameMock = 'Nakano'; + opfQuickBuyTransactionServiceMock.getTransactionDeliveryInfo.and.returnValue( + of({ + type: OpfQuickBuyDeliveryType.SHIPPING, + }) + ); + + applePayObservableFactoryMock.initApplePayEventsHandler.and.returnValue( + throwError('Error') + ); + + opfQuickBuyTransactionServiceMock.getMerchantName.and.returnValue( + of(merchantNameMock) + ); + + service + .start({ product: mockProduct, quantity: 1, countryCode: 'us' }) + .subscribe({ + error: (err: any) => { + expect(err).toBe('Error'); + }, + }); + }); +}); diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.service.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.service.ts new file mode 100644 index 00000000000..4a447489c2a --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/apple-pay.service.ts @@ -0,0 +1,450 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/// +import { Injectable, inject } from '@angular/core'; +import { Address, WindowRef } from '@spartacus/core'; +import { Observable, forkJoin, of, throwError } from 'rxjs'; +import { + catchError, + finalize, + map, + switchMap, + take, + tap, +} from 'rxjs/operators'; + +import { Cart, DeliveryMode } from '@spartacus/cart/base/root'; +import { OpfPaymentFacade } from '@spartacus/opf/payment/root'; +import { OpfQuickBuyTransactionService } from '@spartacus/opf/quick-buy/core'; +import { + ApplePaySessionVerificationRequest, + ApplePaySessionVerificationResponse, + ApplePayTransactionInput, + OPF_QUICK_BUY_DEFAULT_MERCHANT_NAME, + OpfProviderType, + OpfQuickBuyDeliveryType, + OpfQuickBuyFacade, + OpfQuickBuyLocation, + QuickBuyTransactionDetails, +} from '@spartacus/opf/quick-buy/root'; +import { ApplePaySessionFactory } from './apple-pay-session/apple-pay-session.factory'; +import { ApplePayObservableFactory } from './observable/apple-pay-observable.factory'; + +@Injectable({ + providedIn: 'root', +}) +export class ApplePayService { + protected opfPaymentFacade = inject(OpfPaymentFacade); + protected applePaySession = inject(ApplePaySessionFactory); + protected applePayObservable = inject(ApplePayObservableFactory); + protected winRef = inject(WindowRef); + protected opfQuickBuyTransactionService = inject( + OpfQuickBuyTransactionService + ); + protected opfQuickBuyFacade = inject(OpfQuickBuyFacade); + protected paymentInProgress = false; + + protected initialTransactionDetails: QuickBuyTransactionDetails = { + context: OpfQuickBuyLocation.PRODUCT, + product: undefined, + cart: undefined, + quantity: 0, + addressIds: [], + total: { + label: OPF_QUICK_BUY_DEFAULT_MERCHANT_NAME, + amount: '', + currency: '', + }, + deliveryInfo: { + type: OpfQuickBuyDeliveryType.SHIPPING, + pickupDetails: undefined, + }, + }; + + protected transactionDetails = this.initialTransactionDetails; + + protected initTransactionDetails( + transactionInput: ApplePayTransactionInput + ): QuickBuyTransactionDetails { + this.transactionDetails = { + ...this.initialTransactionDetails, + addressIds: [], + }; + + if (transactionInput?.cart) { + this.transactionDetails = { + ...this.transactionDetails, + context: OpfQuickBuyLocation.CART, + cart: transactionInput.cart, + total: { + amount: `${transactionInput.cart.totalPrice?.value}`, + label: `${transactionInput.cart.code}`, + currency: transactionInput.cart?.totalPrice?.currencyIso as string, + }, + }; + } + + if (transactionInput?.product && transactionInput?.quantity) { + const productPrice = transactionInput.product.price?.value as number; + const totalPrice = productPrice * transactionInput.quantity; + + this.transactionDetails = { + ...this.transactionDetails, + context: OpfQuickBuyLocation.PRODUCT, + product: transactionInput.product, + quantity: transactionInput.quantity, + total: { + amount: totalPrice.toString(), + label: `${transactionInput.product?.name as string}${ + transactionInput.quantity > 1 + ? ` x ${transactionInput.quantity}` + : '' + }`, + currency: transactionInput.product?.price?.currencyIso as string, + }, + }; + } + + return this.transactionDetails; + } + + start(transactionInput: ApplePayTransactionInput): any { + if (this.paymentInProgress) { + return throwError(() => new Error('Apple Pay is already in progress')); + } + this.paymentInProgress = true; + + return this.setApplePayRequestConfig(transactionInput).pipe( + switchMap((request: ApplePayJS.ApplePayPaymentRequest) => { + return this.applePayObservable.initApplePayEventsHandler({ + request, + validateMerchant: (event) => this.handleValidation(event), + shippingContactSelected: (event) => + this.handleShippingContactSelected(event), + paymentMethodSelected: (event) => + this.handlePaymentMethodSelected(event), + shippingMethodSelected: (event) => + this.handleShippingMethodSelected(event), + paymentAuthorized: (event) => this.handlePaymentAuthorized(event), + }); + }), + take(1), + catchError((error) => throwError(() => error)), + finalize(() => { + this.deleteUserAddresses(); + this.paymentInProgress = false; + }) + ); + } + + protected deleteUserAddresses() { + if (this.transactionDetails.addressIds.length) { + this.opfQuickBuyTransactionService.deleteUserAddresses([ + ...this.transactionDetails.addressIds, + ]); + this.transactionDetails.addressIds = []; + } + } + + private handleValidation( + event: ApplePayJS.ApplePayValidateMerchantEvent + ): Observable { + return this.validateOpfAppleSession(event); + } + + protected setApplePayRequestConfig( + transactionInput: ApplePayTransactionInput + ): Observable { + this.transactionDetails = this.initTransactionDetails(transactionInput); + const countryCode = transactionInput?.countryCode || ''; + const initialRequest: ApplePayJS.ApplePayPaymentRequest = { + currencyCode: this.transactionDetails.total.currency, + total: { + amount: this.transactionDetails.total.amount, + label: this.transactionDetails.total.label, + }, + shippingMethods: [], + merchantCapabilities: ['supports3DS'], + supportedNetworks: ['visa', 'masterCard', 'amex', 'discover'], + requiredShippingContactFields: ['email', 'name', 'postalAddress'], + requiredBillingContactFields: ['email', 'name', 'postalAddress'], + countryCode, + }; + + return forkJoin({ + deliveryInfo: + this.opfQuickBuyTransactionService.getTransactionDeliveryInfo(), + merchantName: this.opfQuickBuyTransactionService.getMerchantName(), + }).pipe( + switchMap(({ deliveryInfo, merchantName }) => { + this.transactionDetails.total.label = merchantName; + initialRequest.total.label = merchantName; + this.transactionDetails.deliveryInfo = deliveryInfo; + + return of(undefined); + }), + map((opfQuickBuyDeliveryInfo) => { + if (!opfQuickBuyDeliveryInfo) { + return initialRequest; + } + this.transactionDetails.deliveryInfo = opfQuickBuyDeliveryInfo; + return initialRequest; + }) + ); + } + + private validateOpfAppleSession( + event: ApplePayJS.ApplePayValidateMerchantEvent + ): Observable { + return this.opfQuickBuyTransactionService.getCurrentCartId().pipe( + switchMap((cartId: string) => { + const verificationRequest: ApplePaySessionVerificationRequest = { + validationUrl: event.validationURL, + initiative: 'web', + initiativeContext: (this.winRef?.nativeWindow as Window).location + ?.hostname, + cartId, + }; + return this.verifyApplePaySession(verificationRequest); + }) + ); + } + + private convertAppleToOpfAddress( + addr: ApplePayJS.ApplePayPaymentContact, + partial = false + ): Address { + const ADDRESS_FIELD_PLACEHOLDER = '[FIELD_NOT_SET]'; + return { + firstName: partial ? ADDRESS_FIELD_PLACEHOLDER : addr?.givenName, + lastName: partial ? ADDRESS_FIELD_PLACEHOLDER : addr?.familyName, + line1: partial ? ADDRESS_FIELD_PLACEHOLDER : addr?.addressLines?.[0], + line2: addr?.addressLines?.[1], + email: addr?.emailAddress, + town: addr?.locality, + district: addr?.administrativeArea, + postalCode: addr?.postalCode, + phone: addr?.phoneNumber, + country: { + isocode: addr?.countryCode, + name: addr?.country, + }, + defaultAddress: false, + }; + } + + private handleShippingContactSelected( + _event: ApplePayJS.ApplePayShippingContactSelectedEvent + ): Observable { + const partialAddress: Address = this.convertAppleToOpfAddress( + _event.shippingContact, + true + ); + + const result: ApplePayJS.ApplePayShippingContactUpdate = + this.updateApplePayForm({ ...this.transactionDetails.total }); + + return this.opfQuickBuyTransactionService + .setDeliveryAddress(partialAddress) + .pipe( + tap((addrId: string) => { + this.recordDeliveryAddress(addrId); + }), + switchMap(() => + this.opfQuickBuyTransactionService.getSupportedDeliveryModes() + ), + take(1), + map((modes: DeliveryMode[]) => { + if (!modes.length) { + return of({ + ...result, + errors: [ + this.updateApplePayFormWithError( + 'No shipment methods available for this delivery address' + ), + ], + }); + } + const newShippingMethods = modes.map((mode) => { + return { + identifier: mode.code as string, + label: mode.name as string, + amount: (mode.deliveryCost?.value as number).toFixed(2), + detail: mode.description ?? (mode.name as string), + }; + }); + result.newShippingMethods = newShippingMethods; + return result; + }), + switchMap(() => { + return this.opfQuickBuyTransactionService.getCurrentCartTotalPrice(); + }), + switchMap((price: number | undefined) => { + if (!price) { + return throwError(() => new Error('Total Price not available')); + } + this.transactionDetails.total.amount = price.toString(); + result.newTotal.amount = price.toString(); + return of(result); + }) + ); + } + + private handlePaymentMethodSelected( + _event: ApplePayJS.ApplePayPaymentMethodSelectedEvent + ): Observable { + const result: ApplePayJS.ApplePayPaymentMethodUpdate = + this.updateApplePayForm({ ...this.transactionDetails.total }); + return of(result); + } + + private handleShippingMethodSelected( + _event: ApplePayJS.ApplePayShippingMethodSelectedEvent + ): Observable { + const result: ApplePayJS.ApplePayShippingContactUpdate = + this.updateApplePayForm({ ...this.transactionDetails.total }); + + return this.opfQuickBuyTransactionService + .setDeliveryMode(_event.shippingMethod.identifier) + .pipe( + switchMap(() => this.opfQuickBuyTransactionService.getCurrentCart()), + take(1), + switchMap((cart: Cart) => { + if (!cart?.totalPrice?.value) { + return throwError(() => new Error('Total Price not available')); + } + result.newTotal.amount = cart.totalPrice.value.toString(); + this.transactionDetails.total.amount = + cart.totalPrice.value.toString(); + return of(result); + }) + ); + } + + private handlePaymentAuthorized( + event: ApplePayJS.ApplePayPaymentAuthorizedEvent + ): Observable { + const result: ApplePayJS.ApplePayPaymentAuthorizationResult = { + status: this.applePaySession.statusSuccess, + }; + let orderSuccess: boolean; + return this.placeOrderAfterPayment(event.payment).pipe( + map((success) => { + orderSuccess = success; + return orderSuccess + ? result + : { ...result, status: this.applePaySession.statusFailure }; + }), + catchError((error) => { + return of({ + ...result, + status: this.applePaySession.statusFailure, + errors: [ + this.updateApplePayFormWithError(error?.message ?? 'Payment error'), + ], + } as ApplePayJS.ApplePayPaymentAuthorizationResult); + }) + ); + } + + private verifyApplePaySession( + request: ApplePaySessionVerificationRequest + ): Observable { + return this.opfQuickBuyFacade.getApplePayWebSession(request); + } + + protected recordDeliveryAddress(addrId: string): void { + if (!this.transactionDetails.addressIds?.includes(addrId)) { + this.transactionDetails.addressIds?.push(addrId); + } + } + + private placeOrderAfterPayment( + applePayPayment: ApplePayJS.ApplePayPayment + ): Observable { + if (!applePayPayment) { + return of(false); + } + const { shippingContact, billingContact } = applePayPayment; + if (!billingContact) { + throw new Error('Error: empty billingContact'); + } + if ( + this.transactionDetails.deliveryInfo?.type === + OpfQuickBuyDeliveryType.SHIPPING && + !shippingContact + ) { + throw new Error('Error: empty shippingContact'); + } + + const deliveryTypeHandlingObservable: Observable = + this.transactionDetails.deliveryInfo?.type === + OpfQuickBuyDeliveryType.PICKUP + ? this.opfQuickBuyTransactionService + .setDeliveryMode(OpfQuickBuyDeliveryType.PICKUP.toLocaleLowerCase()) + .pipe( + switchMap(() => { + return this.opfQuickBuyTransactionService.setBillingAddress( + this.convertAppleToOpfAddress(billingContact) + ); + }) + ) + : this.opfQuickBuyTransactionService + .setDeliveryAddress( + this.convertAppleToOpfAddress( + shippingContact as ApplePayJS.ApplePayPaymentContact + ) + ) + .pipe( + tap((addrId: string) => { + this.recordDeliveryAddress(addrId); + }), + switchMap(() => { + return this.opfQuickBuyTransactionService.setBillingAddress( + this.convertAppleToOpfAddress(billingContact) + ); + }) + ); + + return deliveryTypeHandlingObservable.pipe( + switchMap(() => this.opfQuickBuyTransactionService.getCurrentCartId()), + switchMap((cartId: string) => { + const encryptedToken = btoa( + JSON.stringify(applePayPayment.token.paymentData) + ); + + return this.opfPaymentFacade.submitPayment({ + additionalData: [], + paymentSessionId: '', + callbackArray: [() => {}, () => {}, () => {}], + paymentMethod: OpfProviderType.APPLE_PAY as any, + encryptedToken, + cartId, + }); + }) + ); + } + + protected updateApplePayForm(total: { amount: string; label: string }) { + return { + newTotal: { + amount: total.amount, + label: total.label, + }, + }; + } + + protected updateApplePayFormWithError( + message: string, + code = 'unknown' + ): { code: string; message: string } { + return { + code, + message, + }; + } +} diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/index.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/index.ts new file mode 100644 index 00000000000..75d1549ec3b --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/index.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './apple-pay.component'; +export * from './apple-pay.module'; +export * from './apple-pay.service'; diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/observable/apple-pay-observable.factory.spec.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/observable/apple-pay-observable.factory.spec.ts new file mode 100644 index 00000000000..31da26259a3 --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/observable/apple-pay-observable.factory.spec.ts @@ -0,0 +1,500 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { ApplePayObservableConfig } from '@spartacus/opf/quick-buy/root'; +import { Observable, of, throwError } from 'rxjs'; +import { ApplePaySessionFactory } from '../apple-pay-session/apple-pay-session.factory'; +import { ApplePayObservableFactory } from './apple-pay-observable.factory'; + +class MockEventTarget implements EventTarget { + _stubEventListeners: Array<{ type: string; listener: Function }> = []; + + /** Method to call registered events of specified type with provided arguments */ + _stubMockEvent(type: string, ...args: Array): void { + this._stubEventListeners.forEach((listener) => { + if (listener.type === type) { + listener.listener(...args); + } + }); + } + + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + _options?: boolean | AddEventListenerOptions + ): void { + this._stubEventListeners.push({ type, listener: listener as Function }); + } + + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + _options?: boolean | EventListenerOptions + ): void { + const index = this._stubEventListeners.findIndex( + (registeredListener) => + registeredListener.type === type && + registeredListener.listener === listener + ); + + if (index > -1) { + this._stubEventListeners.splice(index, 1); + } + } + + dispatchEvent(_evt: Event): boolean { + return true; + } +} + +class MockApplePaySession + extends MockEventTarget + implements Partial +{ + static readonly STATUS_SUCCESS: number; + + static readonly STATUS_FAILURE: number; + + oncancel: (event: ApplePayJS.Event) => void; + + onpaymentauthorized: ( + event: ApplePayJS.ApplePayPaymentAuthorizedEvent + ) => void; + + onpaymentmethodselected: ( + event: ApplePayJS.ApplePayPaymentMethodSelectedEvent + ) => void; + + onshippingcontactselected: ( + event: ApplePayJS.ApplePayShippingContactSelectedEvent + ) => void; + + onshippingmethodselected: ( + event: ApplePayJS.ApplePayShippingMethodSelectedEvent + ) => void; + + onvalidatemerchant: (event: ApplePayJS.ApplePayValidateMerchantEvent) => void; + + _stubConstructorArguments: Array; + + constructor( + version: number, + paymentRequest: ApplePayJS.ApplePayPaymentRequest + ) { + super(); + this._stubConstructorArguments = [version, paymentRequest]; + } + + static canMakePayments(): boolean { + return true; + } + + static canMakePaymentsWithActiveCard( + _merchantIdentifier: string + ): Promise { + return Promise.resolve(true); + } + + static openPaymentSetup(_merchantIdentifier: string): Promise { + return Promise.resolve(true); + } + + static supportsVersion(_version: number): boolean { + return true; + } + + abort(): void {} + + begin(): void {} + + completeMerchantValidation(_merchantSession: any): void {} + + completePayment( + _result: number | ApplePayJS.ApplePayPaymentAuthorizationResult + ): void {} + + completePaymentMethodSelection( + newTotal: ApplePayJS.ApplePayLineItem, + newLineItems: Array + ): void; + completePaymentMethodSelection( + update: ApplePayJS.ApplePayPaymentMethodUpdate + ): void; + completePaymentMethodSelection(_newTotal: any, _newLineItems?: any): void {} + + completeShippingContactSelection( + _status: number, + _newShippingMethods: Array, + _newTotal: ApplePayJS.ApplePayLineItem, + _newLineItems: Array + ): void; + completeShippingContactSelection( + _update: ApplePayJS.ApplePayShippingContactUpdate + ): void; + completeShippingContactSelection( + _status: any, + _newShippingMethods?: any, + _newTotal?: any, + _newLineItems?: any + ): void {} + + completeShippingMethodSelection( + _status: number, + _newTotal: ApplePayJS.ApplePayLineItem, + _newLineItems: Array + ): void; + completeShippingMethodSelection( + _update: ApplePayJS.ApplePayShippingMethodUpdate + ): void; + completeShippingMethodSelection( + _status: any, + _newTotal?: any, + _newLineItems?: any + ): void {} +} + +interface ApplePayObservableConfigExt extends ApplePayObservableConfig { + [key: string]: any; +} + +describe('ApplePayObservableFactory', () => { + let factory: ApplePayObservableFactory; + let mockApplePaySessionFactory: jasmine.SpyObj; + let mockApplePaySession: MockApplePaySession; + + const mockRequest: ApplePayJS.ApplePayPaymentRequest = { + countryCode: '', + currencyCode: '', + merchantCapabilities: [], + supportedNetworks: [], + total: { + label: '', + amount: '', + }, + }; + const mockConfig: ApplePayObservableConfig = { + request: mockRequest, + validateMerchant: () => of({}), + paymentMethodSelected: () => + of({} as ApplePayJS.ApplePayPaymentMethodUpdate), + shippingContactSelected: () => + of({} as ApplePayJS.ApplePayShippingContactUpdate), + shippingMethodSelected: () => + of({} as ApplePayJS.ApplePayShippingMethodUpdate), + paymentAuthorized: () => + of({} as ApplePayJS.ApplePayPaymentAuthorizationResult), + }; + + beforeEach(() => { + const MockApplePaySessionFactory = jasmine.createSpyObj( + 'ApplePaySessionFactory', + ['startApplePaySession'] + ); + + TestBed.configureTestingModule({ + providers: [ + ApplePayObservableFactory, + { + provide: ApplePaySessionFactory, + useValue: MockApplePaySessionFactory, + }, + ], + }); + + factory = TestBed.inject(ApplePayObservableFactory); + mockApplePaySessionFactory = TestBed.inject( + ApplePaySessionFactory + ) as jasmine.SpyObj; + + mockApplePaySession = new MockApplePaySession( + 1, + {} as ApplePayJS.ApplePayPaymentRequest + ); + spyOn(mockApplePaySession, 'addEventListener').and.callThrough(); + }); + + it('should be created', () => { + expect(factory).toBeTruthy(); + }); + + it('should return an observable that creates an apple pay session', () => { + const actual = factory.initApplePayEventsHandler({ + ...mockConfig, + }); + + mockApplePaySessionFactory.startApplePaySession.and.returnValue( + mockApplePaySession + ); + expect(actual instanceof Observable).toBe(true); + expect( + mockApplePaySessionFactory.startApplePaySession + ).not.toHaveBeenCalled(); + + actual.subscribe(); + + expect( + mockApplePaySessionFactory.startApplePaySession + ).toHaveBeenCalledTimes(1); + expect( + mockApplePaySessionFactory.startApplePaySession + ).toHaveBeenCalledWith(mockRequest); + }); + + it('should bind config event handlers to ApplePaySession', () => { + mockApplePaySessionFactory.startApplePaySession.and.returnValue( + mockApplePaySession + ); + factory.initApplePayEventsHandler(mockConfig).subscribe(); + + expect(mockApplePaySession.addEventListener).toHaveBeenCalledTimes(6); + expect(mockApplePaySession.addEventListener).toHaveBeenCalledWith( + 'cancel', + jasmine.any(Function) + ); + expect(mockApplePaySession.addEventListener).toHaveBeenCalledWith( + 'paymentauthorized', + jasmine.any(Function) + ); + expect(mockApplePaySession.addEventListener).toHaveBeenCalledWith( + 'paymentmethodselected', + jasmine.any(Function) + ); + expect(mockApplePaySession.addEventListener).toHaveBeenCalledWith( + 'shippingcontactselected', + jasmine.any(Function) + ); + expect(mockApplePaySession.addEventListener).toHaveBeenCalledWith( + 'shippingmethodselected', + jasmine.any(Function) + ); + expect(mockApplePaySession.addEventListener).toHaveBeenCalledWith( + 'validatemerchant', + jasmine.any(Function) + ); + }); + + it('should complete if the session is cancelled', () => { + let complete = false; + let actualEmit: any; + let actualError: any; + spyOn(mockApplePaySession, 'abort').and.stub(); + + mockApplePaySessionFactory.startApplePaySession.and.returnValue( + mockApplePaySession + ); + factory.initApplePayEventsHandler(mockConfig).subscribe( + (next) => (actualEmit = next), + (error) => (actualError = error), + () => (complete = true) + ); + expect(complete).toBe(false); + expect(actualError).toEqual(undefined); + expect(actualEmit).toEqual(undefined); + + mockApplePaySession._stubMockEvent('cancel'); + + expect(complete).toBe(false); + expect(actualError).toEqual({ type: 'PAYMENT_CANCELLED' }); + expect(actualEmit).toEqual(undefined); + }); + + describe('callback behavior', () => { + let actual: Observable; + let configuration: ApplePayObservableConfigExt; + + let paymentAuthorizedReturnValue: ApplePayJS.ApplePayPaymentAuthorizationResult; + let validateMerchantReturnValue: Object; + let shippingContactSelectedReturnValue: ApplePayJS.ApplePayShippingContactUpdate; + let paymentMethodSelectedReturnValue: ApplePayJS.ApplePayPaymentMethodUpdate; + let shippingMethodSelectedReturnValue: ApplePayJS.ApplePayShippingMethodUpdate; + let actualEmit: any; + let actualError: any; + let actualComplete: boolean; + beforeEach(() => { + configuration = { + request: mockRequest, + paymentAuthorized: jasmine.createSpy('paymentAuthorized'), + validateMerchant: jasmine.createSpy('validateMerchant'), + shippingContactSelected: jasmine.createSpy('shippingContactSelected'), + paymentMethodSelected: jasmine.createSpy('paymentMethodSelected'), + shippingMethodSelected: jasmine.createSpy('shippingMethodSelected'), + }; + + paymentAuthorizedReturnValue = { status: 0 }; + validateMerchantReturnValue = {}; + shippingContactSelectedReturnValue = { + newTotal: { amount: '5.00', label: 'Shipping' }, + }; + paymentMethodSelectedReturnValue = { + newTotal: { amount: '5.00', label: 'Payment' }, + }; + shippingMethodSelectedReturnValue = { + newTotal: { amount: '5.00', label: 'Shipping' }, + }; + + (configuration.paymentAuthorized as jasmine.Spy).and.returnValue( + of(paymentAuthorizedReturnValue) + ); + (configuration.validateMerchant as jasmine.Spy).and.returnValue( + of(validateMerchantReturnValue) + ); + (configuration.shippingContactSelected as jasmine.Spy).and.returnValue( + of(shippingContactSelectedReturnValue) + ); + (configuration.paymentMethodSelected as jasmine.Spy).and.returnValue( + of(paymentMethodSelectedReturnValue) + ); + (configuration.shippingMethodSelected as jasmine.Spy).and.returnValue( + of(shippingMethodSelectedReturnValue) + ); + mockApplePaySessionFactory.startApplePaySession.and.returnValue( + mockApplePaySession + ); + actual = factory.initApplePayEventsHandler(configuration); + actualEmit = undefined; + actualError = undefined; + actualComplete = false; + actual.subscribe( + (next) => (actualEmit = next), + (error) => (actualError = error), + () => (actualComplete = true) + ); + + spyOn(mockApplePaySession, 'completeMerchantValidation').and.stub(); + spyOn(mockApplePaySession, 'completePayment').and.stub(); + spyOn(mockApplePaySession, 'completePaymentMethodSelection').and.stub(); + spyOn(mockApplePaySession, 'completeShippingContactSelection').and.stub(); + spyOn(mockApplePaySession, 'completeShippingMethodSelection').and.stub(); + }); + + it('should use validateMerchant callback to fill validateMerchant', () => { + const event = { + validationURL: '', + }; + + mockApplePaySession._stubMockEvent('validatemerchant', event); + expect(actualComplete).toBe(false); + expect(configuration.validateMerchant).toHaveBeenCalledWith(event); + expect( + mockApplePaySession.completeMerchantValidation + ).toHaveBeenCalledWith(validateMerchantReturnValue); + }); + + it('should use shippingContactSelected callback to fill shippingContactSelected', () => { + const event = { + shippingContact: {}, + }; + + mockApplePaySession._stubMockEvent('shippingcontactselected', event); + + expect(configuration.shippingContactSelected).toHaveBeenCalledWith(event); + expect( + mockApplePaySession.completeShippingContactSelection + ).toHaveBeenCalledWith(shippingContactSelectedReturnValue); + }); + + it('should use shippingMethodSelected callback to fill shippingMethodSelected', () => { + const event = { + shippingMethod: {}, + }; + + mockApplePaySession._stubMockEvent('shippingmethodselected', event); + + expect(configuration.shippingMethodSelected).toHaveBeenCalledWith(event); + expect( + mockApplePaySession.completeShippingMethodSelection + ).toHaveBeenCalledWith(shippingMethodSelectedReturnValue); + }); + + it('should use paymentMethodSelected callback to fill paymentMethodSelected', () => { + const event = { + paymentMethod: {}, + }; + + mockApplePaySession._stubMockEvent('paymentmethodselected', event); + + expect(configuration.paymentMethodSelected).toHaveBeenCalledWith(event); + expect( + mockApplePaySession.completePaymentMethodSelection + ).toHaveBeenCalledWith(paymentMethodSelectedReturnValue); + }); + + it('should use paymentAuthorized callback to fill completePayment', () => { + const event = { + payment: {}, + }; + mockApplePaySession._stubMockEvent('paymentauthorized', event); + + expect(configuration.paymentAuthorized).toHaveBeenCalledWith(event); + expect(mockApplePaySession.completePayment).toHaveBeenCalledWith( + paymentAuthorizedReturnValue + ); + }); + + describe('on callback error', () => { + it('should emit an error when validateMerchant failed', () => { + const event = { + validationURL: '', + }; + (configuration.validateMerchant as jasmine.Spy).and.returnValue( + throwError(new Error('Validation Error')) + ); + + mockApplePaySession._stubMockEvent('validatemerchant', event); + + expect(configuration.validateMerchant).toHaveBeenCalledWith(event); + expect( + mockApplePaySession.completeMerchantValidation + ).not.toHaveBeenCalled(); + expect(actualError).toBeDefined(); + }); + + it('should abort when paymentauthorized failed with errors', () => { + spyOn(mockApplePaySession, 'abort').and.stub(); + const event = { + payment: {}, + }; + const paymentAuthorizedReturnValue = { + status: 0, + errors: [{ message: 'paymentauthorized failed' }], + }; + (configuration.paymentAuthorized as jasmine.Spy).and.returnValue( + of(paymentAuthorizedReturnValue) + ); + + mockApplePaySession._stubMockEvent('paymentauthorized', event); + + expect(configuration.paymentAuthorized).toHaveBeenCalledWith(event); + expect(mockApplePaySession.abort).toHaveBeenCalled(); + }); + + [ + ['paymentAuthorized', 'paymentauthorized'], + ['paymentMethodSelected', 'paymentmethodselected'], + ['shippingContactSelected', 'shippingcontactselected'], + ['shippingMethodSelected', 'shippingmethodselected'], + ['validateMerchant', 'validatemerchant'], + ].forEach(([callback, eventType]) => { + it(`should abort the session on an error in ${callback}`, () => { + const event = {}; + const callbackError = new Error('Error'); + (configuration[callback] as jasmine.Spy).and.returnValue( + throwError(callbackError) + ); + spyOn(mockApplePaySession, 'abort').and.stub(); + + mockApplePaySession._stubMockEvent(eventType, event); + + expect(configuration[callback]).toHaveBeenCalledWith(event); + expect(mockApplePaySession.abort).toHaveBeenCalled(); + expect(actualEmit).toBeUndefined(); + expect(actualError).toBe(callbackError); + }); + }); + }); + }); +}); diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/observable/apple-pay-observable.factory.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/observable/apple-pay-observable.factory.ts new file mode 100644 index 00000000000..14571dcf90d --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/observable/apple-pay-observable.factory.ts @@ -0,0 +1,149 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/// +import { Injectable, inject } from '@angular/core'; +import { PaymentErrorType } from '@spartacus/opf/payment/root'; +import { + ApplePayEvent, + ApplePayObservableConfig, + ApplePayShippingType, +} from '@spartacus/opf/quick-buy/root'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { ApplePaySessionFactory } from '../apple-pay-session/apple-pay-session.factory'; + +@Injectable({ + providedIn: 'root', +}) +export class ApplePayObservableFactory { + protected applePaySessionFactory = inject(ApplePaySessionFactory); + + initApplePayEventsHandler(config: ApplePayObservableConfig): Observable { + return new Observable((observer) => { + let session: ApplePaySession; + try { + session = this.applePaySessionFactory.startApplePaySession( + config.request + ) as ApplePaySession; + } catch (err) { + observer.error(err); + return; + } + + const handleUnspecifiedError = (error: any): void => { + session.abort(); + observer.error(error); + }; + + session.addEventListener( + ApplePayEvent.VALIDATE_MERCHANT, + (event: Event) => { + config + .validateMerchant(event) + .pipe(take(1)) + .subscribe({ + next: (merchantSession) => { + session.completeMerchantValidation(merchantSession); + }, + error: handleUnspecifiedError, + }); + } + ); + + session.addEventListener(ApplePayEvent.CANCEL, () => { + observer.error({ type: PaymentErrorType.PAYMENT_CANCELLED }); + }); + + if (config.paymentMethodSelected) { + session.addEventListener( + ApplePayEvent.PAYMENT_METHOD_SELECTED, + (event: Event) => { + config + .paymentMethodSelected(event) + .pipe(take(1)) + .subscribe({ + next: (paymentMethodUpdate) => { + session.completePaymentMethodSelection(paymentMethodUpdate); + }, + error: handleUnspecifiedError, + }); + } + ); + } + + if ( + config.shippingContactSelected && + this.isShippingTypeNotPickup(config) + ) { + session.addEventListener( + ApplePayEvent.SHIPPING_CONTACT_SELECTED, + (event: Event) => { + config + .shippingContactSelected(event) + .pipe(take(1)) + .subscribe({ + next: (shippingContactUpdate) => { + session.completeShippingContactSelection( + shippingContactUpdate + ); + }, + error: handleUnspecifiedError, + }); + } + ); + } + + if ( + config.shippingMethodSelected && + this.isShippingTypeNotPickup(config) + ) { + session.addEventListener( + ApplePayEvent.SHIPPING_METHOD_SELECTED, + (event: Event) => { + config + .shippingMethodSelected(event) + .pipe(take(1)) + .subscribe({ + next: (shippingMethodUpdate) => { + session.completeShippingMethodSelection(shippingMethodUpdate); + }, + error: handleUnspecifiedError, + }); + } + ); + } + + session.addEventListener( + ApplePayEvent.PAYMENT_AUTHORIZED, + (event: Event) => { + config + .paymentAuthorized(event) + .pipe(take(1)) + .subscribe({ + next: (authResult) => { + session.completePayment(authResult); + if (!authResult?.errors?.length) { + observer.next(authResult); + observer.complete(); + } else { + handleUnspecifiedError({ + message: authResult?.errors[0]?.message, + }); + } + }, + error: handleUnspecifiedError, + }); + } + ); + session.begin(); + }); + } + + protected isShippingTypeNotPickup(config: any) { + return config.request.shippingType !== ApplePayShippingType.STORE_PICKUP; + } +} diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/observable/index.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/observable/index.ts new file mode 100644 index 00000000000..3e782cc899d --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/apple-pay/observable/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './apple-pay-observable.factory'; diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.component.html b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.component.html new file mode 100644 index 00000000000..da16342873e --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.component.html @@ -0,0 +1,7 @@ + +
+
diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.component.spec.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.component.spec.ts new file mode 100644 index 00000000000..6614410c33b --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.component.spec.ts @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + ElementRef, +} from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OpfGooglePayComponent } from './google-pay.component'; +import { OpfGooglePayService } from './google-pay.service'; + +class MockOpfGooglePayService { + loadProviderResources = jasmine + .createSpy() + .and.returnValue(Promise.resolve()); + initClient = jasmine.createSpy(); + isReadyToPay = jasmine + .createSpy() + .and.returnValue(Promise.resolve({ result: true })); + renderPaymentButton = jasmine.createSpy(); +} + +describe('OpfGooglePayComponent', () => { + let component: OpfGooglePayComponent; + let fixture: ComponentFixture; + let mockOpfGooglePayService: MockOpfGooglePayService; + let mockChangeDetectorRef: jasmine.SpyObj; + + beforeEach(async () => { + mockChangeDetectorRef = jasmine.createSpyObj('ChangeDetectorRef', [ + 'detectChanges', + ]); + mockOpfGooglePayService = new MockOpfGooglePayService(); + + TestBed.configureTestingModule({ + declarations: [OpfGooglePayComponent], + providers: [ + { provide: OpfGooglePayService, useValue: mockOpfGooglePayService }, + { provide: ChangeDetectorRef, useValue: mockChangeDetectorRef }, + ], + }) + .overrideComponent(OpfGooglePayComponent, { + set: { changeDetection: ChangeDetectionStrategy.OnPush }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(OpfGooglePayComponent); + component = fixture.componentInstance; + component.activeConfiguration = {}; + }); + + async function detectChanges() { + fixture.detectChanges(); + await fixture.whenStable(); + } + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize Google Pay client on init', async () => { + await detectChanges(); + + expect(mockOpfGooglePayService.loadProviderResources).toHaveBeenCalled(); + expect(mockOpfGooglePayService.initClient).toHaveBeenCalledWith( + component.activeConfiguration + ); + }); + + it('should update ready to pay state when Google Pay is ready', async () => { + await detectChanges(); + + expect(component.isReadyToPayState$.getValue()).toBe(true); + }); + + it('should render payment button when Google Pay is ready and container is available', async () => { + component.googlePayButtonContainer = new ElementRef( + document.createElement('div') + ); + + await detectChanges(); + + expect(mockOpfGooglePayService.renderPaymentButton).toHaveBeenCalledWith( + component.googlePayButtonContainer + ); + }); +}); diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.component.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.component.ts new file mode 100644 index 00000000000..6a4b6621df5 --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.component.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + Input, + OnInit, + ViewChild, + inject, +} from '@angular/core'; +import { ActiveConfiguration } from '@spartacus/opf/base/root'; +import { BehaviorSubject } from 'rxjs'; +import { OpfGooglePayService } from './google-pay.service'; + +@Component({ + selector: 'cx-opf-google-pay', + templateUrl: './google-pay.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpfGooglePayComponent implements OnInit { + protected opfGooglePayService = inject(OpfGooglePayService); + protected changeDetectionRef = inject(ChangeDetectorRef); + + @Input() activeConfiguration: ActiveConfiguration; + + @ViewChild('googlePayButtonContainer') googlePayButtonContainer: ElementRef; + + isReadyToPayState$: BehaviorSubject = new BehaviorSubject(false); + + ngOnInit(): void { + this.opfGooglePayService.loadProviderResources().then(() => { + this.opfGooglePayService.initClient(this.activeConfiguration); + this.opfGooglePayService.isReadyToPay().then((response: any) => { + this.isReadyToPayState$.next(response?.result); + this.changeDetectionRef.detectChanges(); + if (response.result && this.googlePayButtonContainer) { + this.opfGooglePayService.renderPaymentButton( + this.googlePayButtonContainer + ); + } + }); + }); + } +} diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.module.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.module.ts new file mode 100644 index 00000000000..cbcb3ab7cdf --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.module.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { OpfGooglePayComponent } from './google-pay.component'; +import { OpfGooglePayService } from './google-pay.service'; + +@NgModule({ + declarations: [OpfGooglePayComponent], + exports: [OpfGooglePayComponent], + imports: [CommonModule], + providers: [OpfGooglePayService], +}) +export class OpfGooglePayModule {} diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.spec.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.spec.ts new file mode 100644 index 00000000000..4497d5bf7f9 --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.spec.ts @@ -0,0 +1,772 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ElementRef } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Cart } from '@spartacus/cart/base/root'; +import { Address, PriceType } from '@spartacus/core'; +import { OpfResourceLoaderService } from '@spartacus/opf/base/root'; +import { OpfPaymentFacade } from '@spartacus/opf/payment/root'; +import { OpfQuickBuyTransactionService } from '@spartacus/opf/quick-buy/core'; +import { + OPF_QUICK_BUY_ADDRESS_FIELD_PLACEHOLDER, + OpfProviderType, + OpfQuickBuyLocation, +} from '@spartacus/opf/quick-buy/root'; +import { CurrentProductService } from '@spartacus/storefront'; +import { of } from 'rxjs'; +import { OpfQuickBuyButtonsService } from '../opf-quick-buy-buttons.service'; +import { OpfGooglePayService } from './google-pay.service'; + +const mockGooglePayAddress = { + countryCode: 'CA', + locality: 'Toronto', + postalCode: 'A1B 2C3', + address1: '456 Elm St', + address2: '', + address3: '', +}; + +const mockConvertedAddress: Address = { + country: { isocode: 'CA' }, + town: 'Toronto', + district: undefined, + postalCode: 'A1B 2C3', + line1: '456 Elm St', + line2: ' ', +}; + +describe('OpfGooglePayService', () => { + const mockMerchantName = 'mockMerchantName'; + let service: OpfGooglePayService; + let mockResourceLoaderService: jasmine.SpyObj; + let mockCurrentProductService: jasmine.SpyObj; + let mockQuickBuyTransactionService: jasmine.SpyObj; + let mockPaymentFacade: jasmine.SpyObj; + let mockQuickBuyButtonsService: jasmine.SpyObj; + + beforeEach(() => { + mockResourceLoaderService = jasmine.createSpyObj( + 'OpfResourceLoaderService', + ['loadProviderResources'] + ); + mockCurrentProductService = jasmine.createSpyObj('CurrentProductService', [ + 'getProduct', + ]); + mockQuickBuyTransactionService = jasmine.createSpyObj( + 'OpfQuickBuyTransactionService', + [ + 'deleteUserAddresses', + 'checkStableCart', + 'getSupportedDeliveryModes', + 'setDeliveryAddress', + 'setBillingAddress', + 'getDeliveryAddress', + 'getCurrentCart', + 'getCurrentCartId', + 'getCurrentCartTotalPrice', + 'setDeliveryMode', + 'getSelectedDeliveryMode', + 'deleteUserAddresses', + 'getTransactionDeliveryInfo', + 'getTransactionLocationContext', + 'getMerchantName', + ] + ); + mockPaymentFacade = jasmine.createSpyObj('OpfPaymentFacade', [ + 'submitPayment', + ]); + mockQuickBuyButtonsService = jasmine.createSpyObj( + 'OpfQuickBuyButtonsService', + ['getQuickBuyProviderConfig'] + ); + + const googlePayApiMock = { + payments: { + api: { + PaymentsClient: jasmine.createSpy('PaymentsClient').and.returnValue({ + loadPaymentData: jasmine + .createSpy() + .and.returnValue(Promise.resolve({})), + isReadyToPay: jasmine + .createSpy() + .and.returnValue(Promise.resolve({ result: true })), + }), + }, + }, + }; + + window.google = googlePayApiMock as any; + + TestBed.configureTestingModule({ + providers: [ + OpfGooglePayService, + { + provide: OpfResourceLoaderService, + useValue: mockResourceLoaderService, + }, + + { provide: CurrentProductService, useValue: mockCurrentProductService }, + { + provide: OpfQuickBuyTransactionService, + useValue: mockQuickBuyTransactionService, + }, + { provide: OpfPaymentFacade, useValue: mockPaymentFacade }, + { + provide: OpfQuickBuyButtonsService, + useValue: mockQuickBuyButtonsService, + }, + ], + }); + + service = TestBed.inject(OpfGooglePayService); + service['updateGooglePaymentClient'](); + }); + + describe('getClient', () => { + it('should return the Google Payment client instance', () => { + const activeConfiguration = {}; + service.initClient(activeConfiguration); + + const client = service['getClient'](); + + expect(client).toBeDefined(); + }); + }); + + describe('getShippingOptionParameters', () => { + it('should transform delivery modes into shipping option parameters', (done) => { + const mockDeliveryModes = [ + { + code: 'STANDARD', + name: 'Standard Delivery', + description: 'Delivers in 3-5 days', + }, + ]; + mockQuickBuyTransactionService.getSupportedDeliveryModes.and.returnValue( + of(mockDeliveryModes) + ); + + service['getShippingOptionParameters']().subscribe((shippingOptions) => { + expect(shippingOptions).toBeDefined(); + expect(shippingOptions?.shippingOptions.length).toBe( + mockDeliveryModes.length + ); + expect(shippingOptions?.defaultSelectedOptionId).toBe( + mockDeliveryModes[0].code + ); + + mockDeliveryModes.forEach((mode, index) => { + const shippingOption = shippingOptions?.shippingOptions[index]; + expect(shippingOption?.id).toEqual(mode.code); + expect(shippingOption?.label).toEqual(mode.name); + expect(shippingOption?.description).toEqual(mode.description); + }); + + done(); + }); + }); + + it('should handle cases with no delivery modes available', (done) => { + mockQuickBuyTransactionService.getSupportedDeliveryModes.and.returnValue( + of([]) + ); + + service['getShippingOptionParameters']().subscribe((shippingOptions) => { + expect(shippingOptions).toBeDefined(); + expect(shippingOptions?.shippingOptions).toEqual([]); + expect(shippingOptions?.defaultSelectedOptionId).toBeUndefined(); + done(); + }); + }); + }); + + describe('loadProviderResources', () => { + it('should load the Google Pay JS API', async () => { + mockResourceLoaderService.loadProviderResources.and.returnValue( + Promise.resolve() + ); + + await service.loadProviderResources(); + + expect( + mockResourceLoaderService.loadProviderResources + ).toHaveBeenCalled(); + }); + + it('should handle errors when loading the Google Pay JS API', async () => { + mockResourceLoaderService.loadProviderResources.and.returnValue( + Promise.reject(new Error('Load failed')) + ); + + await expectAsync(service.loadProviderResources()).toBeRejectedWithError( + 'Load failed' + ); + }); + }); + + describe('isReadyToPay', () => { + it('should return info about readiness to pay from the Google Pay API', async () => { + const activeConfiguration = {}; + + service.initClient(activeConfiguration); + + await expectAsync(service.isReadyToPay()).toBeResolvedTo({ + result: true, + }); + }); + + it('should handle errors from the Google Pay API', async () => { + spyOn(service, 'isReadyToPay').and.returnValue( + Promise.reject(new Error('API error')) + ); + + await expectAsync(service.isReadyToPay()).toBeRejectedWithError( + 'API error' + ); + }); + }); + + describe('initClient', () => { + it('should initialize the Google Payment client with configurations', () => { + const activeConfiguration = {}; + service.initClient(activeConfiguration); + + const client = service['googlePaymentClient']; + + expect(client).toBeDefined(); + }); + }); + + describe('updateTransactionInfo', () => { + it('should update transaction info', () => { + const transactionInfo = { + totalPrice: '100.00', + currencyCode: 'USD', + totalPriceStatus: 'FINAL', + } as google.payments.api.TransactionInfo; + + service['updateTransactionInfo'](transactionInfo); + + const updatedTransactionInfo = + service['googlePaymentRequest'].transactionInfo; + + expect(updatedTransactionInfo).toEqual(transactionInfo); + }); + }); + + describe('setDeliveryAddress', () => { + it('should successfully set delivery address and return an address ID', async () => { + const mockAddress = { countryCode: 'US' } as google.payments.api.Address; + const mockAddressId = 'mockAddressId'; + mockQuickBuyTransactionService['setDeliveryAddress'].and.returnValue( + of(mockAddressId) + ); + + const addressId = + await service['setDeliveryAddress'](mockAddress).toPromise(); + + expect(addressId).toEqual(mockAddressId); + }); + + it('should correctly split name into first and last names and set delivery address', (done) => { + const mockAddress = { + name: 'John Doe', + countryCode: 'US', + }; + + mockQuickBuyTransactionService.setDeliveryAddress.and.returnValue( + of('addressId') + ); + + service['setDeliveryAddress']( + mockAddress as google.payments.api.Address + ).subscribe((addressId) => { + expect(addressId).toEqual('addressId'); + expect( + mockQuickBuyTransactionService.setDeliveryAddress + ).toHaveBeenCalledWith( + jasmine.objectContaining({ + firstName: 'John', + lastName: ' Doe', + country: { isocode: mockAddress.countryCode }, + }) + ); + done(); + }); + }); + }); + + describe('getNewTransactionInfo', () => { + it('should return transaction info for a given cart', () => { + const mockCart = { + totalPriceWithTax: { value: 100.0, currencyIso: 'USD' }, + } as Cart; + + const transactionInfo = service['getNewTransactionInfo'](mockCart); + + expect(transactionInfo).toBeDefined(); + expect(transactionInfo?.totalPrice).toBe('100'); + expect(transactionInfo?.currencyCode).toBe('USD'); + expect(transactionInfo?.totalPriceStatus).toBe('FINAL'); + }); + + it('should handle cart with missing price information', () => { + const mockCart = {} as Cart; + + const transactionInfo = service['getNewTransactionInfo'](mockCart); + + expect(transactionInfo).toBeUndefined(); + }); + + it('should return undefined for cart with a total price of zero', () => { + const mockCart = { + totalPriceWithTax: { value: 0, currencyIso: 'USD' }, + } as Cart; + + const transactionInfo = service['getNewTransactionInfo'](mockCart); + + expect(transactionInfo).toBeUndefined(); + }); + }); + + describe('setDeliveryMode', () => { + it('should successfully set delivery mode', async () => { + const mode = 'standard'; + mockQuickBuyTransactionService.setDeliveryMode.and.returnValue(of({})); + + const result = await service.setDeliveryMode(mode).toPromise(); + + expect(result).toBeDefined(); + }); + }); + + describe('setBillingAddress', () => { + it('should call setBillingAddress from cartHandlerService', async () => { + const address = { + ...mockGooglePayAddress, + ...{ name: 'John Doe' }, + }; + + mockQuickBuyTransactionService.setBillingAddress.and.returnValue( + of(true) + ); + + service['setBillingAddress'](address as any).subscribe((result) => { + expect(result).toBe(true); + expect( + mockQuickBuyTransactionService.setBillingAddress + ).toHaveBeenCalledWith({ + ...mockConvertedAddress, + ...{ + firstName: 'John', + lastName: ' Doe', + }, + }); + }); + }); + }); + + describe('convertAddress', () => { + it('should convert the address correctly when address is partially defined', () => { + const result = service['convertAddress'](mockGooglePayAddress as any); + + expect(result).toEqual({ + ...mockConvertedAddress, + ...{ + firstName: OPF_QUICK_BUY_ADDRESS_FIELD_PLACEHOLDER, + lastName: OPF_QUICK_BUY_ADDRESS_FIELD_PLACEHOLDER, + }, + }); + }); + + it('should convert the address correctly when address includes a name', () => { + const address = { + ...mockGooglePayAddress, + ...{ name: 'John Doe' }, + }; + const result = service['convertAddress'](address as any); + + expect(result).toEqual({ + ...mockConvertedAddress, + ...{ + firstName: 'John', + lastName: ' Doe', + }, + }); + }); + }); + + describe('verifyShippingOption', () => { + it('should return the mode if it is not "shipping_option_unselected"', () => { + const mode = 'standard_shipping'; + + expect(service['verifyShippingOption'](mode)).toBe(mode); + }); + + it('should return undefined if the mode is "shipping_option_unselected"', () => { + const mode = 'shipping_option_unselected'; + + expect(service['verifyShippingOption'](mode)).toBeUndefined(); + }); + + it('should return undefined if the mode is undefined', () => { + expect(service['verifyShippingOption'](undefined)).toBeUndefined(); + }); + }); + + describe('associateAddressId', () => { + it('should add a new address ID if not already present', () => { + const addressId = 'newAddressId'; + + service['associateAddressId'](addressId); + + expect(service['initialTransactionDetails']['addressIds']).toContain( + addressId + ); + }); + + it('should not add an address ID if it is already present', () => { + const addressId = 'existingAddressId'; + + service['associateAddressId'](addressId); + service['associateAddressId'](addressId); + + expect( + service['initialTransactionDetails']['addressIds'].filter( + (id: any) => id === addressId + ).length + ).toBe(1); + }); + }); + + describe('isAddressIdAssociated', () => { + it('should return true if address ID is already associated', () => { + const addressId = 'existingAddressId'; + service['initialTransactionDetails']['addressIds'].push(addressId); + + const result = service['isAddressIdAssociated'](addressId); + + expect(result).toBeTruthy(); + }); + + it('should return false if address ID is not associated', () => { + const addressId = 'newAddressId'; + service['initialTransactionDetails']['addressIds'] = ( + service as any + ).initialTransactionDetails.addressIds.filter( + (id: any) => id !== addressId + ); + + const result = service['isAddressIdAssociated'](addressId); + + expect(result).toBeFalsy(); + }); + }); + + describe('resetAssociatedAddresses', () => { + it('should clear all associated address IDs', () => { + service['initialTransactionDetails']['addressIds'] = [ + 'address1', + 'address2', + ]; + + service['resetAssociatedAddresses'](); + + expect(service['initialTransactionDetails']['addressIds']).toEqual([]); + expect(service['initialTransactionDetails']['addressIds'].length).toBe(0); + }); + }); + + describe('deleteAssociatedAddresses', () => { + it('should call deleteUserAddresses and reset associated addresses', () => { + service['initialTransactionDetails']['addressIds'] = [ + 'address1', + 'address2', + ]; + + service['deleteAssociatedAddresses'](); + + expect( + mockQuickBuyTransactionService.deleteUserAddresses + ).toHaveBeenCalledWith(['address1', 'address2']); + + expect(service['initialTransactionDetails']['addressIds']).toEqual([]); + }); + + it('should not call deleteUserAddresses if there are no associated addresses', () => { + (service as any).associatedShippingAddressIds = []; + + service['deleteAssociatedAddresses'](); + + expect( + mockQuickBuyTransactionService.deleteUserAddresses + ).not.toHaveBeenCalled(); + }); + }); + + describe('getFirstAndLastName', () => { + it('should correctly split first and last names', () => { + const name = 'John Doe'; + const result = service['getFirstAndLastName'](name); + expect(result.firstName).toBe('John'); + expect(result.lastName).toBe(' Doe'); + }); + + it('should handle a single name', () => { + const name = 'John'; + const result = service['getFirstAndLastName'](name); + expect(result.firstName).toBe('John'); + expect(result.lastName).toBe('John'); + }); + + it('should correctly handle multiple names', () => { + const name = 'John Michael Doe'; + const result = service['getFirstAndLastName'](name); + expect(result.firstName).toBe('John'); + expect(result.lastName).toBe(' Michael Doe'); + }); + }); + + describe('initTransaction', () => { + it('should initialize transaction for active cart context', (done: DoneFn) => { + spyOn(service, 'handleActiveCartTransaction').and.returnValue(of(null)); + + mockQuickBuyTransactionService.getTransactionLocationContext.and.returnValue( + of(OpfQuickBuyLocation.CART) + ); + + mockQuickBuyTransactionService.getMerchantName.and.returnValue( + of(mockMerchantName) + ); + + service.initTransaction(); + + expect(service['transactionDetails'].context).toBe( + OpfQuickBuyLocation.CART + ); + + setTimeout(() => { + expect(service.handleActiveCartTransaction).toHaveBeenCalled(); + + done(); + }, 0); + }); + }); + + describe('renderPaymentButton', () => { + let mockGooglePaymentClient: jasmine.SpyObj; + + beforeEach(() => { + mockGooglePaymentClient = jasmine.createSpyObj('PaymentsClient', [ + 'createButton', + ]); + service['googlePaymentClient'] = mockGooglePaymentClient; + + mockGooglePaymentClient.createButton.and.callFake((config: any) => { + const button = document.createElement('button'); + button.addEventListener('click', config.onClick); + + return button; + }); + }); + + it('should append a payment button to the container', () => { + const container = new ElementRef(document.createElement('div')); + + service.renderPaymentButton(container); + + expect(container.nativeElement.children.length).toBe(1); + expect(container.nativeElement.children[0].tagName).toBe('BUTTON'); + expect(mockGooglePaymentClient.createButton).toHaveBeenCalled(); + }); + + it('should attach the correct click handler to the button', () => { + const container = new ElementRef(document.createElement('div')); + spyOn(service, 'initTransaction'); + + service.renderPaymentButton(container); + + const button = container.nativeElement.children[0]; + (button as any).click(); + + expect(service.initTransaction).toHaveBeenCalled(); + }); + }); + + describe('handlePaymentCallbacks', () => { + const encodedMockToken = 'encodedMockToken'; + + beforeEach(() => { + spyOn(window, 'btoa').and.callFake(() => { + return encodedMockToken; + }); + }); + + it('should return valid payment data callbacks', () => { + const callbacks = service['handlePaymentCallbacks'](); + + expect(callbacks).toBeDefined(); + expect(callbacks.onPaymentAuthorized).toBeDefined(); + expect(callbacks.onPaymentDataChanged).toBeDefined(); + }); + + describe('onPaymentAuthorized', () => { + it('should handle payment authorization', (done) => { + const callbacks = service['handlePaymentCallbacks'](); + const mockCartId = 'cartId'; + const mockToken = 'mockToken'; + const paymentDataResponse = { + paymentMethodData: { + tokenizationData: { + token: mockToken, + }, + }, + } as google.payments.api.PaymentData; + + mockQuickBuyTransactionService.getCurrentCartId.and.returnValue( + of(mockCartId) + ); + mockPaymentFacade.submitPayment.and.returnValue(of(true)); + mockQuickBuyTransactionService.setBillingAddress.and.returnValue( + of(true) + ); + mockQuickBuyTransactionService.setDeliveryAddress.and.returnValue( + of('addressId') + ); + + if (callbacks.onPaymentAuthorized) { + ( + callbacks.onPaymentAuthorized(paymentDataResponse) as Promise + ).then((result) => { + const submitPaymentArgs = + mockPaymentFacade.submitPayment.calls.mostRecent().args[0]; + + expect(result).toBeDefined(); + expect(mockPaymentFacade.submitPayment).toHaveBeenCalled(); + expect(submitPaymentArgs.callbackArray.length).toBe(3); + submitPaymentArgs.callbackArray.forEach((callback) => { + expect(typeof callback).toBe('function'); + }); + expect(submitPaymentArgs.cartId).toBe(mockCartId); + expect(submitPaymentArgs.cartId).toBe(mockCartId); + expect(submitPaymentArgs.encryptedToken).toBe(encodedMockToken); + expect(submitPaymentArgs.paymentMethod).toBe( + OpfProviderType.GOOGLE_PAY + ); + expect( + mockQuickBuyTransactionService.getCurrentCartId + ).toHaveBeenCalled(); + expect(result).toEqual({ transactionState: 'SUCCESS' }); + done(); + }); + } + }); + }); + + describe('onPaymentDataChanged', () => { + it('should handle payment data changes', (done) => { + const callbacks = service['handlePaymentCallbacks'](); + const intermediatePaymentData = + {} as google.payments.api.IntermediatePaymentData; + + const selectedDeliveryMode = { + code: 'code', + deliveryCost: { + currencyIso: 'US', + formattedValue: '100.00', + maxQuantity: 2, + minQuantity: 1, + priceType: PriceType.BUY, + value: 100, + }, + description: 'description', + name: 'STANDARD DELIVERY', + }; + + mockQuickBuyTransactionService.getCurrentCartId.and.returnValue( + of('cartId') + ); + mockPaymentFacade.submitPayment.and.returnValue(of()); + mockQuickBuyTransactionService.setDeliveryAddress.and.returnValue( + of('addressId') + ); + mockQuickBuyTransactionService.setDeliveryMode.and.returnValue( + of({ + code: 'code', + }) + ); + mockQuickBuyTransactionService.getCurrentCart.and.returnValue(of({})); + mockQuickBuyTransactionService.getSelectedDeliveryMode.and.returnValue( + of(selectedDeliveryMode) + ); + mockQuickBuyTransactionService.getSupportedDeliveryModes.and.returnValue( + of([ + { + code: 'code', + deliveryCost: { + currencyIso: 'US', + formattedValue: '100.00', + maxQuantity: 2, + minQuantity: 1, + priceType: PriceType.BUY, + value: 100, + }, + description: 'description', + name: 'STANDARD DELIVERY', + }, + ]) + ); + + if (callbacks.onPaymentDataChanged) { + ( + callbacks.onPaymentDataChanged( + intermediatePaymentData + ) as Promise + ).then((paymentDataRequestUpdate) => { + expect( + paymentDataRequestUpdate.newShippingOptionParameters + .defaultSelectedOptionId + ).toEqual(selectedDeliveryMode.code); + expect( + paymentDataRequestUpdate.newShippingOptionParameters + .shippingOptions[0].id + ).toEqual(selectedDeliveryMode.code); + expect( + paymentDataRequestUpdate.newShippingOptionParameters + .shippingOptions[0].description + ).toEqual(selectedDeliveryMode.description); + expect( + paymentDataRequestUpdate.newShippingOptionParameters + .shippingOptions[0].label + ).toEqual(selectedDeliveryMode.name); + expect(paymentDataRequestUpdate).toBeDefined(); + expect( + mockQuickBuyTransactionService.setDeliveryAddress + ).toHaveBeenCalled(); + expect( + mockQuickBuyTransactionService.setDeliveryMode + ).toHaveBeenCalled(); + expect( + mockQuickBuyTransactionService.getCurrentCart + ).toHaveBeenCalled(); + expect( + mockQuickBuyTransactionService.getSelectedDeliveryMode + ).toHaveBeenCalledWith(); + + done(); + }); + } + }); + }); + }); + + afterEach(() => { + delete (window as any).google; + + TestBed.resetTestingModule(); + }); +}); diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.ts new file mode 100644 index 00000000000..ec294afa65b --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.ts @@ -0,0 +1,500 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/// +import { ElementRef, Injectable, inject } from '@angular/core'; +import { Cart, DeliveryMode } from '@spartacus/cart/base/root'; +import { Address } from '@spartacus/core'; + +import { + ActiveConfiguration, + OpfResourceLoaderService, +} from '@spartacus/opf/base/root'; +import { OpfPaymentFacade } from '@spartacus/opf/payment/root'; +import { OpfQuickBuyTransactionService } from '@spartacus/opf/quick-buy/core'; +import { + OPF_QUICK_BUY_ADDRESS_FIELD_PLACEHOLDER, + OPF_QUICK_BUY_DEFAULT_MERCHANT_NAME, + OpfProviderType, + OpfQuickBuyDeliveryType, + OpfQuickBuyLocation, + QuickBuyTransactionDetails, +} from '@spartacus/opf/quick-buy/root'; +import { CurrentProductService } from '@spartacus/storefront'; +import { Observable, forkJoin, lastValueFrom, of } from 'rxjs'; +import { catchError, map, switchMap, take, tap } from 'rxjs/operators'; +import { OpfQuickBuyButtonsService } from '../opf-quick-buy-buttons.service'; + +@Injectable({ + providedIn: 'root', +}) +export class OpfGooglePayService { + protected opfResourceLoaderService = inject(OpfResourceLoaderService); + protected currentProductService = inject(CurrentProductService); + protected opfPaymentFacade = inject(OpfPaymentFacade); + protected opfQuickBuyTransactionService = inject( + OpfQuickBuyTransactionService + ); + protected opfQuickBuyButtonsService = inject(OpfQuickBuyButtonsService); + + protected readonly GOOGLE_PAY_JS_URL = + 'https://pay.google.com/gp/p/js/pay.js'; + + private googlePaymentClient: google.payments.api.PaymentsClient; + + private googlePaymentClientOptions: google.payments.api.PaymentOptions = { + environment: 'TEST', + }; + + private initialGooglePaymentRequest: google.payments.api.PaymentDataRequest = + { + /** + * TODO: Move this part into configuration layer. + */ + apiVersion: 2, + apiVersionMinor: 0, + callbackIntents: [ + 'PAYMENT_AUTHORIZATION', + 'SHIPPING_ADDRESS', + 'SHIPPING_OPTION', + ], + + // @ts-ignore + merchantInfo: { + // merchantId: 'spartacusStorefront', + merchantName: OPF_QUICK_BUY_DEFAULT_MERCHANT_NAME, + }, + shippingOptionRequired: true, + shippingAddressRequired: true, + // @ts-ignore + shippingAddressParameters: { + phoneNumberRequired: false, + }, + }; + + private initialTransactionInfo: google.payments.api.TransactionInfo = { + totalPrice: '0.00', + totalPriceStatus: 'ESTIMATED', + currencyCode: 'USD', + }; + + protected initialTransactionDetails: QuickBuyTransactionDetails = { + context: OpfQuickBuyLocation.PRODUCT, + product: undefined, + cart: undefined, + quantity: 0, + deliveryInfo: { + type: OpfQuickBuyDeliveryType.SHIPPING, + pickupDetails: undefined, + }, + addressIds: [], + total: { + label: '', + amount: '', + currency: '', + }, + }; + + private googlePaymentRequest = this.initialGooglePaymentRequest; + + protected transactionDetails = this.initialTransactionDetails; + + protected updateGooglePaymentClient(): void { + this.googlePaymentClient = new google.payments.api.PaymentsClient( + this.googlePaymentClientOptions + ); + } + + protected setGooglePaymentRequestConfig( + deliveryType: OpfQuickBuyDeliveryType, + merchantName: string + ) { + if (deliveryType === OpfQuickBuyDeliveryType.PICKUP) { + this.googlePaymentClientOptions = { + ...this.googlePaymentClientOptions, + paymentDataCallbacks: { + onPaymentAuthorized: + this.handlePaymentCallbacks().onPaymentAuthorized, + }, + }; + this.googlePaymentRequest = { + ...this.initialGooglePaymentRequest, + shippingAddressRequired: false, + shippingOptionRequired: false, + callbackIntents: ['PAYMENT_AUTHORIZATION'], + }; + } else { + this.googlePaymentClientOptions = { + ...this.googlePaymentClientOptions, + paymentDataCallbacks: this.handlePaymentCallbacks(), + }; + this.googlePaymentRequest = this.initialGooglePaymentRequest; + } + this.googlePaymentRequest.merchantInfo.merchantName = merchantName; + this.updateGooglePaymentClient(); + } + + loadProviderResources(): Promise { + return this.opfResourceLoaderService.loadProviderResources([ + { url: this.GOOGLE_PAY_JS_URL }, + ]); + } + + initClient(activeConfiguration: ActiveConfiguration): void { + this.setAllowedPaymentMethodsConfig(activeConfiguration); + this.updateGooglePaymentClient(); + } + + private getClient(): google.payments.api.PaymentsClient { + return this.googlePaymentClient; + } + + isReadyToPay() { + return this.googlePaymentClient.isReadyToPay( + this.googlePaymentRequest + ) as any; + } + + private updateTransactionInfo( + transactionInfo: google.payments.api.TransactionInfo + ) { + this.googlePaymentRequest.transactionInfo = transactionInfo; + } + + private getShippingOptionParameters(): Observable< + google.payments.api.ShippingOptionParameters | undefined + > { + return this.opfQuickBuyTransactionService.getSupportedDeliveryModes().pipe( + take(1), + map((modes) => { + return { + defaultSelectedOptionId: modes[0]?.code, + shippingOptions: modes?.map((mode) => ({ + id: mode?.code, + label: mode?.name, + description: mode?.description, + })), + } as google.payments.api.ShippingOptionParameters; + }) + ); + } + + private getNewTransactionInfo( + cart: Cart + ): google.payments.api.TransactionInfo | undefined { + let transactionInfo: google.payments.api.TransactionInfo | undefined; + const priceInfo = cart?.totalPriceWithTax; + if (priceInfo && priceInfo.value && priceInfo.currencyIso) { + transactionInfo = { + totalPrice: priceInfo.value.toString(), + currencyCode: priceInfo.currencyIso.toString(), + totalPriceStatus: 'FINAL', + }; + } + + return transactionInfo; + } + + private setDeliveryAddress( + address: google.payments.api.Address | undefined + ): Observable { + const deliveryAddress = this.convertAddress(address); + + if ( + this.transactionDetails?.deliveryInfo?.type === + OpfQuickBuyDeliveryType.SHIPPING + ) { + return this.opfQuickBuyTransactionService + .setDeliveryAddress(deliveryAddress) + .pipe( + tap((addressId) => { + this.associateAddressId(addressId); + }) + ); + } else { + return of(OpfQuickBuyDeliveryType.PICKUP); + } + } + + private setBillingAddress( + address: google.payments.api.Address | undefined + ): Observable { + return this.opfQuickBuyTransactionService.setBillingAddress( + this.convertAddress(address) + ); + } + + setDeliveryMode( + mode?: string, + type?: OpfQuickBuyDeliveryType + ): Observable { + if (type === OpfQuickBuyDeliveryType.PICKUP) { + mode = OpfQuickBuyDeliveryType.PICKUP.toLocaleLowerCase(); + } + + if (!mode && type === OpfQuickBuyDeliveryType.SHIPPING) { + return of(undefined); + } + + return mode && this.verifyShippingOption(mode) + ? this.opfQuickBuyTransactionService.setDeliveryMode(mode) + : of(undefined); + } + + private convertAddress( + address: google.payments.api.Address | undefined + ): Address { + let convertedAddress: Address = { + firstName: OPF_QUICK_BUY_ADDRESS_FIELD_PLACEHOLDER, + lastName: OPF_QUICK_BUY_ADDRESS_FIELD_PLACEHOLDER, + country: { + isocode: address?.countryCode, + }, + town: address?.locality, + district: address?.administrativeArea, + postalCode: address?.postalCode, + line1: address?.address1 || OPF_QUICK_BUY_ADDRESS_FIELD_PLACEHOLDER, + line2: `${address?.address2} ${address?.address3}`, + }; + + if (address?.name) { + convertedAddress = { + ...convertedAddress, + ...this.getFirstAndLastName(address?.name), + }; + } + return convertedAddress; + } + + handleActiveCartTransaction(): Observable { + this.transactionDetails.context = OpfQuickBuyLocation.CART; + + return this.opfQuickBuyTransactionService.handleCartGuestUser().pipe( + switchMap(() => { + return forkJoin({ + deliveryInfo: + this.opfQuickBuyTransactionService.getTransactionDeliveryInfo(), + merchantName: this.opfQuickBuyTransactionService.getMerchantName(), + }).pipe( + switchMap(({ deliveryInfo, merchantName }) => { + this.transactionDetails.deliveryInfo = deliveryInfo; + this.setGooglePaymentRequestConfig(deliveryInfo.type, merchantName); + + return this.setDeliveryMode(undefined, deliveryInfo.type).pipe( + switchMap(() => + this.opfQuickBuyTransactionService.getCurrentCart().pipe( + take(1), + tap((cart: Cart) => { + this.transactionDetails.cart = cart; + this.updateTransactionInfo({ + totalPrice: `${cart.totalPrice?.value}`, + currencyCode: + cart.totalPrice?.currencyIso || + this.initialTransactionInfo.currencyCode, + totalPriceStatus: + this.initialTransactionInfo.totalPriceStatus, + }); + }) + ) + ) + ); + }) + ); + }) + ); + } + + initTransaction(): void { + this.transactionDetails = { + ...this.initialTransactionDetails, + addressIds: [], + }; + + this.opfQuickBuyTransactionService + .getTransactionLocationContext() + .pipe( + switchMap((context: OpfQuickBuyLocation) => { + this.transactionDetails.context = context; + + return this.handleActiveCartTransaction(); + }) + ) + .subscribe(() => { + this.googlePaymentClient + .loadPaymentData(this.googlePaymentRequest) + .catch((err: any) => { + // If err.statusCode === 'CANCELED' it means that customer closed popup + if (err.statusCode === 'CANCELED') { + this.deleteAssociatedAddresses(); + } + }); + }); + } + + renderPaymentButton(container: ElementRef): void { + container.nativeElement.appendChild( + this.getClient().createButton({ + onClick: () => this.initTransaction(), + buttonSizeMode: 'fill', + }) + ); + } + + private handlePaymentCallbacks(): google.payments.api.PaymentDataCallbacks { + return { + onPaymentAuthorized: (paymentDataResponse: any) => { + return lastValueFrom( + this.opfQuickBuyTransactionService.getCurrentCartId().pipe( + switchMap((cartId) => + this.setDeliveryAddress(paymentDataResponse.shippingAddress).pipe( + switchMap(() => + this.setBillingAddress( + paymentDataResponse.paymentMethodData.info?.billingAddress + ) + ), + switchMap(() => { + const encryptedToken = btoa( + paymentDataResponse.paymentMethodData.tokenizationData.token + ); + + return this.opfPaymentFacade.submitPayment({ + additionalData: [], + paymentSessionId: '', + callbackArray: [() => {}, () => {}, () => {}], + paymentMethod: OpfProviderType.GOOGLE_PAY as any, + encryptedToken, + cartId, + }); + }) + ) + ), + catchError(() => { + return of(false); + }) + ) + ).then((isSuccess) => { + this.deleteAssociatedAddresses(); + return { transactionState: isSuccess ? 'SUCCESS' : 'ERROR' }; + }); + }, + + onPaymentDataChanged: (intermediatePaymentData: any) => { + return lastValueFrom( + this.setDeliveryAddress(intermediatePaymentData.shippingAddress).pipe( + switchMap(() => this.getShippingOptionParameters()), + switchMap((shippingOptions) => { + const selectedMode = + this.verifyShippingOption( + intermediatePaymentData.shippingOptionData?.id + ) ?? shippingOptions?.defaultSelectedOptionId; + + return this.setDeliveryMode(selectedMode).pipe( + switchMap(() => + forkJoin([ + this.opfQuickBuyTransactionService.getCurrentCart(), + this.opfQuickBuyTransactionService.getSelectedDeliveryMode(), + ]) + ), + switchMap(([cart, mode]) => { + const paymentDataRequestUpdate: google.payments.api.PaymentDataRequestUpdate = + { + newShippingOptionParameters: shippingOptions, + newTransactionInfo: this.getNewTransactionInfo(cart), + }; + + if ( + paymentDataRequestUpdate.newShippingOptionParameters + ?.defaultSelectedOptionId + ) { + paymentDataRequestUpdate.newShippingOptionParameters.defaultSelectedOptionId = + mode?.code; + } + + return of(paymentDataRequestUpdate); + }) + ); + }) + ) + ); + }, + }; + } + + protected verifyShippingOption(mode: string | undefined): string | undefined { + return mode === 'shipping_option_unselected' ? undefined : mode; + } + + protected associateAddressId(addressId: string): void { + if (!this.isAddressIdAssociated(addressId)) { + this.transactionDetails.addressIds.push(addressId); + } + } + + protected isAddressIdAssociated(addressId: string): boolean { + return this.transactionDetails.addressIds.includes(addressId); + } + + protected resetAssociatedAddresses(): void { + this.transactionDetails.addressIds = []; + } + + protected deleteAssociatedAddresses(): void { + if (this.transactionDetails.addressIds?.length) { + this.opfQuickBuyTransactionService.deleteUserAddresses( + this.transactionDetails.addressIds + ); + this.resetAssociatedAddresses(); + } + } + + protected getFirstAndLastName(name: string) { + const firstName = name?.split(' ')[0]; + const lastName = name?.substring(firstName?.length) || firstName; + + return { + firstName, + lastName, + }; + } + + protected setAllowedPaymentMethodsConfig( + activeConfiguration: ActiveConfiguration + ): void { + const googlePayConfig = + this.opfQuickBuyButtonsService.getQuickBuyProviderConfig( + OpfProviderType.GOOGLE_PAY, + activeConfiguration + ); + + this.googlePaymentRequest.allowedPaymentMethods = [ + { + parameters: { + allowedAuthMethods: ['PAN_ONLY', 'CRYPTOGRAM_3DS'], + allowedCardNetworks: [ + 'AMEX', + 'DISCOVER', + 'INTERAC', + 'JCB', + 'MASTERCARD', + 'VISA', + ], + billingAddressRequired: true, + billingAddressParameters: { + format: 'FULL', + }, + }, + tokenizationSpecification: { + parameters: { + gateway: String(googlePayConfig?.googlePayGateway), + gatewayMerchantId: String(activeConfiguration.merchantId), + }, + type: activeConfiguration.providerType as any, + }, + type: 'CARD', + }, + ]; + } +} diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/index.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/index.ts new file mode 100644 index 00000000000..1aa81a7756d --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/index.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './google-pay.component'; +export * from './google-pay.module'; +export * from './google-pay.service'; diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/index.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/index.ts new file mode 100644 index 00000000000..dea3f4ca18b --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/index.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-quick-buy-buttons.component'; +export * from './opf-quick-buy-buttons.module'; +export * from './opf-quick-buy-buttons.service'; diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.component.html b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.component.html new file mode 100644 index 00000000000..7bd7656537f --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.component.html @@ -0,0 +1,14 @@ + + + + diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.component.spec.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.component.spec.ts new file mode 100644 index 00000000000..e192246c40b --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.component.spec.ts @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterState, RoutingService } from '@spartacus/core'; +import { BehaviorSubject, of } from 'rxjs'; +import { OpfProviderType } from '../../root/model'; +import { OpfQuickBuyButtonsComponent } from './opf-quick-buy-buttons.component'; +import { OpfQuickBuyButtonsService } from './opf-quick-buy-buttons.service'; +import createSpy = jasmine.createSpy; + +const routerStateSubject = new BehaviorSubject({ + state: { + semanticRoute: 'cart', + }, +} as unknown as RouterState); + +class MockRoutingService implements Partial { + getRouterState = createSpy().and.returnValue( + routerStateSubject.asObservable() + ); +} + +describe('OpfQuickBuyButtonsComponent', () => { + let component: OpfQuickBuyButtonsComponent; + let fixture: ComponentFixture; + let opfQuickBuyButtonsServiceMock: any; + + beforeEach(async () => { + opfQuickBuyButtonsServiceMock = jasmine.createSpyObj('OpfQuickBuyService', [ + 'getPaymentGatewayConfiguration', + 'isQuickBuyProviderEnabled', + ]); + + await TestBed.configureTestingModule({ + declarations: [OpfQuickBuyButtonsComponent], + providers: [ + { + provide: OpfQuickBuyButtonsService, + useValue: opfQuickBuyButtonsServiceMock, + }, + { provide: RoutingService, useValie: MockRoutingService }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OpfQuickBuyButtonsComponent); + component = fixture.componentInstance; + + opfQuickBuyButtonsServiceMock.getPaymentGatewayConfiguration.and.returnValue( + of({}) + ); + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call getPaymentGatewayConfiguration on init', () => { + expect( + opfQuickBuyButtonsServiceMock.getPaymentGatewayConfiguration + ).toHaveBeenCalled(); + }); + + it('should determine if a payment method is enabled', () => { + const provider = OpfProviderType.APPLE_PAY; + const activeConfiguration = {}; + opfQuickBuyButtonsServiceMock.isQuickBuyProviderEnabled.and.returnValue( + true + ); + + expect( + component.isPaymentMethodEnabled(provider, activeConfiguration) + ).toBeTruthy(); + expect( + opfQuickBuyButtonsServiceMock.isQuickBuyProviderEnabled + ).toHaveBeenCalledWith(provider, activeConfiguration); + }); +}); diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.component.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.component.ts new file mode 100644 index 00000000000..e1dee5951b0 --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.component.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ChangeDetectionStrategy, + Component, + OnInit, + inject, +} from '@angular/core'; +import { ActiveConfiguration } from '@spartacus/opf/base/root'; +import { OpfProviderType } from '@spartacus/opf/quick-buy/root'; +import { Observable } from 'rxjs'; +import { OpfQuickBuyButtonsService } from './opf-quick-buy-buttons.service'; + +@Component({ + selector: 'cx-opf-quick-buy-buttons', + templateUrl: './opf-quick-buy-buttons.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpfQuickBuyButtonsComponent implements OnInit { + protected opfQuickBuyButtonsService = inject(OpfQuickBuyButtonsService); + protected paymentGatewayConfig$: Observable; + + PAYMENT_METHODS = OpfProviderType; + + ngOnInit(): void { + this.paymentGatewayConfig$ = + this.opfQuickBuyButtonsService.getPaymentGatewayConfiguration(); + } + + isPaymentMethodEnabled( + provider: OpfProviderType, + activeConfiguration: ActiveConfiguration + ): boolean { + return this.opfQuickBuyButtonsService.isQuickBuyProviderEnabled( + provider, + activeConfiguration + ); + } +} diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.module.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.module.ts new file mode 100644 index 00000000000..1b3400e9e5e --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.module.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { CmsConfig, provideDefaultConfig } from '@spartacus/core'; +import { OpfApplePayModule } from './apple-pay'; +import { OpfGooglePayModule } from './google-pay/google-pay.module'; +import { OpfQuickBuyButtonsComponent } from './opf-quick-buy-buttons.component'; +import { OpfQuickBuyButtonsService } from './opf-quick-buy-buttons.service'; + +@NgModule({ + declarations: [OpfQuickBuyButtonsComponent], + providers: [ + OpfQuickBuyButtonsService, + provideDefaultConfig({ + cmsComponents: { + OpfQuickBuyButtonsComponent: { + component: OpfQuickBuyButtonsComponent, + }, + }, + }), + ], + exports: [OpfQuickBuyButtonsComponent], + imports: [CommonModule, OpfApplePayModule, OpfGooglePayModule], +}) +export class OpfQuickBuyButtonsModule {} diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.service.spec.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.service.spec.ts new file mode 100644 index 00000000000..12021a5bf49 --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.service.spec.ts @@ -0,0 +1,207 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { StoreModule } from '@ngrx/store'; +import { CheckoutConfig } from '@spartacus/checkout/base/root'; +import { AuthService, QueryState, RoutingService } from '@spartacus/core'; +import { + ActiveConfiguration, + OpfBaseFacade, + OpfPaymentProviderType, +} from '@spartacus/opf/base/root'; +import { of, throwError } from 'rxjs'; +import { OpfProviderType, OpfQuickBuyDigitalWallet } from '../../root/model'; +import { OpfQuickBuyButtonsService } from './opf-quick-buy-buttons.service'; + +describe('OpfQuickBuyButtonsService', () => { + let service: OpfQuickBuyButtonsService; + let opfBaseFacadeMock: jasmine.SpyObj; + let authServiceMock: jasmine.SpyObj; + let checkoutConfigMock: jasmine.SpyObj; + let routingServiceMock: jasmine.SpyObj; + + beforeEach(() => { + opfBaseFacadeMock = jasmine.createSpyObj('OpfBaseFacade', [ + 'getActiveConfigurationsState', + ]); + authServiceMock = jasmine.createSpyObj('AuthService', ['isUserLoggedIn']); + checkoutConfigMock = jasmine.createSpyObj('CheckoutConfig', ['checkout']); + routingServiceMock = jasmine.createSpyObj('RoutingService', [ + 'getRouterState', + ]); + + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({})], + providers: [ + OpfQuickBuyButtonsService, + { provide: OpfBaseFacade, useValue: opfBaseFacadeMock }, + { provide: AuthService, useValue: authServiceMock }, + { provide: CheckoutConfig, useValue: checkoutConfigMock }, + { provide: RoutingService, useValue: routingServiceMock }, + ], + }); + + service = TestBed.inject(OpfQuickBuyButtonsService); + }); + + describe('getPaymentGatewayConfiguration', () => { + it('should return the first PAYMENT_GATEWAY configuration when available', () => { + const mockConfigurations = [ + { providerType: OpfPaymentProviderType.PAYMENT_METHOD }, + { providerType: OpfPaymentProviderType.PAYMENT_GATEWAY }, + { providerType: OpfPaymentProviderType.PAYMENT_GATEWAY }, + ]; + opfBaseFacadeMock.getActiveConfigurationsState.and.returnValue( + of({ data: mockConfigurations } as QueryState) + ); + + service.getPaymentGatewayConfiguration().subscribe((result) => { + expect(result).toEqual(mockConfigurations[1]); + }); + }); + + it('should return undefined when there are no active configurations', () => { + opfBaseFacadeMock.getActiveConfigurationsState.and.returnValue( + of({ data: undefined } as QueryState) + ); + + service.getPaymentGatewayConfiguration().subscribe((result) => { + expect(result).toBeUndefined(); + }); + }); + + it('should return undefined when no configuration matches PAYMENT_GATEWAY type', () => { + const mockConfigurations = [{ providerType: 'SOME_OTHER_TYPE' }]; + opfBaseFacadeMock.getActiveConfigurationsState.and.returnValue( + of({ data: mockConfigurations } as QueryState) + ); + + service.getPaymentGatewayConfiguration().subscribe((result) => { + expect(result).toBeUndefined(); + }); + }); + + it('should handle errors when fetching configurations', () => { + const error = new Error('Network error'); + opfBaseFacadeMock.getActiveConfigurationsState.and.returnValue( + throwError(error) + ); + + service.getPaymentGatewayConfiguration().subscribe( + () => fail('Expected an error, not configurations'), + (err) => expect(err).toBe(error) + ); + }); + + it('should return an empty array when config.data is undefined', () => { + opfBaseFacadeMock.getActiveConfigurationsState.and.returnValue( + of({} as QueryState) + ); + + service.getPaymentGatewayConfiguration().subscribe((result) => { + expect(result).toBeUndefined(); + }); + }); + }); + + describe('isQuickBuyProviderEnabled', () => { + const provider = OpfProviderType.APPLE_PAY; + + it('should return true when the provider is enabled', () => { + const activeConfiguration = { + digitalWalletQuickBuy: [ + { provider: OpfProviderType.APPLE_PAY, enabled: true }, + ], + }; + + const result = service.isQuickBuyProviderEnabled( + provider, + activeConfiguration + ); + expect(result).toBeTruthy(); + }); + + it('should return false when the provider is disabled', () => { + const activeConfiguration = { + digitalWalletQuickBuy: [ + { provider: OpfProviderType.APPLE_PAY, enabled: false }, + ], + }; + + const result = service.isQuickBuyProviderEnabled( + provider, + activeConfiguration + ); + expect(result).toBeFalsy(); + }); + + it('should return false when the provider is not found', () => { + const activeConfiguration = { + digitalWalletQuickBuy: [ + { provider: 'otherProvider' as any, enabled: true }, + ], + }; + + const result = service.isQuickBuyProviderEnabled( + provider, + activeConfiguration + ); + expect(result).toBeFalsy(); + }); + + it('should return false when activeConfiguration is null', () => { + const result = service.isQuickBuyProviderEnabled(provider, null as any); + expect(result).toBeFalsy(); + }); + + it('should return false when activeConfiguration is undefined', () => { + const result = service.isQuickBuyProviderEnabled( + provider, + undefined as any + ); + expect(result).toBeFalsy(); + }); + + it('should return false when digitalWalletQuickBuy is null or empty', () => { + const provider = OpfProviderType.APPLE_PAY; + const activeConfiguration = { + digitalWalletQuickBuy: null as any, + }; + + const result = service.isQuickBuyProviderEnabled( + provider, + activeConfiguration + ); + expect(result).toBeFalsy(); + }); + }); + + describe('getQuickBuyProviderConfig', () => { + const config: OpfQuickBuyDigitalWallet = { + provider: OpfProviderType.GOOGLE_PAY, + googlePayGateway: 'test', + merchantId: 'test', + merchantName: 'test', + enabled: true, + }; + + it('should return config for specific provider', () => { + const activeConfiguration = { + digitalWalletQuickBuy: [ + { provider: OpfProviderType.APPLE_PAY, enabled: true }, + config, + ], + }; + + const result = service.getQuickBuyProviderConfig( + OpfProviderType.GOOGLE_PAY, + activeConfiguration + ); + expect(result).toBe(config); + }); + }); +}); diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.service.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.service.ts new file mode 100644 index 00000000000..f85d7365030 --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/opf-quick-buy-buttons.service.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { inject, Injectable } from '@angular/core'; +import { + ActiveCartFacade, + CartGuestUserFacade, + MultiCartFacade, +} from '@spartacus/cart/base/root'; +import { CheckoutConfig } from '@spartacus/checkout/base/root'; +import { AuthService, UserIdService } from '@spartacus/core'; +import { + ActiveConfiguration, + OpfBaseFacade, + OpfPaymentProviderType, +} from '@spartacus/opf/base/root'; +import { + OpfProviderType, + OpfQuickBuyDigitalWallet, +} from '@spartacus/opf/quick-buy/root'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class OpfQuickBuyButtonsService { + protected opfBaseFacade = inject(OpfBaseFacade); + protected checkoutConfig = inject(CheckoutConfig); + protected authService = inject(AuthService); + protected userIdService = inject(UserIdService); + protected cartGuestUserFacade = inject(CartGuestUserFacade); + protected activeCartFacade = inject(ActiveCartFacade); + protected multiCartFacade = inject(MultiCartFacade); + + getPaymentGatewayConfiguration(): Observable { + return this.opfBaseFacade + .getActiveConfigurationsState() + .pipe( + map( + (config) => + (config?.data || []).filter( + (item) => + item?.providerType === OpfPaymentProviderType.PAYMENT_GATEWAY + )[0] + ) + ); + } + + getQuickBuyProviderConfig( + provider: OpfProviderType, + activeConfiguration: ActiveConfiguration + ): OpfQuickBuyDigitalWallet | undefined { + let config; + if (activeConfiguration && activeConfiguration.digitalWalletQuickBuy) { + config = activeConfiguration?.digitalWalletQuickBuy.find( + (item) => item.provider === provider + ); + } + + return config; + } + + isQuickBuyProviderEnabled( + provider: OpfProviderType, + activeConfiguration: ActiveConfiguration + ): boolean { + let isEnabled = false; + if (activeConfiguration && activeConfiguration.digitalWalletQuickBuy) { + isEnabled = Boolean( + activeConfiguration?.digitalWalletQuickBuy.find( + (item) => item.provider === provider + )?.enabled + ); + } + + return isEnabled; + } +} diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-components.module.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-components.module.ts new file mode 100644 index 00000000000..71ea7e1bcae --- /dev/null +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-components.module.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfQuickBuyButtonsModule } from './opf-quick-buy-buttons/opf-quick-buy-buttons.module'; + +@NgModule({ + imports: [OpfQuickBuyButtonsModule], +}) +export class OpfQuickBuyComponentsModule {} diff --git a/integration-libs/opf/quick-buy/components/public_api.ts b/integration-libs/opf/quick-buy/components/public_api.ts new file mode 100644 index 00000000000..cbc0b00f2f2 --- /dev/null +++ b/integration-libs/opf/quick-buy/components/public_api.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-quick-buy-components.module'; diff --git a/integration-libs/opf/quick-buy/core/connectors/index.ts b/integration-libs/opf/quick-buy/core/connectors/index.ts new file mode 100644 index 00000000000..5319ac93b18 --- /dev/null +++ b/integration-libs/opf/quick-buy/core/connectors/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-quick-buy.adapter'; +export * from './opf-quick-buy.connector'; diff --git a/integration-libs/opf/quick-buy/core/connectors/opf-quick-buy.adapter.ts b/integration-libs/opf/quick-buy/core/connectors/opf-quick-buy.adapter.ts new file mode 100644 index 00000000000..20846828238 --- /dev/null +++ b/integration-libs/opf/quick-buy/core/connectors/opf-quick-buy.adapter.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ApplePaySessionVerificationRequest, + ApplePaySessionVerificationResponse, +} from '@spartacus/opf/quick-buy/root'; +import { Observable } from 'rxjs'; + +export abstract class OpfQuickBuyAdapter { + /** + * Abstract method used to request an ApplePay session, used by QuickBuy functionality + */ + abstract getApplePayWebSession( + applePayRequest: ApplePaySessionVerificationRequest, + otpKey: string + ): Observable; +} diff --git a/integration-libs/opf/quick-buy/core/connectors/opf-quick-buy.connector.spec.ts b/integration-libs/opf/quick-buy/core/connectors/opf-quick-buy.connector.spec.ts new file mode 100644 index 00000000000..cc4bc3d389d --- /dev/null +++ b/integration-libs/opf/quick-buy/core/connectors/opf-quick-buy.connector.spec.ts @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { + ApplePaySessionVerificationRequest, + ApplePaySessionVerificationResponse, +} from '@spartacus/opf/quick-buy/root'; +import { of } from 'rxjs'; +import { OpfQuickBuyAdapter } from './opf-quick-buy.adapter'; +import { OpfQuickBuyConnector } from './opf-quick-buy.connector'; +import createSpy = jasmine.createSpy; + +const mockGetApplePayWebSessionRequest: ApplePaySessionVerificationRequest = { + cartId: 'test', + validationUrl: 'test', + initiative: 'web', + initiativeContext: 'test', +}; + +const mockGetApplePayWebSessionResponse: ApplePaySessionVerificationResponse = { + epochTimestamp: 1, + expiresAt: 60000, + merchantSessionIdentifier: 'test', + nonce: 'test', + merchantIdentifier: 'test', + domainName: 'test', + displayName: 'test', + signature: 'test', +}; + +const mockAccessCode = 'accessCode'; + +class MockOpfQuickBuyAdapter implements OpfQuickBuyAdapter { + getApplePayWebSession = createSpy('getApplePayWebSession').and.callFake(() => + of(mockGetApplePayWebSessionResponse) + ); +} + +describe('OpfQuickBuyConnector', () => { + let service: OpfQuickBuyConnector; + let adapter: OpfQuickBuyAdapter; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + OpfQuickBuyConnector, + { provide: OpfQuickBuyAdapter, useClass: MockOpfQuickBuyAdapter }, + ], + }); + + service = TestBed.inject(OpfQuickBuyConnector); + adapter = TestBed.inject(OpfQuickBuyAdapter); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('getApplePayWebSession should call adapter', () => { + let result; + service + .getApplePayWebSession(mockGetApplePayWebSessionRequest, mockAccessCode) + .subscribe((res) => (result = res)); + expect(result).toEqual(mockGetApplePayWebSessionResponse); + expect(adapter.getApplePayWebSession).toHaveBeenCalledWith( + mockGetApplePayWebSessionRequest, + mockAccessCode + ); + }); +}); diff --git a/integration-libs/opf/quick-buy/core/connectors/opf-quick-buy.connector.ts b/integration-libs/opf/quick-buy/core/connectors/opf-quick-buy.connector.ts new file mode 100644 index 00000000000..416f63e782e --- /dev/null +++ b/integration-libs/opf/quick-buy/core/connectors/opf-quick-buy.connector.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { + ApplePaySessionVerificationRequest, + ApplePaySessionVerificationResponse, +} from '@spartacus/opf/quick-buy/root'; +import { Observable } from 'rxjs'; +import { OpfQuickBuyAdapter } from './opf-quick-buy.adapter'; + +@Injectable() +export class OpfQuickBuyConnector { + constructor(protected adapter: OpfQuickBuyAdapter) {} + + public getApplePayWebSession( + applePayWebRequest: ApplePaySessionVerificationRequest, + accessCode: string + ): Observable { + return this.adapter.getApplePayWebSession(applePayWebRequest, accessCode); + } +} diff --git a/integration-libs/opf/quick-buy/core/facade/facade-providers.ts b/integration-libs/opf/quick-buy/core/facade/facade-providers.ts new file mode 100644 index 00000000000..277cb27157c --- /dev/null +++ b/integration-libs/opf/quick-buy/core/facade/facade-providers.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Provider } from '@angular/core'; +import { OpfQuickBuyFacade } from '@spartacus/opf/quick-buy/root'; +import { OpfQuickBuyService } from './opf-quick-buy.service'; + +export const facadeProviders: Provider[] = [ + OpfQuickBuyService, + { + provide: OpfQuickBuyFacade, + useExisting: OpfQuickBuyService, + }, +]; diff --git a/integration-libs/opf/quick-buy/core/facade/index.ts b/integration-libs/opf/quick-buy/core/facade/index.ts new file mode 100644 index 00000000000..b71ebd71bf7 --- /dev/null +++ b/integration-libs/opf/quick-buy/core/facade/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-quick-buy.service'; diff --git a/integration-libs/opf/quick-buy/core/facade/opf-quick-buy.service.spec.ts b/integration-libs/opf/quick-buy/core/facade/opf-quick-buy.service.spec.ts new file mode 100644 index 00000000000..a2ad6968c94 --- /dev/null +++ b/integration-libs/opf/quick-buy/core/facade/opf-quick-buy.service.spec.ts @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TestBed } from '@angular/core/testing'; +import { + ActiveCartFacade, + CartAccessCodeFacade, +} from '@spartacus/cart/base/root'; +import { UserIdService } from '@spartacus/core'; +import { + ApplePaySessionVerificationRequest, + ApplePaySessionVerificationResponse, +} from '@spartacus/opf/quick-buy/root'; +import { of } from 'rxjs'; +import { OpfQuickBuyConnector } from '../connectors'; +import { OpfQuickBuyService } from './opf-quick-buy.service'; + +const mockGetApplePayWebSessionRequest: ApplePaySessionVerificationRequest = { + cartId: 'test', + validationUrl: 'test', + initiative: 'web', + initiativeContext: 'test', +}; + +const mockGetApplePayWebSessionResponse: ApplePaySessionVerificationResponse = { + epochTimestamp: 1, + expiresAt: 60000, + merchantSessionIdentifier: 'test', + nonce: 'test', + merchantIdentifier: 'test', + domainName: 'test', + displayName: 'test', + signature: 'test', +}; + +const mockAccessCode = 'mockAccessCode'; + +describe('OpfQuickBuyService', () => { + let service: OpfQuickBuyService; + let opfQuickBuyConnector: jasmine.SpyObj; + let cartAccessCodeFacade: jasmine.SpyObj; + let activeCartFacade: jasmine.SpyObj; + let userIdService: jasmine.SpyObj; + + beforeEach(() => { + const opfQuickBuyConnectorSpy = jasmine.createSpyObj( + 'OpfQuickBuyConnector', + ['getApplePayWebSession'] + ); + const cartAccessCodeFacadeSpy = jasmine.createSpyObj( + 'CartAccessCodeFacade', + ['getCartAccessCode'] + ); + const activeCartFacadeSpy = jasmine.createSpyObj('ActiveCartFacade', [ + 'getActiveCartId', + ]); + const userIdServiceSpy = jasmine.createSpyObj('UserIdService', [ + 'getUserId', + ]); + + TestBed.configureTestingModule({ + providers: [ + OpfQuickBuyService, + { provide: OpfQuickBuyConnector, useValue: opfQuickBuyConnectorSpy }, + { provide: CartAccessCodeFacade, useValue: cartAccessCodeFacadeSpy }, + { provide: ActiveCartFacade, useValue: activeCartFacadeSpy }, + { provide: UserIdService, useValue: userIdServiceSpy }, + ], + }); + + service = TestBed.inject(OpfQuickBuyService); + opfQuickBuyConnector = TestBed.inject( + OpfQuickBuyConnector + ) as jasmine.SpyObj; + cartAccessCodeFacade = TestBed.inject( + CartAccessCodeFacade + ) as jasmine.SpyObj; + activeCartFacade = TestBed.inject( + ActiveCartFacade + ) as jasmine.SpyObj; + userIdService = TestBed.inject( + UserIdService + ) as jasmine.SpyObj; + }); + + it('should successfully get ApplePay web session', (done) => { + userIdService.getUserId.and.returnValue(of('mockUserId')); + activeCartFacade.getActiveCartId.and.returnValue(of('mockCartId')); + cartAccessCodeFacade.getCartAccessCode.and.returnValue( + of({ accessCode: mockAccessCode }) + ); + opfQuickBuyConnector.getApplePayWebSession.and.returnValue( + of(mockGetApplePayWebSessionResponse) + ); + + service + .getApplePayWebSession(mockGetApplePayWebSessionRequest) + .subscribe((response) => { + expect(response).toEqual(mockGetApplePayWebSessionResponse); + expect(opfQuickBuyConnector.getApplePayWebSession).toHaveBeenCalledWith( + mockGetApplePayWebSessionRequest, + mockAccessCode + ); + done(); + }); + }); +}); diff --git a/integration-libs/opf/quick-buy/core/facade/opf-quick-buy.service.ts b/integration-libs/opf/quick-buy/core/facade/opf-quick-buy.service.ts new file mode 100644 index 00000000000..779d71908fd --- /dev/null +++ b/integration-libs/opf/quick-buy/core/facade/opf-quick-buy.service.ts @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { + ActiveCartFacade, + CartAccessCodeFacade, +} from '@spartacus/cart/base/root'; +import { + backOff, + Command, + CommandService, + DEFAULT_AUTHORIZATION_ERROR_RETRIES_COUNT, + isAuthorizationError, + LoggerService, + tryNormalizeHttpError, + UserIdService, +} from '@spartacus/core'; +import { + ApplePaySessionVerificationRequest, + ApplePaySessionVerificationResponse, + OpfQuickBuyFacade, +} from '@spartacus/opf/quick-buy/root'; +import { + catchError, + combineLatest, + concatMap, + filter, + switchMap, + take, +} from 'rxjs'; +import { OpfQuickBuyConnector } from '../connectors'; + +@Injectable() +export class OpfQuickBuyService implements OpfQuickBuyFacade { + protected applePaySessionCommand: Command< + { + applePayWebSessionRequest: ApplePaySessionVerificationRequest; + }, + ApplePaySessionVerificationResponse + > = this.commandService.create((payload) => { + return combineLatest([ + this.userIdService.getUserId(), + this.activeCartFacade.getActiveCartId(), + ]).pipe( + filter( + ([userId, activeCartId]: [string, string]) => !!activeCartId && !!userId + ), + switchMap(([userId, activeCartId]: [string, string]) => { + return this.cartAccessCodeFacade.getCartAccessCode( + userId, + activeCartId + ); + }), + filter((response) => Boolean(response?.accessCode)), + take(1), + concatMap(({ accessCode: accessCode }) => { + return this.opfQuickBuyConnector.getApplePayWebSession( + payload.applePayWebSessionRequest, + accessCode + ); + }), + catchError((error) => { + throw tryNormalizeHttpError(error, this.logger); + }), + backOff({ + shouldRetry: isAuthorizationError, + maxTries: DEFAULT_AUTHORIZATION_ERROR_RETRIES_COUNT, + }) + ); + }); + + constructor( + protected commandService: CommandService, + protected opfQuickBuyConnector: OpfQuickBuyConnector, + protected cartAccessCodeFacade: CartAccessCodeFacade, + protected activeCartFacade: ActiveCartFacade, + protected userIdService: UserIdService, + protected logger: LoggerService + ) {} + + getApplePayWebSession( + applePayWebSessionRequest: ApplePaySessionVerificationRequest + ) { + return this.applePaySessionCommand.execute({ applePayWebSessionRequest }); + } +} diff --git a/integration-libs/opf/quick-buy/core/ng-package.json b/integration-libs/opf/quick-buy/core/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/quick-buy/core/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/quick-buy/core/opf-quick-buy-core.module.ts b/integration-libs/opf/quick-buy/core/opf-quick-buy-core.module.ts new file mode 100644 index 00000000000..d5672dd6a9f --- /dev/null +++ b/integration-libs/opf/quick-buy/core/opf-quick-buy-core.module.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfQuickBuyConnector } from './connectors'; +import { facadeProviders } from './facade/facade-providers'; + +@NgModule({ + imports: [], + providers: [...facadeProviders, OpfQuickBuyConnector], +}) +export class OpfQuickBuyCoreModule {} diff --git a/integration-libs/opf/quick-buy/core/public_api.ts b/integration-libs/opf/quick-buy/core/public_api.ts new file mode 100644 index 00000000000..6f213071e9c --- /dev/null +++ b/integration-libs/opf/quick-buy/core/public_api.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './connectors/index'; +export * from './facade/index'; +export * from './opf-quick-buy-core.module'; +export * from './services/index'; +export * from './tokens/index'; diff --git a/integration-libs/opf/quick-buy/core/services/index.ts b/integration-libs/opf/quick-buy/core/services/index.ts new file mode 100644 index 00000000000..372f83fa89e --- /dev/null +++ b/integration-libs/opf/quick-buy/core/services/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-quick-buy-transaction.service'; diff --git a/integration-libs/opf/quick-buy/core/services/opf-quick-buy-transaction.service.spec.ts b/integration-libs/opf/quick-buy/core/services/opf-quick-buy-transaction.service.spec.ts new file mode 100644 index 00000000000..1b2de4fcf42 --- /dev/null +++ b/integration-libs/opf/quick-buy/core/services/opf-quick-buy-transaction.service.spec.ts @@ -0,0 +1,527 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fakeAsync, flush, TestBed } from '@angular/core/testing'; +import { StoreModule } from '@ngrx/store'; +import { + ActiveCartFacade, + Cart, + CartGuestUserFacade, + DeliveryMode, + MultiCartFacade, +} from '@spartacus/cart/base/root'; +import { + CheckoutBillingAddressFacade, + CheckoutDeliveryAddressFacade, + CheckoutDeliveryModesFacade, +} from '@spartacus/checkout/base/root'; +import { + Address, + AuthService, + BaseSiteService, + EventService, + RouterState, + RoutingService, + UserAddressService, + UserIdService, +} from '@spartacus/core'; +import { OpfGlobalMessageService } from '@spartacus/opf/base/root'; +import { + OPF_QUICK_BUY_DEFAULT_MERCHANT_NAME, + OpfQuickBuyLocation, +} from '@spartacus/opf/quick-buy/root'; +import { BehaviorSubject, of, throwError } from 'rxjs'; +import { OpfQuickBuyTransactionService } from './opf-quick-buy-transaction.service'; + +describe('OpfQuickBuyTransactionService', () => { + let service: OpfQuickBuyTransactionService; + let activeCartFacade: jasmine.SpyObj; + let checkoutDeliveryModesFacade: jasmine.SpyObj; + let checkoutDeliveryAddressFacade: jasmine.SpyObj; + let userAddressService: jasmine.SpyObj; + let multiCartFacade: jasmine.SpyObj; + let userIdService: jasmine.SpyObj; + let eventService: jasmine.SpyObj; + let checkoutBillingAddressFacade: jasmine.SpyObj; + let baseSiteService: jasmine.SpyObj; + let routingService: jasmine.SpyObj; + let opfGlobalMessageService: jasmine.SpyObj; + let authService: jasmine.SpyObj; + let cartGuestUserFacade: jasmine.SpyObj; + + beforeEach(() => { + activeCartFacade = jasmine.createSpyObj('ActiveCartFacade', [ + 'addEntry', + 'isStable', + 'takeActive', + 'getActive', + 'deleteCart', + 'takeActiveCartId', + 'getActiveCartId', + 'isGuestCart', + ]); + checkoutDeliveryModesFacade = jasmine.createSpyObj( + 'CheckoutDeliveryModesFacade', + [ + 'getSupportedDeliveryModes', + 'setDeliveryMode', + 'getSelectedDeliveryModeState', + ] + ); + checkoutDeliveryAddressFacade = jasmine.createSpyObj( + 'CheckoutDeliveryAddressFacade', + ['createAndSetAddress', 'getDeliveryAddressState'] + ); + userAddressService = jasmine.createSpyObj('UserAddressService', [ + 'deleteUserAddress', + ]); + multiCartFacade = jasmine.createSpyObj('MultiCartFacade', [ + 'deleteCart', + 'getCartIdByType', + 'createCart', + 'addEntry', + 'isStable', + 'loadCart', + 'getEntry', + 'removeEntry', + 'updateEntry', + 'reloadCart', + ]); + userIdService = jasmine.createSpyObj('UserIdService', [ + 'getUserId', + 'takeUserId', + ]); + eventService = jasmine.createSpyObj('EventService', ['get']); + checkoutBillingAddressFacade = jasmine.createSpyObj( + 'CheckoutBillingAddressFacade', + ['setBillingAddress'] + ); + baseSiteService = jasmine.createSpyObj('BaseSiteService', ['get']); + routingService = jasmine.createSpyObj('RoutingService', ['getRouterState']); + opfGlobalMessageService = jasmine.createSpyObj('OpfGlobalMessageService', [ + 'disableGlobalMessage', + ]); + authService = jasmine.createSpyObj('AuthService', ['isUserLoggedIn']); + cartGuestUserFacade = jasmine.createSpyObj('CartGuestUserFacade', [ + 'createCartGuestUser', + ]); + + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({})], + providers: [ + OpfQuickBuyTransactionService, + { provide: ActiveCartFacade, useValue: activeCartFacade }, + { + provide: CheckoutDeliveryModesFacade, + useValue: checkoutDeliveryModesFacade, + }, + { + provide: CheckoutDeliveryAddressFacade, + useValue: checkoutDeliveryAddressFacade, + }, + { provide: UserAddressService, useValue: userAddressService }, + { provide: MultiCartFacade, useValue: multiCartFacade }, + { provide: UserIdService, useValue: userIdService }, + { provide: EventService, useValue: eventService }, + { + provide: CheckoutBillingAddressFacade, + useValue: checkoutBillingAddressFacade, + }, + { provide: BaseSiteService, useValue: baseSiteService }, + { provide: RoutingService, useValue: routingService }, + { provide: OpfGlobalMessageService, useValue: opfGlobalMessageService }, + { provide: AuthService, useValue: authService }, + { provide: CartGuestUserFacade, useValue: cartGuestUserFacade }, + ], + }); + + service = TestBed.inject(OpfQuickBuyTransactionService); + }); + + describe('checkStableCart', () => { + it('should return true if the cart is stable', fakeAsync(() => { + activeCartFacade.isStable.and.returnValue(of(true)); + multiCartFacade.isStable.and.returnValue(of(true)); + + service.checkStableCart().subscribe((isStable) => { + expect(isStable).toBeTruthy(); + flush(); + }); + })); + }); + + describe('getSupportedDeliveryModes', () => { + it('should return an observable of delivery modes', (done) => { + const mockDeliveryModes: DeliveryMode[] = [ + { code: 'standard', name: 'Standard Delivery' }, + { code: 'express', name: 'Express Delivery' }, + ]; + + checkoutDeliveryModesFacade.getSupportedDeliveryModes.and.returnValue( + of(mockDeliveryModes) + ); + + service.getSupportedDeliveryModes().subscribe((deliveryModes) => { + expect(deliveryModes).toEqual(mockDeliveryModes); + done(); + }); + }); + }); + + describe('setDeliveryAddress', () => { + it('should set the delivery address and return its ID', (done) => { + const mockAddress: Address = {}; + const mockAddressId = 'addressId'; + + activeCartFacade.isStable.and.returnValue(of(true)); + checkoutDeliveryAddressFacade.createAndSetAddress.and.returnValue( + of(null) + ); + checkoutDeliveryAddressFacade.getDeliveryAddressState.and.returnValue( + of({ + loading: false, + error: false, + data: { id: mockAddressId }, + }) + ); + + service.setDeliveryAddress(mockAddress).subscribe((addressId) => { + expect(addressId).toEqual(mockAddressId); + expect( + checkoutDeliveryAddressFacade.createAndSetAddress + ).toHaveBeenCalledWith(mockAddress); + done(); + }); + }); + + it('should handle an undefined address', (done) => { + const mockAddress: Address = {}; + + checkoutDeliveryAddressFacade.createAndSetAddress.and.returnValue( + of(null) + ); + activeCartFacade.isStable.and.returnValue(of(true)); + checkoutDeliveryAddressFacade.getDeliveryAddressState.and.returnValue( + of({ + loading: false, + error: false, + data: undefined, + }) + ); + + service.setDeliveryAddress(mockAddress).subscribe((result) => { + expect(result).toEqual(''); + done(); + }); + }); + + it('should handle an address without an id', (done) => { + const mockAddress: Address = { + firstName: 'John', + lastName: 'Doe', + }; + + checkoutDeliveryAddressFacade.createAndSetAddress.and.returnValue( + of(null) + ); + activeCartFacade.isStable.and.returnValue(of(true)); + checkoutDeliveryAddressFacade.getDeliveryAddressState.and.returnValue( + of({ + loading: false, + error: false, + data: mockAddress, + }) + ); + + service.setDeliveryAddress(mockAddress).subscribe((result) => { + expect(result).toEqual(''); + done(); + }); + }); + }); + + describe('setBillingAddress', () => { + it('should set the billing address and check if the cart is stable', (done) => { + const mockAddress: Address = {}; + checkoutBillingAddressFacade.setBillingAddress.and.returnValue(of(true)); + + activeCartFacade.isStable.and.returnValue(of(true)); + + service.setBillingAddress(mockAddress).subscribe((result) => { + expect(result).toBeTruthy(); + expect( + checkoutBillingAddressFacade.setBillingAddress + ).toHaveBeenCalledWith(mockAddress); + done(); + }); + }); + + it('should return false if setting the billing address fails', (done) => { + const mockAddress: Address = {}; + + checkoutBillingAddressFacade.setBillingAddress.and.returnValue( + throwError(() => new Error('Setting address failed')) + ); + + service.setBillingAddress(mockAddress).subscribe( + (result) => { + expect(result).toBeFalsy(); + expect( + checkoutBillingAddressFacade.setBillingAddress + ).toHaveBeenCalledWith(mockAddress); + done(); + }, + (error) => { + expect(error).toBeDefined(); + done(); + } + ); + }); + }); + + describe('getCurrentCart', () => { + it('should return an observable of the current cart', (done) => { + const mockCart: Cart = { guid: 'testId' }; + + activeCartFacade.takeActive.and.returnValue(of(mockCart)); + + service.getCurrentCart().subscribe((cart) => { + expect(cart.guid).toEqual(mockCart.guid); + expect(activeCartFacade.takeActive).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('getCurrentCartId', () => { + it('should return an observable of the current cart ID', (done) => { + const mockCartId = '12345'; + activeCartFacade.takeActiveCartId.and.returnValue(of(mockCartId)); + + service.getCurrentCartId().subscribe((cartId) => { + expect(cartId).toEqual(mockCartId); + expect(activeCartFacade.takeActiveCartId).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('getCurrentCartTotalPrice', () => { + it('should return an observable of the current cart total price', (done) => { + const mockTotalPrice = 100.5; + const mockCart = { totalPrice: { value: mockTotalPrice } }; + + activeCartFacade.takeActive.and.returnValue(of(mockCart)); + + service.getCurrentCartTotalPrice().subscribe((totalPrice) => { + expect(totalPrice).toEqual(mockTotalPrice); + expect(activeCartFacade.takeActive).toHaveBeenCalled(); + done(); + }); + }); + + it('should return undefined if the cart does not have a total price', (done) => { + const mockCart = { totalPrice: undefined }; + + activeCartFacade.takeActive.and.returnValue(of(mockCart)); + + service.getCurrentCartTotalPrice().subscribe((totalPrice) => { + expect(totalPrice).toBeUndefined(); + expect(activeCartFacade.takeActive).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('setDeliveryMode', () => { + it('should set the delivery mode and return the selected mode', (done) => { + const mockMode = 'standard'; + const mockDeliveryMode: DeliveryMode = { + code: mockMode, + name: 'Standard Delivery', + }; + + checkoutDeliveryModesFacade.setDeliveryMode.and.returnValue( + of(mockDeliveryMode) + ); + + checkoutDeliveryModesFacade.getSelectedDeliveryModeState.and.returnValue( + of({ + loading: false, + error: false, + data: mockDeliveryMode, + }) + ); + + service.setDeliveryMode(mockMode).subscribe((selectedMode) => { + expect(selectedMode).toEqual(mockDeliveryMode); + expect( + checkoutDeliveryModesFacade.setDeliveryMode + ).toHaveBeenCalledWith(mockMode); + done(); + }); + }); + + it('should return undefined if setting the delivery mode fails', (done) => { + const mockMode = 'express'; + + checkoutDeliveryModesFacade.setDeliveryMode.and.returnValue( + throwError(new Error('Failed to set mode')) + ); + + service.setDeliveryMode(mockMode).subscribe( + (selectedMode) => { + expect(selectedMode).toBeUndefined(); + expect( + checkoutDeliveryModesFacade.setDeliveryMode + ).toHaveBeenCalledWith(mockMode); + done(); + }, + (error) => { + expect(error).toBeDefined(); + done(); + } + ); + }); + }); + + describe('getSelectedDeliveryMode', () => { + it('should return an observable of the selected delivery mode', (done) => { + const mockDeliveryMode: DeliveryMode = { + code: 'standard', + name: 'Standard Delivery', + }; + + checkoutDeliveryModesFacade.getSelectedDeliveryModeState.and.returnValue( + of({ + loading: false, + error: false, + data: mockDeliveryMode, + }) + ); + + service.getSelectedDeliveryMode().subscribe((selectedMode) => { + expect(selectedMode).toEqual(mockDeliveryMode); + done(); + }); + }); + + it('should return undefined if no delivery mode is selected', (done) => { + checkoutDeliveryModesFacade.getSelectedDeliveryModeState.and.returnValue( + of({ + loading: false, + error: false, + data: undefined, + }) + ); + + service.getSelectedDeliveryMode().subscribe((selectedMode) => { + expect(selectedMode).toBeUndefined(); + done(); + }); + }); + }); + + describe('deleteUserAddresses', () => { + it('should call deleteUserAddress for each address ID', () => { + const addrIds = ['addr1', 'addr2', 'addr3']; + + service.deleteUserAddresses(addrIds); + + addrIds.forEach((addrId) => { + expect(userAddressService.deleteUserAddress).toHaveBeenCalledWith( + addrId + ); + }); + }); + + it('should disable global messages for address deletion success', () => { + const addrIds = ['addr1', 'addr2']; + + service.deleteUserAddresses(addrIds); + + expect(opfGlobalMessageService.disableGlobalMessage).toHaveBeenCalledWith( + ['addressForm.userAddressDeleteSuccess'] + ); + }); + }); + + describe('getMerchantName', () => { + it('should return baseSite name', (done) => { + const mockName = 'Store'; + baseSiteService.get.and.returnValue(of({ name: mockName })); + service.getMerchantName().subscribe((merchantName) => { + expect(merchantName).toBe(mockName); + done(); + }); + }); + it('should return default MerchantName name when empty', (done) => { + const mockName = undefined; + baseSiteService.get.and.returnValue(of({ name: mockName })); + service.getMerchantName().subscribe((merchantName) => { + expect(merchantName).toBe(OPF_QUICK_BUY_DEFAULT_MERCHANT_NAME); + done(); + }); + }); + }); + + describe('getTransactionLocationContext', () => { + it('should return OpfQuickBuyLocation', () => { + const routerState = new BehaviorSubject({ + state: { semanticRoute: 'cart' }, + } as RouterState); + routingService.getRouterState.and.returnValue(routerState); + + service.getTransactionLocationContext().subscribe((context) => { + expect(context).toBe(OpfQuickBuyLocation.CART); + }); + }); + }); + + describe('createCartGuestUser', () => { + it('should call `createCartGuestUser` from `CartGuestUserFacade` with params', () => { + userIdService.takeUserId.and.returnValue(of('userId')); + activeCartFacade.takeActiveCartId.and.returnValue(of('cartId')); + multiCartFacade.reloadCart.and.returnValue(); + cartGuestUserFacade.createCartGuestUser.and.returnValue(of({})); + + service + .createCartGuestUser() + .subscribe((result) => { + expect(cartGuestUserFacade.createCartGuestUser).toHaveBeenCalledWith( + 'userId', + 'cartId' + ); + expect(result).toBe(true); + expect(multiCartFacade.reloadCart).toHaveBeenCalled(); + }) + .unsubscribe(); + }); + }); + + describe('handleCartGuestUser', () => { + it('should call `createCartGuestUser` if cart belongs to an anonymous user', () => { + authService.isUserLoggedIn.and.returnValue(of(false)); + activeCartFacade.isGuestCart.and.returnValue(of(false)); + userIdService.takeUserId.and.returnValue(of('userId')); + activeCartFacade.takeActiveCartId.and.returnValue(of('cartId')); + multiCartFacade.reloadCart.and.returnValue(); + cartGuestUserFacade.createCartGuestUser.and.returnValue(of({})); + + service + .handleCartGuestUser() + .subscribe((result) => { + expect(cartGuestUserFacade.createCartGuestUser).toHaveBeenCalledWith( + 'userId', + 'cartId' + ); + expect(result).toBe(true); + expect(multiCartFacade.reloadCart).toHaveBeenCalled(); + }) + .unsubscribe(); + }); + }); +}); diff --git a/integration-libs/opf/quick-buy/core/services/opf-quick-buy-transaction.service.ts b/integration-libs/opf/quick-buy/core/services/opf-quick-buy-transaction.service.ts new file mode 100644 index 00000000000..09d637c805c --- /dev/null +++ b/integration-libs/opf/quick-buy/core/services/opf-quick-buy-transaction.service.ts @@ -0,0 +1,218 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { inject, Injectable } from '@angular/core'; +import { + ActiveCartFacade, + Cart, + CartGuestUserFacade, + DeliveryMode, + MultiCartFacade, +} from '@spartacus/cart/base/root'; +import { + CheckoutBillingAddressFacade, + CheckoutDeliveryAddressFacade, + CheckoutDeliveryModesFacade, +} from '@spartacus/checkout/base/root'; +import { + Address, + AuthService, + BaseSiteService, + QueryState, + RoutingService, + UserAddressService, + UserIdService, +} from '@spartacus/core'; +import { OpfGlobalMessageService } from '@spartacus/opf/base/root'; +import { + OPF_QUICK_BUY_DEFAULT_MERCHANT_NAME, + OpfQuickBuyDeliveryInfo, + OpfQuickBuyDeliveryType, + OpfQuickBuyLocation, +} from '@spartacus/opf/quick-buy/root'; +import { combineLatest, Observable, of } from 'rxjs'; +import { filter, map, switchMap, take, tap } from 'rxjs/operators'; +@Injectable({ + providedIn: 'root', +}) +export class OpfQuickBuyTransactionService { + protected baseSiteService = inject(BaseSiteService); + protected activeCartFacade = inject(ActiveCartFacade); + protected routingService = inject(RoutingService); + protected checkoutDeliveryModesFacade = inject(CheckoutDeliveryModesFacade); + protected checkoutDeliveryAddressFacade = inject( + CheckoutDeliveryAddressFacade + ); + protected checkoutBillingAddressFacade = inject(CheckoutBillingAddressFacade); + protected userAddressService = inject(UserAddressService); + protected opfGlobalMessageService = inject(OpfGlobalMessageService); + protected cartGuestUserFacade = inject(CartGuestUserFacade); + protected authService = inject(AuthService); + protected userIdService = inject(UserIdService); + protected multiCartFacade = inject(MultiCartFacade); + + getTransactionDeliveryType(): Observable { + return this.activeCartFacade.hasDeliveryItems().pipe( + take(1), + map((hasDeliveryItems: boolean) => + hasDeliveryItems + ? OpfQuickBuyDeliveryType.SHIPPING + : OpfQuickBuyDeliveryType.PICKUP + ) + ); + } + + getTransactionDeliveryInfo(): Observable { + const deliveryTypeObservable = this.getTransactionDeliveryType().pipe( + map((deliveryType) => { + return { + type: deliveryType, + } as OpfQuickBuyDeliveryInfo; + }) + ); + + return deliveryTypeObservable.pipe(take(1)); + } + + getTransactionLocationContext(): Observable { + return this.routingService.getRouterState().pipe( + take(1), + map( + (routerState) => + routerState?.state?.semanticRoute?.toLocaleUpperCase() as OpfQuickBuyLocation + ) + ); + } + + getMerchantName(): Observable { + return this.baseSiteService.get().pipe( + take(1), + map((baseSite) => baseSite?.name ?? OPF_QUICK_BUY_DEFAULT_MERCHANT_NAME) + ); + } + + checkStableCart(): Observable { + return this.activeCartFacade.isStable().pipe( + filter((isStable) => !!isStable), + take(1) + ); + } + + getSupportedDeliveryModes(): Observable { + return this.checkoutDeliveryModesFacade.getSupportedDeliveryModes(); + } + + setDeliveryAddress(address: Address): Observable { + this.opfGlobalMessageService.disableGlobalMessage([ + 'addressForm.userAddressAddSuccess', + ]); + return this.checkoutDeliveryAddressFacade.createAndSetAddress(address).pipe( + switchMap(() => this.checkStableCart()), + switchMap(() => + this.getDeliveryAddress().pipe( + map((addr: Address | undefined) => addr?.id ?? '') + ) + ) + ); + } + + setBillingAddress(address: Address): Observable { + return this.checkoutBillingAddressFacade + .setBillingAddress(address) + .pipe(switchMap(() => this.checkStableCart())); + } + + getDeliveryAddress(): Observable
{ + return this.checkoutDeliveryAddressFacade.getDeliveryAddressState().pipe( + filter((state: QueryState
) => !state.loading), + take(1), + map((state: QueryState
) => { + return state.data; + }) + ); + } + + getCurrentCart(): Observable { + return this.activeCartFacade.takeActive(); + } + + getCurrentCartId(): Observable { + return this.activeCartFacade.takeActiveCartId(); + } + + getCurrentCartTotalPrice(): Observable { + return this.activeCartFacade + .takeActive() + .pipe(map((cart: Cart) => cart.totalPrice?.value)); + } + + setDeliveryMode(mode: string): Observable { + return this.checkoutDeliveryModesFacade.setDeliveryMode(mode).pipe( + switchMap(() => + this.checkoutDeliveryModesFacade.getSelectedDeliveryModeState() + ), + filter( + (state: QueryState) => + !state.error && !state.loading + ), + take(1), + map((state: QueryState) => state.data) + ); + } + + getSelectedDeliveryMode(): Observable { + return this.checkoutDeliveryModesFacade.getSelectedDeliveryModeState().pipe( + filter( + (state: QueryState) => + !state.error && !state.loading + ), + take(1), + map((state: QueryState) => state.data) + ); + } + + deleteUserAddresses(addrIds: string[]): void { + this.opfGlobalMessageService.disableGlobalMessage([ + 'addressForm.userAddressDeleteSuccess', + ]); + addrIds.forEach((addrId) => { + this.userAddressService.deleteUserAddress(addrId); + }); + } + + createCartGuestUser(): Observable { + return combineLatest([ + this.userIdService.takeUserId(), + this.activeCartFacade.takeActiveCartId(), + ]).pipe( + take(1), + switchMap(([userId, cartId]) => { + return this.cartGuestUserFacade + .createCartGuestUser(userId, cartId) + .pipe( + tap(() => this.multiCartFacade.reloadCart(cartId)), + map(() => true) + ); + }) + ); + } + + handleCartGuestUser(): Observable { + return combineLatest([ + this.authService.isUserLoggedIn(), + this.activeCartFacade.isGuestCart(), + ]).pipe( + take(1), + switchMap(([isUserLoggedIn, isGuestCart]) => { + if (isUserLoggedIn || isGuestCart) { + return of(true); + } + + return this.createCartGuestUser(); + }) + ); + } +} diff --git a/integration-libs/opf/quick-buy/core/tokens/index.ts b/integration-libs/opf/quick-buy/core/tokens/index.ts new file mode 100644 index 00000000000..62a40ab0d42 --- /dev/null +++ b/integration-libs/opf/quick-buy/core/tokens/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './tokens'; diff --git a/integration-libs/opf/quick-buy/core/tokens/tokens.ts b/integration-libs/opf/quick-buy/core/tokens/tokens.ts new file mode 100644 index 00000000000..f14dcf1415f --- /dev/null +++ b/integration-libs/opf/quick-buy/core/tokens/tokens.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { InjectionToken } from '@angular/core'; +import { Converter } from '@spartacus/core'; +import { ApplePaySessionVerificationResponse } from '@spartacus/opf/quick-buy/root'; + +export const OPF_APPLE_PAY_WEB_SESSION_NORMALIZER = new InjectionToken< + Converter +>('OpfApplePayWebSession'); diff --git a/integration-libs/opf/quick-buy/ng-package.json b/integration-libs/opf/quick-buy/ng-package.json new file mode 100644 index 00000000000..38e01ac17de --- /dev/null +++ b/integration-libs/opf/quick-buy/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/quick-buy/opf-api/adapters/index.ts b/integration-libs/opf/quick-buy/opf-api/adapters/index.ts new file mode 100644 index 00000000000..b8040b7c01c --- /dev/null +++ b/integration-libs/opf/quick-buy/opf-api/adapters/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-api-quick-buy.adapter'; diff --git a/integration-libs/opf/quick-buy/opf-api/adapters/opf-api-quick-buy.adapter.spec.ts b/integration-libs/opf/quick-buy/opf-api/adapters/opf-api-quick-buy.adapter.spec.ts new file mode 100644 index 00000000000..b70811cda81 --- /dev/null +++ b/integration-libs/opf/quick-buy/opf-api/adapters/opf-api-quick-buy.adapter.spec.ts @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { ConverterService, LoggerService } from '@spartacus/core'; +import { OpfEndpointsService } from '@spartacus/opf/base/core'; +import { + OPF_CC_ACCESS_CODE_HEADER, + OPF_CC_PUBLIC_KEY_HEADER, + OpfConfig, +} from '@spartacus/opf/base/root'; +import { OpfApiQuickBuyAdapter } from './opf-api-quick-buy.adapter'; +import { ApplePaySessionVerificationRequest } from '@spartacus/opf/quick-buy/root'; +import { catchError, Observable, throwError } from 'rxjs'; + +describe('OpfApiQuickBuyAdapter', () => { + let service: OpfApiQuickBuyAdapter; + let httpMock: HttpTestingController; + let mockConverter: jasmine.SpyObj; + let mockOpfEndpointsService: jasmine.SpyObj; + let mockOpfConfig: OpfConfig; + let mockLogger: jasmine.SpyObj; + + const applePaySessionRequest: ApplePaySessionVerificationRequest = { + validationUrl: 'https://example.com', + cartId: 'test', + initiative: 'web', + initiativeContext: 'test', + }; + + const accessCode = 'test-access-code'; + const mockResponse = { + epochTimestamp: 1625230000000, + expiresAt: 1625233600000, + merchantSessionIdentifier: 'merchant-session-id', + nonce: 'test-nonce', + merchantIdentifier: 'merchant.com.example', + domainName: 'example.com', + displayName: 'Example Merchant', + signature: 'test-signature', + }; + + beforeEach(() => { + mockConverter = jasmine.createSpyObj('ConverterService', ['pipeable']); + mockOpfEndpointsService = jasmine.createSpyObj('OpfEndpointsService', [ + 'buildUrl', + ]); + mockLogger = jasmine.createSpyObj('LoggerService', ['warn', 'error']); + mockOpfConfig = { opf: { commerceCloudPublicKey: 'public-key' } }; + + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + OpfApiQuickBuyAdapter, + { provide: ConverterService, useValue: mockConverter }, + { provide: OpfEndpointsService, useValue: mockOpfEndpointsService }, + { provide: OpfConfig, useValue: mockOpfConfig }, + { provide: LoggerService, useValue: mockLogger }, + ], + }); + + service = TestBed.inject(OpfApiQuickBuyAdapter); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call the correct URL and return the correct response', () => { + mockOpfEndpointsService.buildUrl.and.returnValue('/test-url'); + mockConverter.pipeable.and.callFake(() => { + return (source: Observable) => source; + }); + service + .getApplePayWebSession(applePaySessionRequest, accessCode) + .subscribe((response) => { + expect(response).toEqual(mockResponse); + }); + + const req = httpMock.expectOne('/test-url'); + expect(req.request.method).toBe('POST'); + + const headers = req.request.headers; + expect(headers.get('Content-Language')).toBe('en-us'); + expect(headers.get(OPF_CC_PUBLIC_KEY_HEADER)).toBe('public-key'); + expect(headers.get(OPF_CC_ACCESS_CODE_HEADER)).toBe(accessCode); + + req.flush(mockResponse); + }); + + it('should handle error and log it', () => { + mockOpfEndpointsService.buildUrl.and.returnValue('/test-url'); + const mockError = { status: 500, message: 'Server Error' }; + mockConverter.pipeable.and.callFake(() => { + return (source: Observable) => source; + }); + + service + .getApplePayWebSession(applePaySessionRequest, accessCode) + .pipe( + catchError((error) => { + expect(mockLogger.error).toHaveBeenCalled(); + return throwError(() => error); + }) + ) + .subscribe({ + error: (error) => { + expect(error.status).toBe(500); + }, + }); + + const req = httpMock.expectOne('/test-url'); + req.flush(mockError, { status: 500, statusText: 'Server Error' }); + }); +}); diff --git a/integration-libs/opf/quick-buy/opf-api/adapters/opf-api-quick-buy.adapter.ts b/integration-libs/opf/quick-buy/opf-api/adapters/opf-api-quick-buy.adapter.ts new file mode 100644 index 00000000000..d4c326813f0 --- /dev/null +++ b/integration-libs/opf/quick-buy/opf-api/adapters/opf-api-quick-buy.adapter.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { + backOff, + ConverterService, + isServerError, + LoggerService, + tryNormalizeHttpError, +} from '@spartacus/core'; +import { OpfEndpointsService } from '@spartacus/opf/base/core'; +import { + OPF_CC_ACCESS_CODE_HEADER, + OPF_CC_PUBLIC_KEY_HEADER, + OpfConfig, +} from '@spartacus/opf/base/root'; +import { + OPF_APPLE_PAY_WEB_SESSION_NORMALIZER, + OpfQuickBuyAdapter, +} from '@spartacus/opf/quick-buy/core'; +import { + ApplePaySessionVerificationRequest, + ApplePaySessionVerificationResponse, +} from '@spartacus/opf/quick-buy/root'; +import { Observable } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +@Injectable() +export class OpfApiQuickBuyAdapter implements OpfQuickBuyAdapter { + protected logger = inject(LoggerService); + + constructor( + protected http: HttpClient, + protected converter: ConverterService, + protected opfEndpointsService: OpfEndpointsService, + protected config: OpfConfig + ) {} + + protected headerWithNoLanguage: { [name: string]: string } = { + accept: 'application/json', + 'Content-Type': 'application/json', + }; + + protected headerWithContentLanguage: { [name: string]: string } = { + ...this.headerWithNoLanguage, + 'Content-Language': 'en-us', + }; + + getApplePayWebSession( + applePayWebSessionRequest: ApplePaySessionVerificationRequest, + accessCode: string + ): Observable { + const headers = new HttpHeaders(this.headerWithContentLanguage) + .set( + OPF_CC_PUBLIC_KEY_HEADER, + this.config.opf?.commerceCloudPublicKey || '' + ) + .set(OPF_CC_ACCESS_CODE_HEADER, accessCode || ''); + + const url = this.getApplePayWebSessionEndpoint(); + + return this.http + .post( + url, + applePayWebSessionRequest, + { headers } + ) + .pipe( + catchError((error) => { + throw tryNormalizeHttpError(error, this.logger); + }), + backOff({ + shouldRetry: isServerError, + maxTries: 2, + }), + this.converter.pipeable(OPF_APPLE_PAY_WEB_SESSION_NORMALIZER) + ); + } + + protected getApplePayWebSessionEndpoint(): string { + return this.opfEndpointsService.buildUrl('getApplePayWebSession'); + } +} diff --git a/integration-libs/opf/quick-buy/opf-api/config/default-opf-api-quick-buy-config.ts b/integration-libs/opf/quick-buy/opf-api/config/default-opf-api-quick-buy-config.ts new file mode 100644 index 00000000000..0a4fabb33aa --- /dev/null +++ b/integration-libs/opf/quick-buy/opf-api/config/default-opf-api-quick-buy-config.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpfApiConfig } from '@spartacus/opf/base/root'; + +export const defaultOpfApiQuickBuyConfig: OpfApiConfig = { + backend: { + opfApi: { + endpoints: { + getApplePayWebSession: 'payments/apple-pay-web-sessions', + }, + }, + }, +}; diff --git a/integration-libs/opf/quick-buy/opf-api/model/index.ts b/integration-libs/opf/quick-buy/opf-api/model/index.ts new file mode 100644 index 00000000000..30a8f2f0853 --- /dev/null +++ b/integration-libs/opf/quick-buy/opf-api/model/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-api-quick-buy-endpoints.model'; diff --git a/integration-libs/opf/quick-buy/opf-api/model/opf-api-quick-buy-endpoints.model.ts b/integration-libs/opf/quick-buy/opf-api/model/opf-api-quick-buy-endpoints.model.ts new file mode 100644 index 00000000000..72106c06243 --- /dev/null +++ b/integration-libs/opf/quick-buy/opf-api/model/opf-api-quick-buy-endpoints.model.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpfApiEndpoint } from '@spartacus/opf/base/root'; + +declare module '@spartacus/opf/base/root' { + interface OpfApiEndpoints { + /** + * Endpoint to get ApplePay Web Session for QuickBuy functionality + */ + getApplePayWebSession?: string | OpfApiEndpoint; + } +} diff --git a/integration-libs/opf/quick-buy/opf-api/ng-package.json b/integration-libs/opf/quick-buy/opf-api/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/quick-buy/opf-api/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/quick-buy/opf-api/opf-api-quick-buy.module.ts b/integration-libs/opf/quick-buy/opf-api/opf-api-quick-buy.module.ts new file mode 100644 index 00000000000..66dcfc623a9 --- /dev/null +++ b/integration-libs/opf/quick-buy/opf-api/opf-api-quick-buy.module.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { provideDefaultConfig } from '@spartacus/core'; +import { OpfQuickBuyAdapter } from '@spartacus/opf/quick-buy/core'; +import { OpfApiQuickBuyAdapter } from './adapters/opf-api-quick-buy.adapter'; +import { defaultOpfApiQuickBuyConfig } from './config/default-opf-api-quick-buy-config'; + +@NgModule({ + imports: [CommonModule], + providers: [ + provideDefaultConfig(defaultOpfApiQuickBuyConfig), + { + provide: OpfQuickBuyAdapter, + useClass: OpfApiQuickBuyAdapter, + }, + ], +}) +export class OpfApiQuickBuyModule {} diff --git a/integration-libs/opf/quick-buy/opf-api/public_api.ts b/integration-libs/opf/quick-buy/opf-api/public_api.ts new file mode 100644 index 00000000000..46b610b8780 --- /dev/null +++ b/integration-libs/opf/quick-buy/opf-api/public_api.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './model/index'; +export * from './opf-api-quick-buy.module'; diff --git a/integration-libs/opf/quick-buy/opf-quick-buy.module.ts b/integration-libs/opf/quick-buy/opf-quick-buy.module.ts new file mode 100644 index 00000000000..ad609e7683e --- /dev/null +++ b/integration-libs/opf/quick-buy/opf-quick-buy.module.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { OpfQuickBuyComponentsModule } from '@spartacus/opf/quick-buy/components'; +import { OpfQuickBuyCoreModule } from '@spartacus/opf/quick-buy/core'; +import { OpfApiQuickBuyModule } from '@spartacus/opf/quick-buy/opf-api'; + +@NgModule({ + imports: [ + OpfQuickBuyComponentsModule, + OpfQuickBuyCoreModule, + OpfApiQuickBuyModule, + ], +}) +export class OpfQuickBuyModule {} diff --git a/integration-libs/opf/quick-buy/public_api.ts b/integration-libs/opf/quick-buy/public_api.ts new file mode 100644 index 00000000000..71d3b23b87d --- /dev/null +++ b/integration-libs/opf/quick-buy/public_api.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-quick-buy.module'; diff --git a/integration-libs/opf/quick-buy/root/facade/index.ts b/integration-libs/opf/quick-buy/root/facade/index.ts new file mode 100644 index 00000000000..75e7004272b --- /dev/null +++ b/integration-libs/opf/quick-buy/root/facade/index.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './opf-quick-buy.facade'; diff --git a/integration-libs/opf/quick-buy/root/facade/opf-quick-buy.facade.ts b/integration-libs/opf/quick-buy/root/facade/opf-quick-buy.facade.ts new file mode 100644 index 00000000000..61478f2cab9 --- /dev/null +++ b/integration-libs/opf/quick-buy/root/facade/opf-quick-buy.facade.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable } from '@angular/core'; +import { facadeFactory } from '@spartacus/core'; +import { Observable } from 'rxjs'; +import { OPF_QUICK_BUY_FEATURE } from '../feature-name'; +import { + ApplePaySessionVerificationRequest, + ApplePaySessionVerificationResponse, +} from '../model'; + +@Injectable({ + providedIn: 'root', + useFactory: () => + facadeFactory({ + facade: OpfQuickBuyFacade, + feature: OPF_QUICK_BUY_FEATURE, + methods: ['getApplePayWebSession'], + }), +}) +export abstract class OpfQuickBuyFacade { + /** + * Abstract method to get ApplePay session for QuickBuy. + * + * @param applePayWebSessionRequest + */ + abstract getApplePayWebSession( + applePayWebSessionRequest: ApplePaySessionVerificationRequest + ): Observable; +} diff --git a/integration-libs/opf/quick-buy/root/feature-name.ts b/integration-libs/opf/quick-buy/root/feature-name.ts new file mode 100644 index 00000000000..0afcf7b7e5a --- /dev/null +++ b/integration-libs/opf/quick-buy/root/feature-name.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export const OPF_QUICK_BUY_FEATURE = 'opfQuickBuy'; diff --git a/integration-libs/opf/quick-buy/root/model/augmented-opf-quick-buy.model.ts b/integration-libs/opf/quick-buy/root/model/augmented-opf-quick-buy.model.ts new file mode 100644 index 00000000000..97d3c0fd426 --- /dev/null +++ b/integration-libs/opf/quick-buy/root/model/augmented-opf-quick-buy.model.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@spartacus/opf/base/root'; +import '@spartacus/opf/payment/root'; +import { OpfQuickBuyDigitalWallet } from './opf-quick-buy.model'; + +declare module '@spartacus/opf/base/root' { + interface ActiveConfiguration { + digitalWalletQuickBuy?: OpfQuickBuyDigitalWallet[]; + } +} diff --git a/integration-libs/opf/quick-buy/root/model/constants.ts b/integration-libs/opf/quick-buy/root/model/constants.ts new file mode 100644 index 00000000000..da073ccd573 --- /dev/null +++ b/integration-libs/opf/quick-buy/root/model/constants.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export const OPF_QUICK_BUY_DEFAULT_MERCHANT_NAME: string = 'Store'; +export const OPF_QUICK_BUY_ADDRESS_FIELD_PLACEHOLDER = '[FIELD_NOT_SET]'; diff --git a/integration-libs/opf/quick-buy/root/model/index.ts b/integration-libs/opf/quick-buy/root/model/index.ts new file mode 100644 index 00000000000..8ca6e243e9b --- /dev/null +++ b/integration-libs/opf/quick-buy/root/model/index.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import './augmented-opf-quick-buy.model'; +export * from './constants'; +export * from './opf-apple-pay.model'; +export * from './opf-quick-buy.model'; diff --git a/integration-libs/opf/quick-buy/root/model/opf-apple-pay.model.ts b/integration-libs/opf/quick-buy/root/model/opf-apple-pay.model.ts new file mode 100644 index 00000000000..efcde27e7c1 --- /dev/null +++ b/integration-libs/opf/quick-buy/root/model/opf-apple-pay.model.ts @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Cart } from '@spartacus/cart/base/root'; +import { Product } from '@spartacus/core'; +import { Observable } from 'rxjs'; + +export interface ApplePaySessionVerificationRequest { + cartId: string; + validationUrl: string; + initiative: 'web'; + initiativeContext: string; +} + +export interface ApplePaySessionVerificationResponse { + epochTimestamp: number; + expiresAt: number; + merchantSessionIdentifier: string; + nonce: string; + merchantIdentifier: string; + domainName: string; + displayName: string; + signature: string; +} + +export interface ApplePayAuthorizationResult { + authResult: any; + payment: any; +} + +export interface ApplePayTransactionInput { + product?: Product; + cart?: Cart; + quantity?: number; + countryCode?: string; +} + +export interface ApplePayObservableConfig { + request: any; + validateMerchant: (event: any) => Observable; + shippingContactSelected: (event: any) => Observable; + paymentMethodSelected: (event: any) => Observable; + shippingMethodSelected: (event: any) => Observable; + paymentAuthorized: (event: any) => Observable; +} + +export enum ApplePayEvent { + VALIDATE_MERCHANT = 'validatemerchant', + CANCEL = 'cancel', + PAYMENT_METHOD_SELECTED = 'paymentmethodselected', + SHIPPING_CONTACT_SELECTED = 'shippingcontactselected', + SHIPPING_METHOD_SELECTED = 'shippingmethodselected', + PAYMENT_AUTHORIZED = 'paymentauthorized', +} + +export enum ApplePayShippingType { + SHIPPING = 'shipping', + DELIVERY = 'delivery', + STORE_PICKUP = 'storePickup', + SERVICE_PICKUP = 'servicePickup', +} diff --git a/integration-libs/opf/quick-buy/root/model/opf-quick-buy.model.ts b/integration-libs/opf/quick-buy/root/model/opf-quick-buy.model.ts new file mode 100644 index 00000000000..9e25b4dbed9 --- /dev/null +++ b/integration-libs/opf/quick-buy/root/model/opf-quick-buy.model.ts @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Cart } from '@spartacus/cart/base/root'; +import { PointOfService, Product } from '@spartacus/core'; + +export interface OpfQuickBuyDigitalWallet { + description?: string; + provider?: OpfProviderType; + enabled?: boolean; + merchantId?: string; + merchantName?: string; + countryCode?: string; + googlePayGateway?: string; +} + +export interface OpfQuickBuyDeliveryInfo { + type: OpfQuickBuyDeliveryType; + pickupDetails?: PointOfService; +} + +export interface QuickBuyTransactionDetails { + context?: OpfQuickBuyLocation; + cart?: Cart; + product?: Product; + quantity?: number; + deliveryInfo?: OpfQuickBuyDeliveryInfo; + addressIds: string[]; + total: { + amount: string; + label: string; + currency: string; + }; +} + +export enum OpfQuickBuyLocation { + CART = 'CART', + PRODUCT = 'PRODUCT', +} + +export enum OpfQuickBuyDeliveryType { + SHIPPING = 'SHIPPING', + PICKUP = 'PICKUP', +} + +export enum OpfProviderType { + APPLE_PAY = 'APPLE_PAY', + GOOGLE_PAY = 'GOOGLE_PAY', +} diff --git a/integration-libs/opf/quick-buy/root/ng-package.json b/integration-libs/opf/quick-buy/root/ng-package.json new file mode 100644 index 00000000000..2a9c6221b0e --- /dev/null +++ b/integration-libs/opf/quick-buy/root/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "./public_api.ts" + } +} diff --git a/integration-libs/opf/quick-buy/root/opf-quick-buy-root.module.ts b/integration-libs/opf/quick-buy/root/opf-quick-buy-root.module.ts new file mode 100644 index 00000000000..799e1a17ae2 --- /dev/null +++ b/integration-libs/opf/quick-buy/root/opf-quick-buy-root.module.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule } from '@angular/core'; +import { CmsConfig, provideDefaultConfigFactory } from '@spartacus/core'; +import { OPF_QUICK_BUY_FEATURE } from './feature-name'; + +export function defaultOpfQuickBuyCmsComponentsConfig(): CmsConfig { + const config: CmsConfig = { + featureModules: { + [OPF_QUICK_BUY_FEATURE]: { + cmsComponents: ['OpfQuickBuyButtonsComponent'], + }, + }, + }; + return config; +} + +@NgModule({ + providers: [ + provideDefaultConfigFactory(defaultOpfQuickBuyCmsComponentsConfig), + ], +}) +export class OpfQuickBuyRootModule {} diff --git a/integration-libs/opf/quick-buy/root/public_api.ts b/integration-libs/opf/quick-buy/root/public_api.ts new file mode 100644 index 00000000000..794e9f4a3c5 --- /dev/null +++ b/integration-libs/opf/quick-buy/root/public_api.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './facade/index'; +export * from './feature-name'; +export * from './model/index'; +export * from './opf-quick-buy-root.module'; diff --git a/integration-libs/opf/quick-buy/styles/_index.scss b/integration-libs/opf/quick-buy/styles/_index.scss new file mode 100644 index 00000000000..192091fb04e --- /dev/null +++ b/integration-libs/opf/quick-buy/styles/_index.scss @@ -0,0 +1 @@ +@import './components/index'; diff --git a/integration-libs/opf/quick-buy/styles/components/_index.scss b/integration-libs/opf/quick-buy/styles/components/_index.scss new file mode 100644 index 00000000000..a164d47a3a4 --- /dev/null +++ b/integration-libs/opf/quick-buy/styles/components/_index.scss @@ -0,0 +1,3 @@ +@import './opf-google-pay'; +@import './opf-apple-pay'; +@import './opf-quick-buy-buttons'; diff --git a/integration-libs/opf/quick-buy/styles/components/_opf-apple-pay.scss b/integration-libs/opf/quick-buy/styles/components/_opf-apple-pay.scss new file mode 100644 index 00000000000..be4e0763c88 --- /dev/null +++ b/integration-libs/opf/quick-buy/styles/components/_opf-apple-pay.scss @@ -0,0 +1,11 @@ +%cx-opf-apple-pay { + .apple-pay-button { + -webkit-appearance: -apple-pay-button; + -apple-pay-button-type: buy; + -apple-pay-button-style: black; + border-radius: var(--cx-buttons-border-radius); + margin-top: 10px; + height: 48px; + cursor: pointer; + } +} diff --git a/integration-libs/opf/quick-buy/styles/components/_opf-google-pay.scss b/integration-libs/opf/quick-buy/styles/components/_opf-google-pay.scss new file mode 100644 index 00000000000..9faa0fcb666 --- /dev/null +++ b/integration-libs/opf/quick-buy/styles/components/_opf-google-pay.scss @@ -0,0 +1,14 @@ +%cx-opf-google-pay { + .cx-opf-google-pay-button { + margin: 10px 0; + height: 48px; + @include media-breakpoint-down(md) { + padding: 0; + } + .gpay-button, + .gpay-card-info-container { + border-radius: var(--cx-buttons-border-radius); + min-width: auto; + } + } +} diff --git a/integration-libs/opf/quick-buy/styles/components/_opf-quick-buy-buttons.scss b/integration-libs/opf/quick-buy/styles/components/_opf-quick-buy-buttons.scss new file mode 100644 index 00000000000..7efd0a4f9d7 --- /dev/null +++ b/integration-libs/opf/quick-buy/styles/components/_opf-quick-buy-buttons.scss @@ -0,0 +1,23 @@ +%cx-opf-quick-buy-buttons { + padding-inline-end: 0; + padding-inline-start: 3rem; + padding-top: 0; + + @include media-breakpoint-up(lg) { + flex: none; + } + + @include media-breakpoint-down(md) { + align-self: flex-end; + padding-inline-start: 0; + order: 5; + } + + @include media-breakpoint-down(sm) { + margin-bottom: -2rem; + padding: 2rem 0 0; + max-width: 100%; + padding-inline-end: 0; + padding-inline-start: 0; + } +} diff --git a/integration-libs/opf/schematics/.gitignore b/integration-libs/opf/schematics/.gitignore new file mode 100644 index 00000000000..c88f4d69e15 --- /dev/null +++ b/integration-libs/opf/schematics/.gitignore @@ -0,0 +1,18 @@ +# Outputs +**/*.js +**/*.js.map +**/*.d.ts + +# IDEs +.idea/ +jsconfig.json +.vscode/ + +# Misc +node_modules/ +npm-debug.log* +yarn-error.log* + +# Mac OSX Finder files. +**/.DS_Store +.DS_Store diff --git a/integration-libs/opf/schematics/add-opf/__snapshots__/index_spec.ts.snap b/integration-libs/opf/schematics/add-opf/__snapshots__/index_spec.ts.snap new file mode 100644 index 00000000000..1c97473974f --- /dev/null +++ b/integration-libs/opf/schematics/add-opf/__snapshots__/index_spec.ts.snap @@ -0,0 +1,178 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Spartacus SAP OPF integration schematics: ng-add SAP OPF feature general setup should add the feature using the lazy loading syntax 1`] = ` +"import { NgModule } from '@angular/core'; +import { CmsConfig, provideConfig } from "@spartacus/core"; +import { OPF_QUICK_BUY_FEATURE, OpfQuickBuyRootModule } from "@spartacus/opf/quick-buy/root"; + +@NgModule({ + declarations: [], + imports: [ + OpfQuickBuyRootModule + ], + providers: [provideConfig({ + featureModules: { + [OPF_QUICK_BUY_FEATURE]: { + module: () => + import('@spartacus/opf/quick-buy').then((m) => m.OpfQuickBuyModule), + }, + } + })] +}) +export class OpfFeatureModule { } +" +`; + +exports[`Spartacus SAP OPF integration schematics: ng-add SAP OPF feature general setup should add the feature using the lazy loading syntax 2`] = ` +"import { NgModule } from '@angular/core'; +import { CmsConfig, provideConfig } from "@spartacus/core"; +import { OPF_QUICK_BUY_FEATURE, OpfQuickBuyRootModule } from "@spartacus/opf/quick-buy/root"; + +@NgModule({ + declarations: [], + imports: [ + OpfQuickBuyRootModule + ], + providers: [provideConfig({ + featureModules: { + [OPF_QUICK_BUY_FEATURE]: { + module: () => + import('@spartacus/opf/quick-buy').then((m) => m.OpfQuickBuyModule), + }, + } + })] +}) +export class OpfFeatureModule { } +" +`; + +exports[`Spartacus SAP OPF integration schematics: ng-add SAP OPF feature general setup should add the feature using the lazy loading syntax 3`] = `""`; + +exports[`Spartacus SAP OPF integration schematics: ng-add SAP OPF feature general setup styling should create a proper scss file 1`] = ` +"@import "../../styles-config"; +@import "@spartacus/opf"; +" +`; + +exports[`Spartacus SAP OPF integration schematics: ng-add SAP OPF feature general setup styling should update angular.json 1`] = ` +"{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "", + "projects": { + "schematics-test": { + "projectType": "application", + "schematics": { + "@schematics/angular:component": { + "style": "scss", + "standalone": false + }, + "@schematics/angular:directive": { + "standalone": false + }, + "@schematics/angular:pipe": { + "standalone": false + } + }, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:application", + "options": { + "outputPath": "dist/schematics-test", + "index": "src/index.html", + "browser": "src/main.ts", + "polyfills": [ + "zone.js" + ], + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss", + "src/styles/spartacus/opf.scss" + ], + "scripts": [], + "stylePreprocessorOptions": { + "includePaths": [ + "node_modules/" + ] + } + }, + "configurations": { + "production": { + "budgets": [ + { + "type": "initial", + "maximumWarning": "500kb", + "maximumError": "3.5mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kb", + "maximumError": "4kb" + } + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true + } + }, + "defaultConfiguration": "production" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "configurations": { + "production": { + "buildTarget": "schematics-test:build:production" + }, + "development": { + "buildTarget": "schematics-test:build:development" + } + }, + "defaultConfiguration": "development" + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "schematics-test:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "tsconfig.spec.json", + "inlineStyleLanguage": "scss", + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.scss", + "src/styles/spartacus/opf.scss" + ], + "scripts": [], + "stylePreprocessorOptions": { + "includePaths": [ + "node_modules/" + ] + } + } + } + } + } + } +}" +`; diff --git a/integration-libs/opf/schematics/add-opf/index.ts b/integration-libs/opf/schematics/add-opf/index.ts new file mode 100644 index 00000000000..854865f0061 --- /dev/null +++ b/integration-libs/opf/schematics/add-opf/index.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + chain, + Rule, + SchematicContext, + Tree, +} from '@angular-devkit/schematics'; +import { + addFeatures, + addPackageJsonDependenciesForLibrary, + analyzeApplication, + analyzeCrossFeatureDependencies, + finalizeInstallation, + readPackageJson, + LibraryOptions as SpartacusOpfOptions, + validateSpartacusInstallation, +} from '@spartacus/schematics'; +import { peerDependencies } from '../../package.json'; + +export function addOpfFeature(options: SpartacusOpfOptions): Rule { + return (tree: Tree, _context: SchematicContext) => { + const packageJson = readPackageJson(tree); + validateSpartacusInstallation(packageJson); + + const features = analyzeCrossFeatureDependencies( + options.features as string[] + ); + + return chain([ + analyzeApplication(options, features), + + addFeatures(options, features), + addPackageJsonDependenciesForLibrary(peerDependencies, options), + + finalizeInstallation(options, features), + ]); + }; +} diff --git a/integration-libs/opf/schematics/add-opf/index_spec.ts b/integration-libs/opf/schematics/add-opf/index_spec.ts new file mode 100644 index 00000000000..becb979d1ba --- /dev/null +++ b/integration-libs/opf/schematics/add-opf/index_spec.ts @@ -0,0 +1,244 @@ +/// + +import { RunSchematicTaskOptions } from '@angular-devkit/schematics/tasks/run-schematic/options'; +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import { + Schema as ApplicationOptions, + Style, +} from '@schematics/angular/application/schema'; +import { Schema as WorkspaceOptions } from '@schematics/angular/workspace/schema'; +import { + OPF_BASE_FEATURE_NAME, + OPF_CHECKOUT_FEATURE_NAME, + OPF_CTA_FEATURE_NAME, + OPF_GLOBAL_FUNCTIONS_FEATURE_NAME, + OPF_PAYMENT_FEATURE_NAME, + OPF_QUICK_BUY_FEATURE_NAME, + SPARTACUS_CHECKOUT, + SPARTACUS_OPF, + SPARTACUS_SCHEMATICS, + LibraryOptions as SpartacusOpfOptions, + SpartacusOptions, + checkoutWrapperModulePath, + opfFeatureModulePath, +} from '@spartacus/schematics'; +import * as path from 'path'; +import { peerDependencies } from '../../package.json'; +const collectionPath = path.join(__dirname, '../collection.json'); +const scssFilePath = 'src/styles/spartacus/opf.scss'; + +describe('Spartacus SAP OPF integration schematics: ng-add', () => { + const schematicRunner = new SchematicTestRunner( + SPARTACUS_OPF, + collectionPath + ); + + let appTree: UnitTestTree; + + const workspaceOptions: WorkspaceOptions = { + name: 'workspace', + version: '0.5.0', + }; + + const appOptions: ApplicationOptions = { + name: 'schematics-test', + inlineStyle: false, + inlineTemplate: false, + routing: false, + style: Style.Scss, + skipTests: false, + projectRoot: '', + standalone: false, + }; + + const spartacusDefaultOptions: SpartacusOptions = { + project: 'schematics-test', + lazy: true, + features: [], + }; + + const libraryNoFeaturesOptions: SpartacusOpfOptions = { + project: 'schematics-test', + lazy: true, + features: [], + }; + + const checkoutFeatureOptions: SpartacusOpfOptions = { + ...libraryNoFeaturesOptions, + features: [OPF_CHECKOUT_FEATURE_NAME], + }; + + const opfFeatureOptions: SpartacusOpfOptions = { + ...libraryNoFeaturesOptions, + features: [OPF_BASE_FEATURE_NAME], + }; + + const opfPaymentFeatureOptions: SpartacusOpfOptions = { + ...libraryNoFeaturesOptions, + features: [OPF_PAYMENT_FEATURE_NAME], + }; + + const opfCtaFeatureOptions: SpartacusOpfOptions = { + ...libraryNoFeaturesOptions, + features: [OPF_CTA_FEATURE_NAME], + }; + const opfGlobalFunctionsFeatureOptions: SpartacusOpfOptions = { + ...libraryNoFeaturesOptions, + features: [OPF_GLOBAL_FUNCTIONS_FEATURE_NAME], + }; + + const opfQuickBuyFeatureOptions: SpartacusOpfOptions = { + ...libraryNoFeaturesOptions, + features: [OPF_QUICK_BUY_FEATURE_NAME], + }; + + beforeEach(async () => { + schematicRunner.registerCollection( + SPARTACUS_SCHEMATICS, + path.join( + __dirname, + '../../../../projects/schematics/src/collection.json' + ) + ); + + schematicRunner.registerCollection( + SPARTACUS_CHECKOUT, + path.join( + __dirname, + '../../../../feature-libs/checkout/schematics/collection.json' + ) + ); + + appTree = await schematicRunner.runExternalSchematic( + '@schematics/angular', + 'workspace', + workspaceOptions + ); + + appTree = await schematicRunner.runExternalSchematic( + '@schematics/angular', + 'application', + appOptions, + appTree + ); + + appTree = await schematicRunner.runExternalSchematic( + SPARTACUS_SCHEMATICS, + 'ng-add', + { ...spartacusDefaultOptions, name: 'schematics-test' }, + appTree + ); + }); + + describe('Without features', () => { + beforeEach(async () => { + appTree = await schematicRunner.runSchematic( + 'ng-add', + { ...libraryNoFeaturesOptions, features: [] }, + appTree + ); + }); + + it('should not create any of the feature modules', () => { + expect(appTree.exists(opfFeatureModulePath)).toBeFalsy(); + }); + }); + + describe('SAP OPF feature', () => { + describe('general setup', () => { + beforeEach(async () => { + appTree = await schematicRunner.runSchematic( + 'ng-add', + { + ...checkoutFeatureOptions, + ...opfFeatureOptions, + ...opfPaymentFeatureOptions, + ...opfCtaFeatureOptions, + ...opfGlobalFunctionsFeatureOptions, + ...opfQuickBuyFeatureOptions, + }, + appTree + ); + }); + + it('should install necessary Spartacus libraries', () => { + const packageJson = JSON.parse(appTree.readContent('package.json')); + let dependencies: Record = {}; + dependencies = { ...packageJson.dependencies }; + dependencies = { ...dependencies, ...packageJson.devDependencies }; + + for (const toAdd in peerDependencies) { + if ( + !peerDependencies.hasOwnProperty(toAdd) || + toAdd === SPARTACUS_SCHEMATICS + ) { + continue; + } + const expectedDependency = dependencies[toAdd]; + expect(expectedDependency).toBeTruthy(); + } + }); + + it('should add the feature using the lazy loading syntax', async () => { + const module = appTree.readContent(opfFeatureModulePath); + expect(module).toMatchSnapshot(); + }); + + describe('styling', () => { + it('should create a proper scss file', () => { + const scssContent = appTree.readContent(scssFilePath); + expect(scssContent).toMatchSnapshot(); + }); + + it('should update angular.json', async () => { + const content = appTree.readContent('/angular.json'); + expect(content).toMatchSnapshot(); + }); + }); + + it('should install necessary Spartacus libraries', () => { + const packageJson = JSON.parse(appTree.readContent('package.json')); + let dependencies: Record = {}; + dependencies = { ...packageJson.dependencies }; + dependencies = { ...dependencies, ...packageJson.devDependencies }; + + for (const toAdd in peerDependencies) { + if ( + !peerDependencies.hasOwnProperty(toAdd) || + toAdd === SPARTACUS_SCHEMATICS + ) { + continue; + } + const expectedDependency = dependencies[toAdd]; + expect(expectedDependency).toBeTruthy(); + } + }); + + it('should run the proper installation tasks', async () => { + const tasks = schematicRunner.tasks.filter( + (task) => + task.name === 'run-schematic' && + (task.options as RunSchematicTaskOptions<{}>).collection === + '@sap/opf' + ); + + expect(tasks.length).toEqual(0); + }); + + it('should add the feature using the lazy loading syntax', async () => { + const module = appTree.readContent(opfFeatureModulePath); + expect(module).toMatchSnapshot(); + + const wrapperModule = appTree.readContent(checkoutWrapperModulePath); + expect(wrapperModule).toMatchSnapshot(); + }); + }); + }); +}); + +it('should pass schematics tests', async () => { + expect(true).toEqual(true); +}); diff --git a/integration-libs/opf/schematics/add-opf/schema.json b/integration-libs/opf/schematics/add-opf/schema.json new file mode 100644 index 00000000000..c106443db2c --- /dev/null +++ b/integration-libs/opf/schematics/add-opf/schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "OpfSchematics", + "title": "Opf Schematics", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + } + }, + "debug": { + "description": "Display additional details during the running process.", + "type": "boolean", + "default": true + }, + "lazy": { + "type": "boolean", + "description": "Lazy load the opf features.", + "default": true + }, + "features": { + "type": "array", + "uniqueItems": true, + "items": { + "enum": [ + "OPF-Checkout", + "OPF-Base", + "OPF-Payment", + "OPF-Cta", + "OPF-Global-Functions", + "OPF-Quick-Buy" + ], + "type": "string" + }, + "default": [ + "OPF-Checkout", + "OPF-Base", + "OPF-Payment", + "OPF-Cta", + "OPF-Global-Functions", + "OPF-Quick-Buy" + ] + }, + "opfBaseUrl": { + "type": "string", + "description": "The base url of Cloud Commerce Adapter for Open Payment Framework integration", + "x-prompt": "[OPF] What is the base URL (origin) of your OPF Cloud Commerce Adapter?" + }, + "commerceCloudPublicKey": { + "type": "string", + "description": "Commerce Clould public key required for authentication between OPF Cloud Commerce Adapter and Spartacus" + } + }, + "required": [] +} diff --git a/integration-libs/opf/schematics/collection.json b/integration-libs/opf/schematics/collection.json new file mode 100644 index 00000000000..c1248271fa0 --- /dev/null +++ b/integration-libs/opf/schematics/collection.json @@ -0,0 +1,18 @@ +{ + "$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json", + "schematics": { + "ng-add": { + "factory": "./add-opf/index#addOpfFeature", + "description": "Add and configure the SAP Open Payment Framework integration", + "schema": "./add-opf/schema.json", + "private": true, + "hidden": true, + "aliases": ["install"] + }, + "add": { + "factory": "./add-opf/index#addOpfFeature", + "description": "Add and configure the SAP Open Payment Framework integration", + "schema": "./add-opf/schema.json" + } + } +} diff --git a/integration-libs/opf/setup-jest.ts b/integration-libs/opf/setup-jest.ts new file mode 100644 index 00000000000..aeb0a861992 --- /dev/null +++ b/integration-libs/opf/setup-jest.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import 'jest-preset-angular/setup-jest'; +import 'zone.js'; diff --git a/integration-libs/opf/test.ts b/integration-libs/opf/test.ts new file mode 100644 index 00000000000..cb29fd468dd --- /dev/null +++ b/integration-libs/opf/test.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), + { + teardown: { destroyAfterEach: false }, + } +); diff --git a/integration-libs/opf/tsconfig.lib.json b/integration-libs/opf/tsconfig.lib.json new file mode 100644 index 00000000000..967fb1d8ddb --- /dev/null +++ b/integration-libs/opf/tsconfig.lib.json @@ -0,0 +1,129 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/lib", + "declarationMap": true, + "target": "es2020", + "module": "es2020", + "moduleResolution": "node", + "declaration": true, + "sourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "importHelpers": true, + "skipLibCheck": true, // Needed due to @sapui5/ts-types-esm + "lib": ["dom", "esnext"], + "paths": { + "@spartacus/cart/base/assets": ["dist/cart/base/assets"], + "@spartacus/cart/base/components/add-to-cart": [ + "dist/cart/base/components/add-to-cart" + ], + "@spartacus/cart/base/components/mini-cart": [ + "dist/cart/base/components/mini-cart" + ], + "@spartacus/cart/base/components": ["dist/cart/base/components"], + "@spartacus/cart/base/core": ["dist/cart/base/core"], + "@spartacus/cart/base": ["dist/cart/base"], + "@spartacus/cart/base/occ": ["dist/cart/base/occ"], + "@spartacus/cart/base/root": ["dist/cart/base/root"], + "@spartacus/cart/import-export/assets": [ + "dist/cart/import-export/assets" + ], + "@spartacus/cart/import-export/components": [ + "dist/cart/import-export/components" + ], + "@spartacus/cart/import-export/core": ["dist/cart/import-export/core"], + "@spartacus/cart/import-export": ["dist/cart/import-export"], + "@spartacus/cart/import-export/root": ["dist/cart/import-export/root"], + "@spartacus/cart": ["dist/cart"], + "@spartacus/cart/quick-order/assets": ["dist/cart/quick-order/assets"], + "@spartacus/cart/quick-order/components": [ + "dist/cart/quick-order/components" + ], + "@spartacus/cart/quick-order/core": ["dist/cart/quick-order/core"], + "@spartacus/cart/quick-order": ["dist/cart/quick-order"], + "@spartacus/cart/quick-order/root": ["dist/cart/quick-order/root"], + "@spartacus/cart/saved-cart/assets": ["dist/cart/saved-cart/assets"], + "@spartacus/cart/saved-cart/components": [ + "dist/cart/saved-cart/components" + ], + "@spartacus/cart/saved-cart/core": ["dist/cart/saved-cart/core"], + "@spartacus/cart/saved-cart": ["dist/cart/saved-cart"], + "@spartacus/cart/saved-cart/occ": ["dist/cart/saved-cart/occ"], + "@spartacus/cart/saved-cart/root": ["dist/cart/saved-cart/root"], + "@spartacus/cart/wish-list/assets": ["dist/cart/wish-list/assets"], + "@spartacus/cart/wish-list/components/add-to-wishlist": [ + "dist/cart/wish-list/components/add-to-wishlist" + ], + "@spartacus/cart/wish-list/components": [ + "dist/cart/wish-list/components" + ], + "@spartacus/cart/wish-list/core": ["dist/cart/wish-list/core"], + "@spartacus/cart/wish-list": ["dist/cart/wish-list"], + "@spartacus/cart/wish-list/root": ["dist/cart/wish-list/root"], + "@spartacus/checkout/b2b/assets": ["dist/checkout/b2b/assets"], + "@spartacus/checkout/b2b/components": ["dist/checkout/b2b/components"], + "@spartacus/checkout/b2b/core": ["dist/checkout/b2b/core"], + "@spartacus/checkout/b2b": ["dist/checkout/b2b"], + "@spartacus/checkout/b2b/occ": ["dist/checkout/b2b/occ"], + "@spartacus/checkout/b2b/root": ["dist/checkout/b2b/root"], + "@spartacus/checkout/base/assets": ["dist/checkout/base/assets"], + "@spartacus/checkout/base/components": ["dist/checkout/base/components"], + "@spartacus/checkout/base/core": ["dist/checkout/base/core"], + "@spartacus/checkout/base": ["dist/checkout/base"], + "@spartacus/checkout/base/occ": ["dist/checkout/base/occ"], + "@spartacus/checkout/base/root": ["dist/checkout/base/root"], + "@spartacus/checkout": ["dist/checkout"], + "@spartacus/checkout/scheduled-replenishment/assets": [ + "dist/checkout/scheduled-replenishment/assets" + ], + "@spartacus/checkout/scheduled-replenishment/components": [ + "dist/checkout/scheduled-replenishment/components" + ], + "@spartacus/checkout/scheduled-replenishment": [ + "dist/checkout/scheduled-replenishment" + ], + "@spartacus/checkout/scheduled-replenishment/root": [ + "dist/checkout/scheduled-replenishment/root" + ], + "@spartacus/core": ["dist/core"], + "@spartacus/order/assets": ["dist/order/assets"], + "@spartacus/order/components": ["dist/order/components"], + "@spartacus/order/core": ["dist/order/core"], + "@spartacus/order": ["dist/order"], + "@spartacus/order/occ": ["dist/order/occ"], + "@spartacus/order/root": ["dist/order/root"], + "@spartacus/storefront": ["dist/storefrontlib"], + "@spartacus/user/account/assets": ["dist/user/account/assets"], + "@spartacus/user/account/components": ["dist/user/account/components"], + "@spartacus/user/account/core": ["dist/user/account/core"], + "@spartacus/user/account": ["dist/user/account"], + "@spartacus/user/account/occ": ["dist/user/account/occ"], + "@spartacus/user/account/root": ["dist/user/account/root"], + "@spartacus/user": ["dist/user"], + "@spartacus/user/profile/assets": ["dist/user/profile/assets"], + "@spartacus/user/profile/components": ["dist/user/profile/components"], + "@spartacus/user/profile/core": ["dist/user/profile/core"], + "@spartacus/user/profile": ["dist/user/profile"], + "@spartacus/user/profile/occ": ["dist/user/profile/occ"], + "@spartacus/user/profile/root": ["dist/user/profile/root"], + "@spartacus/pdf-invoices/assets": ["dist/pdf-invoices/assets"], + "@spartacus/pdf-invoices/components": ["dist/pdf-invoices/components"], + "@spartacus/pdf-invoices/core": ["dist/pdf-invoices/core"], + "@spartacus/pdf-invoices": ["dist/pdf-invoices"], + "@spartacus/pdf-invoices/occ": ["dist/pdf-invoices/occ"], + "@spartacus/pdf-invoices/root": ["dist/pdf-invoices/root"] + }, + "resolveJsonModule": true, + "esModuleInterop": true + }, + "angularCompilerOptions": { + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "fullTemplateTypeCheck": true, + "strictInjectionParameters": true, + "enableResourceInlining": false + }, + "exclude": ["test.ts", "setup-jest.ts", "**/*.spec.ts"] +} diff --git a/integration-libs/opf/tsconfig.lib.prod.json b/integration-libs/opf/tsconfig.lib.prod.json new file mode 100644 index 00000000000..2a2faa884cf --- /dev/null +++ b/integration-libs/opf/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/integration-libs/opf/tsconfig.schematics.json b/integration-libs/opf/tsconfig.schematics.json new file mode 100644 index 00000000000..710df6b10a2 --- /dev/null +++ b/integration-libs/opf/tsconfig.schematics.json @@ -0,0 +1,742 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "lib": ["es2018", "dom"], + "declaration": true, + "module": "commonjs", + "moduleResolution": "node", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitThis": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "skipDefaultLibCheck": true, + "skipLibCheck": true, + "sourceMap": true, + "strictNullChecks": true, + "target": "es6", + "types": ["jest", "node"], + "resolveJsonModule": true, + "esModuleInterop": true, + "paths": { + "@spartacus/schematics": ["../../projects/schematics/index"], + "@spartacus/setup": ["../../core-libs/setup/public_api"], + "@spartacus/setup/ssr": ["../../core-libs/setup/ssr/public_api"], + "@spartacus/asm/assets": ["../../feature-libs/asm/assets/public_api"], + "@spartacus/asm/components": [ + "../../feature-libs/asm/components/public_api" + ], + "@spartacus/asm/core": ["../../feature-libs/asm/core/public_api"], + "@spartacus/asm/customer-360/assets": [ + "../../feature-libs/asm/customer-360/assets/public_api" + ], + "@spartacus/asm/customer-360/components": [ + "../../feature-libs/asm/customer-360/components/public_api" + ], + "@spartacus/asm/customer-360/core": [ + "../../feature-libs/asm/customer-360/core/public_api" + ], + "@spartacus/asm/customer-360": [ + "../../feature-libs/asm/customer-360/public_api" + ], + "@spartacus/asm/customer-360/occ": [ + "../../feature-libs/asm/customer-360/occ/public_api" + ], + "@spartacus/asm/customer-360/root": [ + "../../feature-libs/asm/customer-360/root/public_api" + ], + "@spartacus/asm": ["../../feature-libs/asm/public_api"], + "@spartacus/asm/occ": ["../../feature-libs/asm/occ/public_api"], + "@spartacus/asm/root": ["../../feature-libs/asm/root/public_api"], + "@spartacus/cart/base/assets": [ + "../../feature-libs/cart/base/assets/public_api" + ], + "@spartacus/cart/base/components/add-to-cart": [ + "../../feature-libs/cart/base/components/add-to-cart/public_api" + ], + "@spartacus/cart/base/components/mini-cart": [ + "../../feature-libs/cart/base/components/mini-cart/public_api" + ], + "@spartacus/cart/base/components": [ + "../../feature-libs/cart/base/components/public_api" + ], + "@spartacus/cart/base/core": [ + "../../feature-libs/cart/base/core/public_api" + ], + "@spartacus/cart/base": ["../../feature-libs/cart/base/public_api"], + "@spartacus/cart/base/occ": [ + "../../feature-libs/cart/base/occ/public_api" + ], + "@spartacus/cart/base/root": [ + "../../feature-libs/cart/base/root/public_api" + ], + "@spartacus/cart/import-export/assets": [ + "../../feature-libs/cart/import-export/assets/public_api" + ], + "@spartacus/cart/import-export/components": [ + "../../feature-libs/cart/import-export/components/public_api" + ], + "@spartacus/cart/import-export/core": [ + "../../feature-libs/cart/import-export/core/public_api" + ], + "@spartacus/cart/import-export": [ + "../../feature-libs/cart/import-export/public_api" + ], + "@spartacus/cart/import-export/root": [ + "../../feature-libs/cart/import-export/root/public_api" + ], + "@spartacus/cart": ["../../feature-libs/cart/public_api"], + "@spartacus/cart/quick-order/assets": [ + "../../feature-libs/cart/quick-order/assets/public_api" + ], + "@spartacus/cart/quick-order/components": [ + "../../feature-libs/cart/quick-order/components/public_api" + ], + "@spartacus/cart/quick-order/core": [ + "../../feature-libs/cart/quick-order/core/public_api" + ], + "@spartacus/cart/quick-order": [ + "../../feature-libs/cart/quick-order/public_api" + ], + "@spartacus/cart/quick-order/root": [ + "../../feature-libs/cart/quick-order/root/public_api" + ], + "@spartacus/cart/saved-cart/assets": [ + "../../feature-libs/cart/saved-cart/assets/public_api" + ], + "@spartacus/cart/saved-cart/components": [ + "../../feature-libs/cart/saved-cart/components/public_api" + ], + "@spartacus/cart/saved-cart/core": [ + "../../feature-libs/cart/saved-cart/core/public_api" + ], + "@spartacus/cart/saved-cart": [ + "../../feature-libs/cart/saved-cart/public_api" + ], + "@spartacus/cart/saved-cart/occ": [ + "../../feature-libs/cart/saved-cart/occ/public_api" + ], + "@spartacus/cart/saved-cart/root": [ + "../../feature-libs/cart/saved-cart/root/public_api" + ], + "@spartacus/cart/wish-list/assets": [ + "../../feature-libs/cart/wish-list/assets/public_api" + ], + "@spartacus/cart/wish-list/components/add-to-wishlist": [ + "../../feature-libs/cart/wish-list/components/add-to-wishlist/public_api" + ], + "@spartacus/cart/wish-list/components": [ + "../../feature-libs/cart/wish-list/components/public_api" + ], + "@spartacus/cart/wish-list/core": [ + "../../feature-libs/cart/wish-list/core/public_api" + ], + "@spartacus/cart/wish-list": [ + "../../feature-libs/cart/wish-list/public_api" + ], + "@spartacus/cart/wish-list/root": [ + "../../feature-libs/cart/wish-list/root/public_api" + ], + "@spartacus/checkout/b2b/assets": [ + "../../feature-libs/checkout/b2b/assets/public_api" + ], + "@spartacus/checkout/b2b/components": [ + "../../feature-libs/checkout/b2b/components/public_api" + ], + "@spartacus/checkout/b2b/core": [ + "../../feature-libs/checkout/b2b/core/public_api" + ], + "@spartacus/checkout/b2b": ["../../feature-libs/checkout/b2b/public_api"], + "@spartacus/checkout/b2b/occ": [ + "../../feature-libs/checkout/b2b/occ/public_api" + ], + "@spartacus/checkout/b2b/root": [ + "../../feature-libs/checkout/b2b/root/public_api" + ], + "@spartacus/checkout/base/assets": [ + "../../feature-libs/checkout/base/assets/public_api" + ], + "@spartacus/checkout/base/components": [ + "../../feature-libs/checkout/base/components/public_api" + ], + "@spartacus/checkout/base/core": [ + "../../feature-libs/checkout/base/core/public_api" + ], + "@spartacus/checkout/base": [ + "../../feature-libs/checkout/base/public_api" + ], + "@spartacus/checkout/base/occ": [ + "../../feature-libs/checkout/base/occ/public_api" + ], + "@spartacus/checkout/base/root": [ + "../../feature-libs/checkout/base/root/public_api" + ], + "@spartacus/checkout": ["../../feature-libs/checkout/public_api"], + "@spartacus/checkout/scheduled-replenishment/assets": [ + "../../feature-libs/checkout/scheduled-replenishment/assets/public_api" + ], + "@spartacus/checkout/scheduled-replenishment/components": [ + "../../feature-libs/checkout/scheduled-replenishment/components/public_api" + ], + "@spartacus/checkout/scheduled-replenishment": [ + "../../feature-libs/checkout/scheduled-replenishment/public_api" + ], + "@spartacus/checkout/scheduled-replenishment/root": [ + "../../feature-libs/checkout/scheduled-replenishment/root/public_api" + ], + "@spartacus/customer-ticketing/assets": [ + "../../feature-libs/customer-ticketing/assets/public_api" + ], + "@spartacus/customer-ticketing/components": [ + "../../feature-libs/customer-ticketing/components/public_api" + ], + "@spartacus/customer-ticketing/core": [ + "../../feature-libs/customer-ticketing/core/public_api" + ], + "@spartacus/customer-ticketing": [ + "../../feature-libs/customer-ticketing/public_api" + ], + "@spartacus/customer-ticketing/occ": [ + "../../feature-libs/customer-ticketing/occ/public_api" + ], + "@spartacus/customer-ticketing/root": [ + "../../feature-libs/customer-ticketing/root/public_api" + ], + "@spartacus/estimated-delivery-date/assets": [ + "../../feature-libs/estimated-delivery-date/assets/public_api" + ], + "@spartacus/estimated-delivery-date": [ + "../../feature-libs/estimated-delivery-date/public_api" + ], + "@spartacus/estimated-delivery-date/root": [ + "../../feature-libs/estimated-delivery-date/root/public_api" + ], + "@spartacus/estimated-delivery-date/show-estimated-delivery-date": [ + "../../feature-libs/estimated-delivery-date/show-estimated-delivery-date/public_api" + ], + "@spartacus/order/assets": ["../../feature-libs/order/assets/public_api"], + "@spartacus/order/components": [ + "../../feature-libs/order/components/public_api" + ], + "@spartacus/order/core": ["../../feature-libs/order/core/public_api"], + "@spartacus/order": ["../../feature-libs/order/public_api"], + "@spartacus/order/occ": ["../../feature-libs/order/occ/public_api"], + "@spartacus/order/root": ["../../feature-libs/order/root/public_api"], + "@spartacus/organization/account-summary/assets": [ + "../../feature-libs/organization/account-summary/assets/public_api" + ], + "@spartacus/organization/account-summary/components": [ + "../../feature-libs/organization/account-summary/components/public_api" + ], + "@spartacus/organization/account-summary/core": [ + "../../feature-libs/organization/account-summary/core/public_api" + ], + "@spartacus/organization/account-summary": [ + "../../feature-libs/organization/account-summary/public_api" + ], + "@spartacus/organization/account-summary/occ": [ + "../../feature-libs/organization/account-summary/occ/public_api" + ], + "@spartacus/organization/account-summary/root": [ + "../../feature-libs/organization/account-summary/root/public_api" + ], + "@spartacus/organization/administration/assets": [ + "../../feature-libs/organization/administration/assets/public_api" + ], + "@spartacus/organization/administration/components": [ + "../../feature-libs/organization/administration/components/public_api" + ], + "@spartacus/organization/administration/core": [ + "../../feature-libs/organization/administration/core/public_api" + ], + "@spartacus/organization/administration": [ + "../../feature-libs/organization/administration/public_api" + ], + "@spartacus/organization/administration/occ": [ + "../../feature-libs/organization/administration/occ/public_api" + ], + "@spartacus/organization/administration/root": [ + "../../feature-libs/organization/administration/root/public_api" + ], + "@spartacus/organization": ["../../feature-libs/organization/public_api"], + "@spartacus/organization/order-approval/assets": [ + "../../feature-libs/organization/order-approval/assets/public_api" + ], + "@spartacus/organization/order-approval": [ + "../../feature-libs/organization/order-approval/public_api" + ], + "@spartacus/organization/order-approval/root": [ + "../../feature-libs/organization/order-approval/root/public_api" + ], + "@spartacus/organization/unit-order/assets": [ + "../../feature-libs/organization/unit-order/assets/public_api" + ], + "@spartacus/organization/unit-order/components": [ + "../../feature-libs/organization/unit-order/components/public_api" + ], + "@spartacus/organization/unit-order/core": [ + "../../feature-libs/organization/unit-order/core/public_api" + ], + "@spartacus/organization/unit-order": [ + "../../feature-libs/organization/unit-order/public_api" + ], + "@spartacus/organization/unit-order/occ": [ + "../../feature-libs/organization/unit-order/occ/public_api" + ], + "@spartacus/organization/unit-order/root": [ + "../../feature-libs/organization/unit-order/root/public_api" + ], + "@spartacus/organization/user-registration/assets": [ + "../../feature-libs/organization/user-registration/assets/public_api" + ], + "@spartacus/organization/user-registration/components": [ + "../../feature-libs/organization/user-registration/components/public_api" + ], + "@spartacus/organization/user-registration/core": [ + "../../feature-libs/organization/user-registration/core/public_api" + ], + "@spartacus/organization/user-registration": [ + "../../feature-libs/organization/user-registration/public_api" + ], + "@spartacus/organization/user-registration/occ": [ + "../../feature-libs/organization/user-registration/occ/public_api" + ], + "@spartacus/organization/user-registration/root": [ + "../../feature-libs/organization/user-registration/root/public_api" + ], + "@spartacus/pdf-invoices/assets": [ + "../../feature-libs/pdf-invoices/assets/public_api" + ], + "@spartacus/pdf-invoices/components": [ + "../../feature-libs/pdf-invoices/components/public_api" + ], + "@spartacus/pdf-invoices/core": [ + "../../feature-libs/pdf-invoices/core/public_api" + ], + "@spartacus/pdf-invoices": ["../../feature-libs/pdf-invoices/public_api"], + "@spartacus/pdf-invoices/occ": [ + "../../feature-libs/pdf-invoices/occ/public_api" + ], + "@spartacus/pdf-invoices/root": [ + "../../feature-libs/pdf-invoices/root/public_api" + ], + "@spartacus/pickup-in-store/assets": [ + "../../feature-libs/pickup-in-store/assets/public_api" + ], + "@spartacus/pickup-in-store/components": [ + "../../feature-libs/pickup-in-store/components/public_api" + ], + "@spartacus/pickup-in-store/core": [ + "../../feature-libs/pickup-in-store/core/public_api" + ], + "@spartacus/pickup-in-store": [ + "../../feature-libs/pickup-in-store/public_api" + ], + "@spartacus/pickup-in-store/occ": [ + "../../feature-libs/pickup-in-store/occ/public_api" + ], + "@spartacus/pickup-in-store/root": [ + "../../feature-libs/pickup-in-store/root/public_api" + ], + "@spartacus/product-configurator/common/assets": [ + "../../feature-libs/product-configurator/common/assets/public_api" + ], + "@spartacus/product-configurator/common": [ + "../../feature-libs/product-configurator/common/public_api" + ], + "@spartacus/product-configurator": [ + "../../feature-libs/product-configurator/public_api" + ], + "@spartacus/product-configurator/rulebased/cpq": [ + "../../feature-libs/product-configurator/rulebased/cpq/public_api" + ], + "@spartacus/product-configurator/rulebased": [ + "../../feature-libs/product-configurator/rulebased/public_api" + ], + "@spartacus/product-configurator/rulebased/root": [ + "../../feature-libs/product-configurator/rulebased/root/public_api" + ], + "@spartacus/product-configurator/textfield": [ + "../../feature-libs/product-configurator/textfield/public_api" + ], + "@spartacus/product-configurator/textfield/root": [ + "../../feature-libs/product-configurator/textfield/root/public_api" + ], + "@spartacus/product-multi-dimensional/list": [ + "../../feature-libs/product-multi-dimensional/list/public_api" + ], + "@spartacus/product-multi-dimensional/list/root": [ + "../../feature-libs/product-multi-dimensional/list/root/public_api" + ], + "@spartacus/product-multi-dimensional": [ + "../../feature-libs/product-multi-dimensional/public_api" + ], + "@spartacus/product-multi-dimensional/selector/assets": [ + "../../feature-libs/product-multi-dimensional/selector/assets/public_api" + ], + "@spartacus/product-multi-dimensional/selector/components": [ + "../../feature-libs/product-multi-dimensional/selector/components/public_api" + ], + "@spartacus/product-multi-dimensional/selector/core": [ + "../../feature-libs/product-multi-dimensional/selector/core/public_api" + ], + "@spartacus/product-multi-dimensional/selector": [ + "../../feature-libs/product-multi-dimensional/selector/public_api" + ], + "@spartacus/product-multi-dimensional/selector/occ": [ + "../../feature-libs/product-multi-dimensional/selector/occ/public_api" + ], + "@spartacus/product-multi-dimensional/selector/root": [ + "../../feature-libs/product-multi-dimensional/selector/root/public_api" + ], + "@spartacus/product/bulk-pricing/assets": [ + "../../feature-libs/product/bulk-pricing/assets/public_api" + ], + "@spartacus/product/bulk-pricing/components": [ + "../../feature-libs/product/bulk-pricing/components/public_api" + ], + "@spartacus/product/bulk-pricing/core": [ + "../../feature-libs/product/bulk-pricing/core/public_api" + ], + "@spartacus/product/bulk-pricing": [ + "../../feature-libs/product/bulk-pricing/public_api" + ], + "@spartacus/product/bulk-pricing/occ": [ + "../../feature-libs/product/bulk-pricing/occ/public_api" + ], + "@spartacus/product/bulk-pricing/root": [ + "../../feature-libs/product/bulk-pricing/root/public_api" + ], + "@spartacus/product/future-stock/assets": [ + "../../feature-libs/product/future-stock/assets/public_api" + ], + "@spartacus/product/future-stock/components": [ + "../../feature-libs/product/future-stock/components/public_api" + ], + "@spartacus/product/future-stock/core": [ + "../../feature-libs/product/future-stock/core/public_api" + ], + "@spartacus/product/future-stock": [ + "../../feature-libs/product/future-stock/public_api" + ], + "@spartacus/product/future-stock/occ": [ + "../../feature-libs/product/future-stock/occ/public_api" + ], + "@spartacus/product/future-stock/root": [ + "../../feature-libs/product/future-stock/root/public_api" + ], + "@spartacus/product/image-zoom/assets": [ + "../../feature-libs/product/image-zoom/assets/public_api" + ], + "@spartacus/product/image-zoom/components": [ + "../../feature-libs/product/image-zoom/components/public_api" + ], + "@spartacus/product/image-zoom": [ + "../../feature-libs/product/image-zoom/public_api" + ], + "@spartacus/product/image-zoom/root": [ + "../../feature-libs/product/image-zoom/root/public_api" + ], + "@spartacus/product": ["../../feature-libs/product/public_api"], + "@spartacus/product/variants/assets": [ + "../../feature-libs/product/variants/assets/public_api" + ], + "@spartacus/product/variants/components": [ + "../../feature-libs/product/variants/components/public_api" + ], + "@spartacus/product/variants": [ + "../../feature-libs/product/variants/public_api" + ], + "@spartacus/product/variants/occ": [ + "../../feature-libs/product/variants/occ/public_api" + ], + "@spartacus/product/variants/root": [ + "../../feature-libs/product/variants/root/public_api" + ], + "@spartacus/qualtrics/components": [ + "../../feature-libs/qualtrics/components/public_api" + ], + "@spartacus/qualtrics": ["../../feature-libs/qualtrics/public_api"], + "@spartacus/qualtrics/root": [ + "../../feature-libs/qualtrics/root/public_api" + ], + "@spartacus/quote/assets": ["../../feature-libs/quote/assets/public_api"], + "@spartacus/quote/components/cart-guard": [ + "../../feature-libs/quote/components/cart-guard/public_api" + ], + "@spartacus/quote/components": [ + "../../feature-libs/quote/components/public_api" + ], + "@spartacus/quote/components/request-button": [ + "../../feature-libs/quote/components/request-button/public_api" + ], + "@spartacus/quote/core": ["../../feature-libs/quote/core/public_api"], + "@spartacus/quote": ["../../feature-libs/quote/public_api"], + "@spartacus/quote/occ": ["../../feature-libs/quote/occ/public_api"], + "@spartacus/quote/root": ["../../feature-libs/quote/root/public_api"], + "@spartacus/requested-delivery-date/assets": [ + "../../feature-libs/requested-delivery-date/assets/public_api" + ], + "@spartacus/requested-delivery-date/core": [ + "../../feature-libs/requested-delivery-date/core/public_api" + ], + "@spartacus/requested-delivery-date": [ + "../../feature-libs/requested-delivery-date/public_api" + ], + "@spartacus/requested-delivery-date/occ": [ + "../../feature-libs/requested-delivery-date/occ/public_api" + ], + "@spartacus/requested-delivery-date/root": [ + "../../feature-libs/requested-delivery-date/root/public_api" + ], + "@spartacus/smartedit/core": [ + "../../feature-libs/smartedit/core/public_api" + ], + "@spartacus/smartedit": ["../../feature-libs/smartedit/public_api"], + "@spartacus/smartedit/root": [ + "../../feature-libs/smartedit/root/public_api" + ], + "@spartacus/storefinder/assets": [ + "../../feature-libs/storefinder/assets/public_api" + ], + "@spartacus/storefinder/components": [ + "../../feature-libs/storefinder/components/public_api" + ], + "@spartacus/storefinder/core": [ + "../../feature-libs/storefinder/core/public_api" + ], + "@spartacus/storefinder": ["../../feature-libs/storefinder/public_api"], + "@spartacus/storefinder/occ": [ + "../../feature-libs/storefinder/occ/public_api" + ], + "@spartacus/storefinder/root": [ + "../../feature-libs/storefinder/root/public_api" + ], + "@spartacus/tracking": ["../../feature-libs/tracking/public_api"], + "@spartacus/tracking/personalization/core": [ + "../../feature-libs/tracking/personalization/core/public_api" + ], + "@spartacus/tracking/personalization": [ + "../../feature-libs/tracking/personalization/public_api" + ], + "@spartacus/tracking/personalization/root": [ + "../../feature-libs/tracking/personalization/root/public_api" + ], + "@spartacus/tracking/tms/aep": [ + "../../feature-libs/tracking/tms/aep/public_api" + ], + "@spartacus/tracking/tms/core": [ + "../../feature-libs/tracking/tms/core/public_api" + ], + "@spartacus/tracking/tms/gtm": [ + "../../feature-libs/tracking/tms/gtm/public_api" + ], + "@spartacus/tracking/tms": ["../../feature-libs/tracking/tms/public_api"], + "@spartacus/user/account/assets": [ + "../../feature-libs/user/account/assets/public_api" + ], + "@spartacus/user/account/components": [ + "../../feature-libs/user/account/components/public_api" + ], + "@spartacus/user/account/core": [ + "../../feature-libs/user/account/core/public_api" + ], + "@spartacus/user/account": ["../../feature-libs/user/account/public_api"], + "@spartacus/user/account/occ": [ + "../../feature-libs/user/account/occ/public_api" + ], + "@spartacus/user/account/root": [ + "../../feature-libs/user/account/root/public_api" + ], + "@spartacus/user": ["../../feature-libs/user/public_api"], + "@spartacus/user/profile/assets": [ + "../../feature-libs/user/profile/assets/public_api" + ], + "@spartacus/user/profile/components": [ + "../../feature-libs/user/profile/components/public_api" + ], + "@spartacus/user/profile/core": [ + "../../feature-libs/user/profile/core/public_api" + ], + "@spartacus/user/profile": ["../../feature-libs/user/profile/public_api"], + "@spartacus/user/profile/occ": [ + "../../feature-libs/user/profile/occ/public_api" + ], + "@spartacus/user/profile/root": [ + "../../feature-libs/user/profile/root/public_api" + ], + "@spartacus/cdc/assets": ["../../integration-libs/cdc/assets/public_api"], + "@spartacus/cdc/components": [ + "../../integration-libs/cdc/components/public_api" + ], + "@spartacus/cdc/core": ["../../integration-libs/cdc/core/public_api"], + "@spartacus/cdc": ["../../integration-libs/cdc/public_api"], + "@spartacus/cdc/organization/administration": [ + "../../integration-libs/cdc/organization/administration/public_api" + ], + "@spartacus/cdc/organization/user-registration": [ + "../../integration-libs/cdc/organization/user-registration/public_api" + ], + "@spartacus/cdc/root": ["../../integration-libs/cdc/root/public_api"], + "@spartacus/cdc/user-account": [ + "../../integration-libs/cdc/user-account/public_api" + ], + "@spartacus/cdc/user-profile": [ + "../../integration-libs/cdc/user-profile/public_api" + ], + "@spartacus/cdp/customer-ticketing": [ + "../../integration-libs/cdp/customer-ticketing/public_api" + ], + "@spartacus/cdp": ["../../integration-libs/cdp/public_api"], + "@spartacus/cds/assets": ["../../integration-libs/cds/assets/public_api"], + "@spartacus/cds": ["../../integration-libs/cds/public_api"], + "@spartacus/cpq-quote/assets": [ + "../../integration-libs/cpq-quote/assets/public_api" + ], + "@spartacus/cpq-quote/cpq-quote-discount": [ + "../../integration-libs/cpq-quote/cpq-quote-discount/public_api" + ], + "@spartacus/cpq-quote": ["../../integration-libs/cpq-quote/public_api"], + "@spartacus/cpq-quote/root": [ + "../../integration-libs/cpq-quote/root/public_api" + ], + "@spartacus/digital-payments/assets": [ + "../../integration-libs/digital-payments/assets/public_api" + ], + "@spartacus/digital-payments": [ + "../../integration-libs/digital-payments/public_api" + ], + "@spartacus/epd-visualization/assets": [ + "../../integration-libs/epd-visualization/assets/public_api" + ], + "@spartacus/epd-visualization/components": [ + "../../integration-libs/epd-visualization/components/public_api" + ], + "@spartacus/epd-visualization/core": [ + "../../integration-libs/epd-visualization/core/public_api" + ], + "@spartacus/epd-visualization/epd-visualization-api": [ + "../../integration-libs/epd-visualization/epd-visualization-api/public_api" + ], + "@spartacus/epd-visualization": [ + "../../integration-libs/epd-visualization/public_api" + ], + "@spartacus/epd-visualization/root": [ + "../../integration-libs/epd-visualization/root/public_api" + ], + "@spartacus/omf": ["../../integration-libs/omf/public_api"], + "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], + "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], + "@spartacus/opps": ["../../integration-libs/opps/public_api"], + "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], + "@spartacus/s4-service/assets": [ + "../../integration-libs/s4-service/assets/public_api" + ], + "@spartacus/s4-service/checkout": [ + "../../integration-libs/s4-service/checkout/public_api" + ], + "@spartacus/s4-service": ["../../integration-libs/s4-service/public_api"], + "@spartacus/s4-service/order": [ + "../../integration-libs/s4-service/order/public_api" + ], + "@spartacus/s4-service/root": [ + "../../integration-libs/s4-service/root/public_api" + ], + "@spartacus/s4om/assets": [ + "../../integration-libs/s4om/assets/public_api" + ], + "@spartacus/s4om": ["../../integration-libs/s4om/public_api"], + "@spartacus/s4om/root": ["../../integration-libs/s4om/root/public_api"], + "@spartacus/segment-refs": [ + "../../integration-libs/segment-refs/public_api" + ], + "@spartacus/segment-refs/root": [ + "../../integration-libs/segment-refs/root/public_api" + ], + "@spartacus/assets": ["../../projects/assets/src/public_api"], + "@spartacus/core": ["../../projects/core/public_api"], + "@spartacus/storefront": ["../../projects/storefrontlib/public_api"] + } + }, + "include": ["schematics/**/*.ts"], + "exclude": ["schematics/*/files/**/*", "schematics/**/*_spec.ts"] +} diff --git a/integration-libs/opf/tsconfig.spec.json b/integration-libs/opf/tsconfig.spec.json new file mode 100644 index 00000000000..dd35e762eb9 --- /dev/null +++ b/integration-libs/opf/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "strict": false, + "target": "es2020", + "module": "es2020", + "types": ["jasmine", "node"], + "skipLibCheck": true, + "resolveJsonModule": true, + "esModuleInterop": true + }, + "files": ["test.ts"], + "include": ["**/*.spec.ts", "**/*.d.ts"] +} diff --git a/integration-libs/opps/tsconfig.schematics.json b/integration-libs/opps/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/integration-libs/opps/tsconfig.schematics.json +++ b/integration-libs/opps/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.spec.ts b/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.spec.ts index 294b5bdec17..de3dbb3f0cd 100644 --- a/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.spec.ts +++ b/integration-libs/s4-service/checkout/components/checkout-delivery-mode/service-checkout-delivery-mode.component.spec.ts @@ -1,17 +1,20 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { GlobalMessageService, I18nTestingModule } from '@spartacus/core'; -import { ServiceCheckoutDeliveryModeComponent } from './service-checkout-delivery-mode.component'; -import { ActivatedRoute } from '@angular/router'; -import { CheckoutStepService } from '@spartacus/checkout/base/components'; -import createSpy = jasmine.createSpy; import { ReactiveFormsModule } from '@angular/forms'; -import { OutletModule } from '@spartacus/storefront'; +import { ActivatedRoute } from '@angular/router'; +import { ActiveCartFacade, Cart, OrderEntry } from '@spartacus/cart/base/root'; +import { + CheckoutFlowOrchestratorService, + CheckoutStepService, +} from '@spartacus/checkout/base/components'; +import { GlobalMessageService, I18nTestingModule } from '@spartacus/core'; import { CheckoutServiceDetailsFacade, S4ServiceDeliveryModeConfig, } from '@spartacus/s4-service/root'; +import { OutletModule } from '@spartacus/storefront'; import { BehaviorSubject, of } from 'rxjs'; -import { ActiveCartFacade, Cart, OrderEntry } from '@spartacus/cart/base/root'; +import { ServiceCheckoutDeliveryModeComponent } from './service-checkout-delivery-mode.component'; +import createSpy = jasmine.createSpy; const mockCart: Cart = { code: '123456789', description: 'testCartDescription', @@ -51,6 +54,13 @@ class MockCartService implements Partial { getPickupEntries = createSpy().and.returnValue(of([])); getActive = () => cart$.asObservable(); } + +class MockCheckoutFlowOrchestratorService + implements Partial +{ + getCheckoutFlow = createSpy(); +} + describe('ServiceCheckoutDeliveryModeComponent', () => { let component: ServiceCheckoutDeliveryModeComponent; let fixture: ComponentFixture; @@ -69,6 +79,10 @@ describe('ServiceCheckoutDeliveryModeComponent', () => { { provide: ActiveCartFacade, useClass: MockCartService }, { provide: CheckoutStepService, useClass: MockCheckoutStepService }, { provide: GlobalMessageService, useClass: MockGlobalMessageService }, + { + provide: CheckoutFlowOrchestratorService, + useClass: MockCheckoutFlowOrchestratorService, + }, { provide: S4ServiceDeliveryModeConfig, useValue: mockServiceDeliveryModeConfig, diff --git a/integration-libs/s4-service/tsconfig.schematics.json b/integration-libs/s4-service/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/integration-libs/s4-service/tsconfig.schematics.json +++ b/integration-libs/s4-service/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/integration-libs/s4om/tsconfig.schematics.json b/integration-libs/s4om/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/integration-libs/s4om/tsconfig.schematics.json +++ b/integration-libs/s4om/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/integration-libs/segment-refs/tsconfig.schematics.json b/integration-libs/segment-refs/tsconfig.schematics.json index 9f1f9485ea6..cd458bc746b 100644 --- a/integration-libs/segment-refs/tsconfig.schematics.json +++ b/integration-libs/segment-refs/tsconfig.schematics.json @@ -612,6 +612,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/package-lock.json b/package-lock.json index c421052862e..3c75cce5fa3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,9 @@ "@ngrx/effects": "^17.0.1", "@ngrx/router-store": "^17.0.1", "@ngrx/store": "^17.0.1", + "@types/applepayjs": "^14.0.3", "@types/google.maps": "^3.54.0", + "@types/googlepay": "^0.7.4", "angular-oauth2-oidc": "^17.0.1", "bootstrap": "^4.6.2", "comment-json": "^4.2.3", @@ -8301,6 +8303,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@types/applepayjs": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/@types/applepayjs/-/applepayjs-14.0.8.tgz", + "integrity": "sha512-Yzf5OSitdS+/G8cjaAkPJ0+pBOEf9Vik1XUCdw6ul7Qh6Xb18wTlG/sWA5jKIme3x4fbyTGlSd4mfkvdtP9mRw==" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -8470,6 +8477,11 @@ "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.54.10.tgz", "integrity": "sha512-N6gwM01mKhooXaw+IKbUH7wJcIJCn8U60VoaVvom5EiQjmfgevhQ+0+/r17beXW5j8ad2x+WPr0iyOUodCw4/w==" }, + "node_modules/@types/googlepay": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@types/googlepay/-/googlepay-0.7.6.tgz", + "integrity": "sha512-5003wG+qvf4Ktf1hC9IJuRakNzQov00+Xf09pAWGJLpdOjUrq0SSLCpXX7pwSeTG9r5hrdzq1iFyZcW7WVyr4g==" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -29629,6 +29641,11 @@ } } }, + "@types/applepayjs": { + "version": "14.0.8", + "resolved": "https://registry.npmjs.org/@types/applepayjs/-/applepayjs-14.0.8.tgz", + "integrity": "sha512-Yzf5OSitdS+/G8cjaAkPJ0+pBOEf9Vik1XUCdw6ul7Qh6Xb18wTlG/sWA5jKIme3x4fbyTGlSd4mfkvdtP9mRw==" + }, "@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -29798,6 +29815,11 @@ "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.54.10.tgz", "integrity": "sha512-N6gwM01mKhooXaw+IKbUH7wJcIJCn8U60VoaVvom5EiQjmfgevhQ+0+/r17beXW5j8ad2x+WPr0iyOUodCw4/w==" }, + "@types/googlepay": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@types/googlepay/-/googlepay-0.7.6.tgz", + "integrity": "sha512-5003wG+qvf4Ktf1hC9IJuRakNzQov00+Xf09pAWGJLpdOjUrq0SSLCpXX7pwSeTG9r5hrdzq1iFyZcW7WVyr4g==" + }, "@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", diff --git a/package.json b/package.json index dedaa86c47d..ad9fe23f044 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:epd-visualization": "npm --prefix integration-libs/epd-visualization run build:schematics && nx build epd-visualization --configuration production", "build:estimated-delivery-date": "npm --prefix feature-libs/estimated-delivery-date run build:schematics && nx build estimated-delivery-date --configuration production", "build:order": "npm --prefix feature-libs/order run build:schematics && nx build order --configuration production", - "build:libs": "nx build core --configuration production && nx build storefrontlib --configuration production && concurrently --kill-others-on-fail npm:build:schematics npm:build:user && npm run build:cart && npm run build:pdf-invoices && npm run build:order && npm run build:storefinder && concurrently --kill-others-on-fail npm:build:checkout npm:build:asm npm:build:tracking npm:build:customer-ticketing && concurrently --kill-others-on-fail npm:build:organization npm:build:product npm:build:product-configurator npm:build:product-multi-dimensional && concurrently --kill-others-on-fail npm:build:requested-delivery-date && concurrently --kill-others-on-fail npm:build:estimated-delivery-date && concurrently --kill-others-on-fail npm:build:smartedit npm:build:qualtrics npm:build:assets npm:build:cds npm:build:cdc npm:build:cdp npm:build:digital-payments npm:build:epd-visualization npm:build:s4om npm:build:omf npm:build:cpq-quote npm:build:segment-refs npm:build:opps npm:build:pickup-in-store npm:build:quote && npm run build:setup && npm run build:s4-service", + "build:libs": "nx build core --configuration production && nx build storefrontlib --configuration production && concurrently --kill-others-on-fail npm:build:schematics npm:build:user && npm run build:cart && npm run build:pdf-invoices && npm run build:order && npm run build:storefinder && concurrently --kill-others-on-fail npm:build:checkout npm:build:asm npm:build:tracking npm:build:customer-ticketing && concurrently --kill-others-on-fail npm:build:organization npm:build:product npm:build:product-configurator npm:build:product-multi-dimensional && concurrently --kill-others-on-fail npm:build:requested-delivery-date && concurrently --kill-others-on-fail npm:build:estimated-delivery-date && concurrently --kill-others-on-fail npm:build:smartedit npm:build:qualtrics npm:build:assets npm:build:cds npm:build:cdc npm:build:cdp npm:build:digital-payments npm:build:epd-visualization npm:build:s4om npm:build:omf npm:build:cpq-quote npm:build:segment-refs npm:build:opf npm:build:opps npm:build:pickup-in-store npm:build:quote && npm run build:setup && npm run build:s4-service", "build:organization": "npm --prefix feature-libs/organization run build:schematics && nx build organization --configuration production", "build:pdf-invoices": "npm --prefix feature-libs/pdf-invoices run build:schematics && nx build pdf-invoices --configuration production", "build:pickup-in-store": "npm --prefix feature-libs/pickup-in-store run build:schematics && nx build pickup-in-store --configuration production", @@ -36,12 +36,14 @@ "build:omf": "npm --prefix integration-libs/omf run build:schematics && nx build omf --configuration production", "build:cpq-quote": "npm --prefix integration-libs/cpq-quote run build:schematics && npx nx build cpq-quote --configuration production", "build:segment-refs": "npm --prefix integration-libs/segment-refs run build:schematics && nx build segment-refs --configuration production", + "build:opf": "npm --prefix integration-libs/opf run build:schematics && nx build opf --configuration production", "build:opps": "npm --prefix integration-libs/opps run build:schematics && nx build opps --configuration production", "build:qualtrics": "npm --prefix feature-libs/qualtrics run build:schematics && nx build qualtrics --configuration production", "build:requested-delivery-date": "npm --prefix feature-libs/requested-delivery-date run build:schematics && nx build requested-delivery-date --configuration production", "build:schematics": "npm --prefix projects/schematics run build", "build:setup": "nx build setup --configuration production", "build:ssr": "env-cmd --no-override -e dev,b2c,$SPA_ENV nx run storefrontapp:server:production", + "build:ssr:opf": "env-cmd --no-override -e opf,b2c,$SPA_ENV nx run storefrontapp:server:production", "build:ssr:ci": "env-cmd -e ci,b2c,$SPA_ENV nx run storefrontapp:server:production", "build:ssr:local-http-backend": "env-cmd -e local-http,b2c,$SPA_ENV nx run storefrontapp:server:production", "build:storefinder": "npm --prefix feature-libs/storefinder run build:schematics && nx build storefinder --configuration production", @@ -97,13 +99,17 @@ "start:b2b": "env-cmd --no-override -e dev,b2b,$SPA_ENV nx serve storefrontapp --configuration=development", "start:ci": "env-cmd --no-override -e ci,b2c,$SPA_ENV nx serve storefrontapp --configuration=development", "start:ci:b2b": "env-cmd --no-override -e ci,b2b,$SPA_ENV nx serve storefrontapp --configuration=development", + "start:opf": "env-cmd --no-override -e opf,$SPA_ENV nx serve storefrontapp --configuration=development --ssl", + "start:opf:vm": "env-cmd --no-override -e opf,$SPA_ENV nx serve storefrontapp --configuration=development", "start:prod": "env-cmd --no-override -e dev,b2c,$SPA_ENV nx serve storefrontapp --configuration=production", "start:pwa": "cd ./dist/storefrontapp/ && http-server --silent --proxy http://localhost:4200? -p 4200", "test": "nx test", "test:all-schematics": "set -e; npm --prefix ./projects/schematics test -- -u; for dir in feature-libs/* integration-libs/*; do (cd $dir && npm run test:schematics -- -u); done", - "test:libs": "concurrently \"nx test core --code-coverage\" \"nx test storefrontlib --code-coverage\" \"nx test cart --code-coverage\" \"nx test organization --code-coverage\" \"nx test storefinder --code-coverage\" \"nx test smartedit --code-coverage\" \"nx test asm --code-coverage\" \"nx test qualtrics --code-coverage\" \"nx test product --code-coverage\" \"nx test product-configurator --code-coverage\" \"nx test product-multi-dimensional --code-coverage\" \"nx test customer-ticketing --code-coverage\" \"nx test cdc --code-coverage\" \"nx test s4-service --code-coverage\" \"nx test cdp --code-coverage\" \"nx test opps --code-coverage\" \"nx test setup --code-coverage\" \"nx test checkout --code-coverage\" \"nx test order --code-coverage\" \"nx test digital-payments --code-coverage\" \"nx test epd-visualization --code-coverage\" \"nx test pickup-in-store --code-coverage\" \"nx test s4om --code-coverage\" \"nx test cpq-quote --code-coverage\" \"nx test omf --code-coverage\" \"nx test requested-delivery-date --code-coverage\" \"nx test estimated-delivery-date --code-coverage\" \"nx test pdf-invoices --code-coverage\" \"nx test quote --code-coverage\"", + "test:libs": "concurrently \"nx test core --code-coverage\" \"nx test storefrontlib --code-coverage\" \"nx test cart --code-coverage\" \"nx test organization --code-coverage\" \"nx test storefinder --code-coverage\" \"nx test smartedit --code-coverage\" \"nx test asm --code-coverage\" \"nx test qualtrics --code-coverage\" \"nx test product --code-coverage\" \"nx test product-configurator --code-coverage\" \"nx test product-multi-dimensional --code-coverage\" \"nx test customer-ticketing --code-coverage\" \"nx test cdc --code-coverage\" \"nx test s4-service --code-coverage\" \"nx test cdp --code-coverage\" \"nx test opps --code-coverage\" \"nx test setup --code-coverage\" \"nx test checkout --code-coverage\" \"nx test order --code-coverage\" \"nx test digital-payments --code-coverage\" \"nx test epd-visualization --code-coverage\" \"nx test pickup-in-store --code-coverage\" \"nx test s4om --code-coverage\" \"nx test cpq-quote --code-coverage\" \"nx test omf --code-coverage\" \"nx test requested-delivery-date --code-coverage\" \"nx test estimated-delivery-date --code-coverage\" \"nx test pdf-invoices --code-coverage\" \"nx test quote --code-coverage\" \"nx test opf --code-coverage\"", "test:storefront:lib": "nx test storefrontlib --source-map --code-coverage", + "test:opf:lib": "nx test opf --source-map --code-coverage", "dev:ssr": "env-cmd --no-override -e dev,b2c,$SPA_ENV cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 nx run storefrontapp:serve-ssr", + "dev:ssr:opf": "env-cmd --no-override -e opf,b2c,$SPA_ENV cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 nx run storefrontapp:serve-ssr", "serve:ssr": "node dist/storefrontapp-server/main.js", "serve:ssr:ci": "NODE_TLS_REJECT_UNAUTHORIZED=0 SSR_TIMEOUT=0 node dist/storefrontapp-server/main.js", "serve:ssr:dev": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 node dist/storefrontapp-server/main.js", @@ -138,7 +144,9 @@ "@ngrx/effects": "^17.0.1", "@ngrx/router-store": "^17.0.1", "@ngrx/store": "^17.0.1", + "@types/applepayjs": "^14.0.3", "@types/google.maps": "^3.54.0", + "@types/googlepay": "^0.7.4", "angular-oauth2-oidc": "^17.0.1", "bootstrap": "^4.6.2", "comment-json": "^4.2.3", diff --git a/projects/core/src/model/misc.model.ts b/projects/core/src/model/misc.model.ts index 87101809e03..528f02ec12a 100644 --- a/projects/core/src/model/misc.model.ts +++ b/projects/core/src/model/misc.model.ts @@ -110,6 +110,7 @@ export interface BaseStore { defaultCurrency?: Currency; languages?: Language[]; defaultLanguage?: Language; + paymentProvider?: string; } export interface BaseSite { diff --git a/projects/core/src/util/occ-http-error-handlers.spec.ts b/projects/core/src/util/occ-http-error-handlers.spec.ts index e2b9a167bda..1a2f877b64a 100644 --- a/projects/core/src/util/occ-http-error-handlers.spec.ts +++ b/projects/core/src/util/occ-http-error-handlers.spec.ts @@ -1,6 +1,10 @@ import { HttpErrorModel } from '@spartacus/core'; import { normalizeHttpError } from './normalize-http-error'; -import { isJaloError } from './occ-http-error-handlers'; +import { + isAuthorizationError, + isJaloError, + isServerError, +} from './occ-http-error-handlers'; class MockLoggerService { log(): void {} @@ -29,4 +33,49 @@ describe('occ-http-error-handlers', () => { expect(result).toBeFalsy(); }); }); + describe('when the error is server error', () => { + it('should return true for codes between 500 and 511', () => { + for (let errorCode = 500; errorCode <= 511; errorCode++) { + const error: Partial = { + status: errorCode, + }; + const result = isServerError(error); + expect(result).toBeTruthy(); + } + }); + }); + describe('when the error is not a server error', () => { + it('should return false for code above 511', () => { + const error: Partial = { + status: 512, + }; + const result = isServerError(error); + expect(result).toBeFalsy(); + }); + it('should return false for code below 500', () => { + const error: Partial = { + status: 400, + }; + const result = isServerError(error); + expect(result).toBeFalsy(); + }); + }); + describe('when the error is authorization error', () => { + it('should return true', () => { + const error: Partial = { + status: 401, + }; + const result = isAuthorizationError(error); + expect(result).toBeTruthy(); + }); + }); + describe('when the error is not an authorization error', () => { + it('should return false', () => { + const error: Partial = { + status: 400, + }; + const result = isAuthorizationError(error); + expect(result).toBeFalsy(); + }); + }); }); diff --git a/projects/core/src/util/occ-http-error-handlers.ts b/projects/core/src/util/occ-http-error-handlers.ts index 8398befcd40..c2bc678721c 100644 --- a/projects/core/src/util/occ-http-error-handlers.ts +++ b/projects/core/src/util/occ-http-error-handlers.ts @@ -12,3 +12,20 @@ import { HttpErrorModel } from '../model/misc.model'; export function isJaloError(err: HttpErrorModel): boolean { return err.details?.[0]?.type === 'JaloObjectNoLongerValidError'; } + +/** + * A helper function for detecting server (500 and above) code errors + */ +export function isServerError(err: HttpErrorModel): boolean { + return !!err?.status && err.status >= 500 && err.status <= 511; +} + +/** + * A helper function for detecting 401 code errors + */ +export function isAuthorizationError(err: HttpErrorModel): boolean { + return !!err?.status && err.status === 401; +} + +export const DEFAULT_SERVER_ERROR_RETRIES_COUNT = 2; +export const DEFAULT_AUTHORIZATION_ERROR_RETRIES_COUNT = 2; diff --git a/projects/schematics/package.json b/projects/schematics/package.json index 894b37ba260..23ead448cf3 100644 --- a/projects/schematics/package.json +++ b/projects/schematics/package.json @@ -70,6 +70,7 @@ "@spartacus/epd-visualization", "@spartacus/s4om", "@spartacus/customer-ticketing", + "@spartacus/opf", "@spartacus/estimated-delivery-date" ] }, diff --git a/projects/schematics/src/add-spartacus/schema.json b/projects/schematics/src/add-spartacus/schema.json index 269b57ae610..725d4ed2082 100644 --- a/projects/schematics/src/add-spartacus/schema.json +++ b/projects/schematics/src/add-spartacus/schema.json @@ -34,6 +34,7 @@ "OPPS", "Digital-Payments", "EPD-Visualization", + "OPF-Checkout", "Customer-Ticketing", "Administration", "Order-Approval", @@ -160,6 +161,10 @@ "value": "EPD-Visualization", "label": "EPD Visualization Integration" }, + { + "value": "OPF-Checkout", + "label": "Open Payment Framework Checkout Integration" + }, { "value": "Administration", "label": "Organization - Adminstration (b2b feature)" diff --git a/projects/schematics/src/dependencies.json b/projects/schematics/src/dependencies.json index 764c6f48b33..66b32f2dee6 100644 --- a/projects/schematics/src/dependencies.json +++ b/projects/schematics/src/dependencies.json @@ -419,6 +419,26 @@ "@spartacus/schematics": "2211.31.1", "rxjs": "^7.8.0" }, + "@spartacus/opf": { + "@angular-devkit/schematics": "^17.0.5", + "@angular/common": "^17.0.5", + "@angular/core": "^17.0.5", + "@angular/forms": "^17.0.5", + "@angular/platform-browser": "^17.0.5", + "@angular/router": "^17.0.5", + "@ng-select/ng-select": "^12.0.4", + "@ngrx/store": "^17.0.1", + "@spartacus/cart": "2211.29.0", + "@spartacus/checkout": "2211.29.0", + "@spartacus/core": "2211.29.0", + "@spartacus/order": "2211.29.0", + "@spartacus/schematics": "2211.29.0", + "@spartacus/storefront": "2211.29.0", + "@spartacus/styles": "2211.29.0", + "@spartacus/user": "2211.29.0", + "bootstrap": "^4.6.2", + "rxjs": "^7.8.0" + }, "@spartacus/opps": { "@angular-devkit/schematics": "^17.0.5", "@angular/common": "^17.0.5", @@ -483,7 +503,9 @@ "@ngrx/effects": "^17.0.1", "@ngrx/router-store": "^17.0.1", "@ngrx/store": "^17.0.1", + "@types/applepayjs": "^14.0.3", "@types/google.maps": "^3.54.0", + "@types/googlepay": "^0.7.4", "angular-oauth2-oidc": "^17.0.1", "bootstrap": "^4.6.2", "comment-json": "^4.2.3", diff --git a/projects/schematics/src/shared/lib-configs/integration-libs/index.ts b/projects/schematics/src/shared/lib-configs/integration-libs/index.ts index 210ad10a547..73c7db387e0 100644 --- a/projects/schematics/src/shared/lib-configs/integration-libs/index.ts +++ b/projects/schematics/src/shared/lib-configs/integration-libs/index.ts @@ -10,6 +10,7 @@ export * from './cds-schematics-config'; export * from './digital-payments-schematics-config'; export * from './epd-schematics-config'; export * from './s4om-schematics-config'; +export * from './opf-schematics-config'; export * from './segment-refs-schematics-config'; export * from './opps-schematics-config'; export * from './cpq-quote-schematics-config'; diff --git a/projects/schematics/src/shared/lib-configs/integration-libs/opf-schematics-config.ts b/projects/schematics/src/shared/lib-configs/integration-libs/opf-schematics-config.ts new file mode 100644 index 00000000000..d755feb2729 --- /dev/null +++ b/projects/schematics/src/shared/lib-configs/integration-libs/opf-schematics-config.ts @@ -0,0 +1,272 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + OPF_BASE_FEATURE_NAME, + OPF_CHECKOUT_FEATURE_NAME, + OPF_CTA_FEATURE_NAME, + OPF_GLOBAL_FUNCTIONS_FEATURE_NAME, + OPF_PAYMENT_FEATURE_NAME, + OPF_QUICK_BUY_FEATURE_NAME, + SPARTACUS_OPF, + SPARTACUS_OPF_BASE, + SPARTACUS_OPF_BASE_ROOT, + SPARTACUS_OPF_CHECKOUT, + SPARTACUS_OPF_CHECKOUT_ASSETS, + SPARTACUS_OPF_CHECKOUT_ROOT, + SPARTACUS_OPF_CTA, + SPARTACUS_OPF_CTA_ROOT, + SPARTACUS_OPF_GLOBAL_FUNCTIONS, + SPARTACUS_OPF_GLOBAL_FUNCTIONS_ROOT, + SPARTACUS_OPF_PAYMENT, + SPARTACUS_OPF_PAYMENT_ASSETS, + SPARTACUS_OPF_PAYMENT_ROOT, + SPARTACUS_OPF_QUICK_BUY, + SPARTACUS_OPF_QUICK_BUY_ROOT, +} from '../../libs-constants'; +import { AdditionalFeatureConfiguration } from '../../utils/feature-utils'; +import { LibraryOptions, SchematicConfig } from '../../utils/lib-utils'; + +export interface SpartacusOpfOptions extends LibraryOptions { + opfBaseUrl?: string; + commerceCloudPublicKey?: string; +} + +export const OPF_FOLDER_NAME = 'opf'; +export const OPF_MODULE_NAME = 'Opf'; +export const OPF_SCSS_FILE_NAME = 'opf.scss'; +export const OPF_CONFIG = 'OpfConfig'; + +export const OPF_CHECKOUT_FEATURE_NAME_CONSTANT = 'OPF_CHECKOUT_FEATURE'; +export const OPF_CHECKOUT_MODULE = 'OpfCheckoutModule'; +export const OPF_CHECKOUT_ROOT_MODULE = 'OpfCheckoutRootModule'; +export const OPF_CHECKOUT_TRANSLATIONS = 'opfCheckoutTranslations'; +export const OPF_CHECKOUT_TRANSLATION_CHUNKS_CONFIG = + 'opfCheckoutTranslationChunksConfig'; + +export const OPF_BASE_FEATURE_NAME_CONSTANT = 'OPF_BASE_FEATURE'; +export const OPF_BASE_MODULE = 'OpfBaseModule'; +export const OPF_BASE_ROOT_MODULE = 'OpfBaseRootModule'; + +export const OPF_GLOBAL_FUNCTIONS_FEATURE_NAME_CONSTANT = + 'OPF_GLOBAL_FUNCTIONS_FEATURE'; +export const OPF_GLOBAL_FUNCTIONS_MODULE = 'OpfGlobalFunctionsModule'; +export const OPF_GLOBAL_FUNCTIONS_ROOT_MODULE = 'OpfGlobalFunctionsRootModule'; + +export const OPF_CTA_FEATURE_NAME_CONSTANT = 'OPF_CTA_FEATURE'; +export const OPF_CTA_MODULE = 'OpfCtaModule'; +export const OPF_CTA_ROOT_MODULE = 'OpfCtaRootModule'; + +export const OPF_QUICK_BUY_FEATURE_NAME_CONSTANT = 'OPF_QUICK_BUY_FEATURE'; +export const OPF_QUICK_BUY_MODULE = 'OpfQuickBuyModule'; +export const OPF_QUICK_BUY_ROOT_MODULE = 'OpfQuickBuyRootModule'; + +export const OPF_PAYMENT_FEATURE_NAME_CONSTANT = 'OPF_PAYMENT_FEATURE'; +export const OPF_PAYMENT_MODULE = 'OpfPaymentModule'; +export const OPF_PAYMENT_ROOT_MODULE = 'OpfPaymentRootModule'; +export const OPF_PAYMENT_TRANSLATIONS = 'opfPaymentTranslations'; +export const OPF_PAYMENT_TRANSLATION_CHUNKS_CONFIG = + 'opfPaymentTranslationChunksConfig'; + +export const OPF_BASE_SCHEMATICS_CONFIG: SchematicConfig = { + library: { + featureName: OPF_BASE_FEATURE_NAME, + mainScope: SPARTACUS_OPF, + featureScope: SPARTACUS_OPF_BASE, + }, + folderName: OPF_FOLDER_NAME, + moduleName: OPF_MODULE_NAME, + featureModule: { + name: OPF_BASE_MODULE, + importPath: SPARTACUS_OPF_BASE, + }, + rootModule: { + name: OPF_BASE_ROOT_MODULE, + importPath: SPARTACUS_OPF_BASE_ROOT, + }, + lazyLoadingChunk: { + moduleSpecifier: SPARTACUS_OPF_BASE_ROOT, + namedImports: [OPF_BASE_FEATURE_NAME_CONSTANT], + }, + styles: { + scssFileName: OPF_SCSS_FILE_NAME, + importStyle: SPARTACUS_OPF, + }, + customConfig: buildOpfConfig, +}; + +export const OPF_PAYMENT_SCHEMATICS_CONFIG: SchematicConfig = { + library: { + featureName: OPF_PAYMENT_FEATURE_NAME, + mainScope: SPARTACUS_OPF, + featureScope: SPARTACUS_OPF_PAYMENT, + }, + folderName: OPF_FOLDER_NAME, + moduleName: OPF_MODULE_NAME, + featureModule: { + name: OPF_PAYMENT_MODULE, + importPath: SPARTACUS_OPF_PAYMENT, + }, + rootModule: { + name: OPF_PAYMENT_ROOT_MODULE, + importPath: SPARTACUS_OPF_PAYMENT_ROOT, + }, + lazyLoadingChunk: { + moduleSpecifier: SPARTACUS_OPF_PAYMENT_ROOT, + namedImports: [OPF_PAYMENT_FEATURE_NAME_CONSTANT], + }, + i18n: { + resources: OPF_PAYMENT_TRANSLATIONS, + chunks: OPF_PAYMENT_TRANSLATION_CHUNKS_CONFIG, + importPath: SPARTACUS_OPF_PAYMENT_ASSETS, + }, + styles: { + scssFileName: OPF_SCSS_FILE_NAME, + importStyle: SPARTACUS_OPF, + }, + customConfig: buildOpfConfig, +}; + +export const OPF_CHECKOUT_SCHEMATICS_CONFIG: SchematicConfig = { + library: { + featureName: OPF_CHECKOUT_FEATURE_NAME, + mainScope: SPARTACUS_OPF, + featureScope: SPARTACUS_OPF_CHECKOUT, + }, + folderName: OPF_FOLDER_NAME, + moduleName: OPF_MODULE_NAME, + featureModule: { + name: OPF_CHECKOUT_MODULE, + importPath: SPARTACUS_OPF_CHECKOUT, + }, + rootModule: { + name: OPF_CHECKOUT_ROOT_MODULE, + importPath: SPARTACUS_OPF_CHECKOUT_ROOT, + }, + lazyLoadingChunk: { + moduleSpecifier: SPARTACUS_OPF_CHECKOUT_ROOT, + namedImports: [OPF_CHECKOUT_FEATURE_NAME_CONSTANT], + }, + i18n: { + resources: OPF_CHECKOUT_TRANSLATIONS, + chunks: OPF_CHECKOUT_TRANSLATION_CHUNKS_CONFIG, + importPath: SPARTACUS_OPF_CHECKOUT_ASSETS, + }, + styles: { + scssFileName: OPF_SCSS_FILE_NAME, + importStyle: SPARTACUS_OPF, + }, + customConfig: buildOpfConfig, + dependencyFeatures: [ + OPF_PAYMENT_FEATURE_NAME, + OPF_BASE_FEATURE_NAME, + OPF_CTA_FEATURE_NAME, + OPF_GLOBAL_FUNCTIONS_FEATURE_NAME, + OPF_QUICK_BUY_FEATURE_NAME, + ], +}; + +export const OPF_CTA_SCHEMATICS_CONFIG: SchematicConfig = { + library: { + featureName: OPF_CTA_FEATURE_NAME, + mainScope: SPARTACUS_OPF, + featureScope: SPARTACUS_OPF_CTA, + }, + folderName: OPF_FOLDER_NAME, + moduleName: OPF_MODULE_NAME, + featureModule: { + name: OPF_CTA_MODULE, + importPath: SPARTACUS_OPF_CTA, + }, + rootModule: { + name: OPF_CTA_ROOT_MODULE, + importPath: SPARTACUS_OPF_CTA_ROOT, + }, + lazyLoadingChunk: { + moduleSpecifier: SPARTACUS_OPF_CTA_ROOT, + namedImports: [OPF_CTA_FEATURE_NAME_CONSTANT], + }, + styles: { + scssFileName: OPF_SCSS_FILE_NAME, + importStyle: SPARTACUS_OPF, + }, +}; + +export const OPF_GLOBAL_FUNCTIONS_SCHEMATICS_CONFIG: SchematicConfig = { + library: { + featureName: OPF_GLOBAL_FUNCTIONS_FEATURE_NAME, + mainScope: SPARTACUS_OPF, + featureScope: SPARTACUS_OPF_GLOBAL_FUNCTIONS, + }, + folderName: OPF_FOLDER_NAME, + moduleName: OPF_MODULE_NAME, + featureModule: { + name: OPF_GLOBAL_FUNCTIONS_MODULE, + importPath: SPARTACUS_OPF_GLOBAL_FUNCTIONS, + }, + rootModule: { + name: OPF_GLOBAL_FUNCTIONS_ROOT_MODULE, + importPath: SPARTACUS_OPF_GLOBAL_FUNCTIONS_ROOT, + }, + lazyLoadingChunk: { + moduleSpecifier: SPARTACUS_OPF_GLOBAL_FUNCTIONS_ROOT, + namedImports: [OPF_GLOBAL_FUNCTIONS_FEATURE_NAME_CONSTANT], + }, + styles: { + scssFileName: OPF_SCSS_FILE_NAME, + importStyle: SPARTACUS_OPF, + }, +}; + +export const OPF_QUICK_BUY_SCHEMATICS_CONFIG: SchematicConfig = { + library: { + featureName: OPF_QUICK_BUY_FEATURE_NAME, + mainScope: SPARTACUS_OPF, + featureScope: SPARTACUS_OPF_QUICK_BUY, + }, + folderName: OPF_FOLDER_NAME, + moduleName: OPF_MODULE_NAME, + featureModule: { + name: OPF_QUICK_BUY_MODULE, + importPath: SPARTACUS_OPF_QUICK_BUY, + }, + rootModule: { + name: OPF_QUICK_BUY_ROOT_MODULE, + importPath: SPARTACUS_OPF_QUICK_BUY_ROOT, + }, + lazyLoadingChunk: { + moduleSpecifier: SPARTACUS_OPF_QUICK_BUY_ROOT, + namedImports: [OPF_QUICK_BUY_FEATURE_NAME_CONSTANT], + }, + styles: { + scssFileName: OPF_SCSS_FILE_NAME, + importStyle: SPARTACUS_OPF, + }, +}; + +function buildOpfConfig( + options: SpartacusOpfOptions +): AdditionalFeatureConfiguration { + return { + providers: { + import: [ + { + moduleSpecifier: SPARTACUS_OPF_BASE_ROOT, + namedImports: [OPF_CONFIG], + }, + ], + content: `<${OPF_CONFIG}>{ + opf: { + opfBaseUrl: "${options.opfBaseUrl || 'PLACEHOLDER_OPF_BASE_URL'}", + commerceCloudPublicKey: "${ + options.commerceCloudPublicKey || + 'PLACEHOLDER_COMMERCE_CLOUD_PUBLIC_KEY' + }", + }, + }`, + }, + }; +} diff --git a/projects/schematics/src/shared/libs-constants.ts b/projects/schematics/src/shared/libs-constants.ts index 1f18b1cb26d..4846b111af3 100644 --- a/projects/schematics/src/shared/libs-constants.ts +++ b/projects/schematics/src/shared/libs-constants.ts @@ -229,6 +229,30 @@ export const SPARTACUS_S4OM = '@spartacus/s4om'; export const SPARTACUS_S4OM_ROOT = `@spartacus/s4om/root`; export const SPARTACUS_S4OM_ASSETS = `@spartacus/s4om/assets`; +export const SPARTACUS_OPF = `@spartacus/opf`; +export const SPARTACUS_OPF_ROOT = `@spartacus/opf/root`; +export const SPARTACUS_OPF_ASSETS = `@spartacus/opf/assets`; + +export const SPARTACUS_OPF_CHECKOUT = `@spartacus/opf/checkout`; +export const SPARTACUS_OPF_CHECKOUT_ROOT = `@spartacus/opf/checkout/root`; +export const SPARTACUS_OPF_CHECKOUT_ASSETS = `@spartacus/opf/checkout/assets`; + +export const SPARTACUS_OPF_PAYMENT = `@spartacus/opf/payment`; +export const SPARTACUS_OPF_PAYMENT_ROOT = `@spartacus/opf/payment/root`; +export const SPARTACUS_OPF_PAYMENT_ASSETS = `@spartacus/opf/payment/assets`; + +export const SPARTACUS_OPF_GLOBAL_FUNCTIONS = `@spartacus/opf/global-functions`; +export const SPARTACUS_OPF_GLOBAL_FUNCTIONS_ROOT = `@spartacus/opf/global-functions/root`; + +export const SPARTACUS_OPF_CTA = `@spartacus/opf/cta`; +export const SPARTACUS_OPF_CTA_ROOT = `@spartacus/opf/cta/root`; + +export const SPARTACUS_OPF_QUICK_BUY = `@spartacus/opf/quick-buy`; +export const SPARTACUS_OPF_QUICK_BUY_ROOT = `@spartacus/opf/quick-buy/root`; + +export const SPARTACUS_OPF_BASE = `@spartacus/opf/base`; +export const SPARTACUS_OPF_BASE_ROOT = `@spartacus/opf/base/root`; + export const SPARTACUS_OMF = '@spartacus/omf'; export const SPARTACUS_OMF_ROOT = '@spartacus/omf/root'; export const SPARTACUS_OMF_ORDER = '@spartacus/omf/order'; @@ -341,6 +365,13 @@ export const OPPS_FEATURE_NAME = 'OPPS'; export const OMF_FEATURE_NAME = 'OMF'; export const CUSTOMER_TICKETING_FEATURE_NAME = 'Customer-Ticketing'; +export const OPF_FEATURE_NAME = 'OPF'; +export const OPF_CHECKOUT_FEATURE_NAME = 'OPF-Checkout'; +export const OPF_BASE_FEATURE_NAME = 'OPF-Base'; +export const OPF_PAYMENT_FEATURE_NAME = 'OPF-Payment'; +export const OPF_CTA_FEATURE_NAME = 'OPF-Cta'; +export const OPF_GLOBAL_FUNCTIONS_FEATURE_NAME = 'OPF-Global-Functions'; +export const OPF_QUICK_BUY_FEATURE_NAME = 'OPF-Quick-Buy'; /***** Feature name end *****/ /***** Feature name start *****/ diff --git a/projects/schematics/src/shared/schematics-config-mappings.ts b/projects/schematics/src/shared/schematics-config-mappings.ts index cc4e08925e2..8aa815d603b 100644 --- a/projects/schematics/src/shared/schematics-config-mappings.ts +++ b/projects/schematics/src/shared/schematics-config-mappings.ts @@ -5,17 +5,17 @@ */ import { SchematicsException } from '@angular-devkit/schematics'; -import { - ASM_CUSTOMER_360_SCHEMATICS_CONFIG, - ASM_SCHEMATICS_CONFIG, -} from './lib-configs/asm-schematics-config'; import { CDP_SCHEMATICS_CONFIG, + OMF_SCHEMATICS_CONFIG, OPPS_SCHEMATICS_CONFIG, QUOTE_SCHEMATICS_CONFIG, S4_SERVICE_SCHEMATICS_CONFIG, - OMF_SCHEMATICS_CONFIG, } from './lib-configs'; +import { + ASM_CUSTOMER_360_SCHEMATICS_CONFIG, + ASM_SCHEMATICS_CONFIG, +} from './lib-configs/asm-schematics-config'; import { CART_BASE_SCHEMATICS_CONFIG, CART_IMPORT_EXPORT_SCHEMATICS_CONFIG, @@ -34,11 +34,20 @@ import { CDC_SCHEMATICS_CONFIG, } from './lib-configs/integration-libs/cdc-schematics-config'; import { CDS_SCHEMATICS_CONFIG } from './lib-configs/integration-libs/cds-schematics-config'; +import { CPQ_QUOTE_SCHEMATICS_CONFIG } from './lib-configs/integration-libs/cpq-quote-schematics-config'; import { DIGITAL_PAYMENTS_SCHEMATICS_CONFIG } from './lib-configs/integration-libs/digital-payments-schematics-config'; import { EPD_SCHEMATICS_CONFIG } from './lib-configs/integration-libs/epd-schematics-config'; +import { + OPF_BASE_SCHEMATICS_CONFIG, + OPF_CHECKOUT_SCHEMATICS_CONFIG, + OPF_CTA_SCHEMATICS_CONFIG, + OPF_GLOBAL_FUNCTIONS_SCHEMATICS_CONFIG, + OPF_PAYMENT_SCHEMATICS_CONFIG, + OPF_QUICK_BUY_SCHEMATICS_CONFIG, +} from './lib-configs/integration-libs/opf-schematics-config'; import { S4OM_SCHEMATICS_CONFIG } from './lib-configs/integration-libs/s4om-schematics-config'; -import { CPQ_QUOTE_SCHEMATICS_CONFIG } from './lib-configs/integration-libs/cpq-quote-schematics-config'; +import { ESTIMATED_DELIVERY_DATE_SCHEMATICS_CONFIG } from './lib-configs/estimated-delivery-date-schematics-config'; import { SEGMENT_REFS_SCHEMATICS_CONFIG } from './lib-configs/integration-libs/segment-refs-schematics-config'; import { ORDER_SCHEMATICS_CONFIG } from './lib-configs/order-schematics-config'; import { @@ -55,6 +64,10 @@ import { PRODUCT_CONFIGURATOR_RULEBASED_SCHEMATICS_CONFIG, PRODUCT_CONFIGURATOR_TEXTFIELD_SCHEMATICS_CONFIG, } from './lib-configs/product-configurator-schematics-config'; +import { + PRODUCT_MULTI_DIMENSIONAL_LIST_SCHEMATICS_CONFIG, + PRODUCT_MULTI_DIMENSIONAL_SELECTOR_SCHEMATICS_CONFIG, +} from './lib-configs/product-multi-dimensional-schematics-config'; import { PRODUCT_BULK_PRICING_SCHEMATICS_CONFIG, PRODUCT_FUTURE_STOCK_SCHEMATICS_CONFIG, @@ -63,7 +76,6 @@ import { } from './lib-configs/product-schematics-config'; import { QUALTRICS_SCHEMATICS_CONFIG } from './lib-configs/qualtrics-schematics-config'; import { REQUESTED_DELIVERY_DATE_SCHEMATICS_CONFIG } from './lib-configs/requested-delivery-date-schematics-config'; -import { ESTIMATED_DELIVERY_DATE_SCHEMATICS_CONFIG } from './lib-configs/estimated-delivery-date-schematics-config'; import { SMARTEDIT_SCHEMATICS_CONFIG } from './lib-configs/smartedit-schematics-config'; import { STOREFINDER_SCHEMATICS_CONFIG } from './lib-configs/storefinder-schematics-config'; import { @@ -76,10 +88,6 @@ import { USER_PROFILE_SCHEMATICS_CONFIG, } from './lib-configs/user-schematics-config'; import { Module, SchematicConfig } from './utils/lib-utils'; -import { - PRODUCT_MULTI_DIMENSIONAL_LIST_SCHEMATICS_CONFIG, - PRODUCT_MULTI_DIMENSIONAL_SELECTOR_SCHEMATICS_CONFIG, -} from './lib-configs/product-multi-dimensional-schematics-config'; /** * A list of all schematics feature configurations. @@ -162,6 +170,13 @@ export const SCHEMATICS_CONFIGS: SchematicConfig[] = [ S4OM_SCHEMATICS_CONFIG, + OPF_BASE_SCHEMATICS_CONFIG, + OPF_CHECKOUT_SCHEMATICS_CONFIG, + OPF_PAYMENT_SCHEMATICS_CONFIG, + OPF_CTA_SCHEMATICS_CONFIG, + OPF_GLOBAL_FUNCTIONS_SCHEMATICS_CONFIG, + OPF_QUICK_BUY_SCHEMATICS_CONFIG, + S4_SERVICE_SCHEMATICS_CONFIG, SEGMENT_REFS_SCHEMATICS_CONFIG, diff --git a/projects/schematics/src/shared/utils/graph-utils_spec.ts b/projects/schematics/src/shared/utils/graph-utils_spec.ts index 7ea318f20f0..19c59b19251 100644 --- a/projects/schematics/src/shared/utils/graph-utils_spec.ts +++ b/projects/schematics/src/shared/utils/graph-utils_spec.ts @@ -13,6 +13,7 @@ import { SPARTACUS_EPD_VISUALIZATION, SPARTACUS_ESTIMATED_DELIVERY_DATE, SPARTACUS_OMF, + SPARTACUS_OPF, SPARTACUS_OPPS, SPARTACUS_ORDER, SPARTACUS_ORGANIZATION, @@ -150,6 +151,7 @@ describe('Graph utils', () => { SPARTACUS_S4OM, SPARTACUS_S4_SERVICE, SPARTACUS_OPPS, + SPARTACUS_OPF, SPARTACUS_OMF, SPARTACUS_EPD_VISUALIZATION, SPARTACUS_DIGITAL_PAYMENTS, @@ -187,6 +189,12 @@ describe('Graph utils', () => { "Personalization", "TMS-AEPL", "TMS-GTM", + "OPF-Quick-Buy", + "OPF-Global-Functions", + "OPF-Cta", + "OPF-Base", + "OPF-Payment", + "OPF-Checkout", "PDF-Invoices", "Requested-Delivery-Date", "Customer-Ticketing", diff --git a/projects/schematics/src/shared/utils/test-utils.ts b/projects/schematics/src/shared/utils/test-utils.ts index 9f23361ba80..71dee037198 100644 --- a/projects/schematics/src/shared/utils/test-utils.ts +++ b/projects/schematics/src/shared/utils/test-utils.ts @@ -115,6 +115,8 @@ export const digitalPaymentsFeatureModulePath = 'src/app/spartacus/features/digital-payments/digital-payments-feature.module.ts'; export const epdFeatureModulePath = 'src/app/spartacus/features/epd-visualization/epd-visualization-feature.module.ts'; +export const opfFeatureModulePath = + 'src/app/spartacus/features/opf/opf-feature.module.ts'; export const segmentRefsFeatureModulePath = 'src/app/spartacus/features/segment-refs/segment-refs-feature.module.ts'; export const oppsFeatureModulePath = diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/opf/opf-billing-address.e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/opf/opf-billing-address.e2e.cy.ts new file mode 100644 index 00000000000..671bdc26f2f --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/opf/opf-billing-address.e2e.cy.ts @@ -0,0 +1,182 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitForPage } from '../../../helpers/checkout-flow'; +import { fillShippingAddress } from '../../../helpers/checkout-forms'; +import { + changeLastNameOnPaymentForm, + checkAddressForAllRequiredFields, + fillPaymentAddress, + mockPaymentAddress, + proceedToCheckoutWithFirstProductFromSearch, + verifyDeliveryMethod, +} from '../../../helpers/opf'; +import { viewportContext } from '../../../helpers/viewport-context'; +import { getSampleUser } from '../../../sample-data/checkout-flow'; + +const user = getSampleUser(); + +describe('OPF billing address', () => { + viewportContext(['mobile', 'desktop'], () => { + describe('billing address', () => { + beforeEach(() => { + cy.requireLoggedIn(); + cy.visit('/'); + + proceedToCheckoutWithFirstProductFromSearch(); + + const deliveryModePage = waitForPage( + '/checkout/delivery-mode', + 'getDeliveryModePage' + ); + + fillShippingAddress(user); + + cy.wait(`@${deliveryModePage}`) + .its('response.statusCode') + .should('eq', 200); + + verifyDeliveryMethod(); + }); + + it('should show and hide payment form when click checkbox', () => { + const checkbox = cy + .get('cx-opf-checkout-billing-address-form') + .find('input.form-check-input'); + + checkbox.uncheck(); + + cy.get('.cx-custom-address-info').should('not.exist'); + + cy.get('cx-opf-checkout-billing-address-form') + .find('cx-address-form') + .should('exist'); + + checkbox.check(); + + cy.get('cx-opf-checkout-billing-address-form') + .find('cx-address-form') + .should('not.exist'); + + cy.get('.cx-custom-address-info').should('exist'); + }); + + it('should hide payment form when click cancel button', () => { + const checkbox = cy + .get('cx-opf-checkout-billing-address-form') + .find('input.form-check-input'); + + checkbox.uncheck(); + + cy.get('.cx-address-form-btns').find('button.btn-secondary').click(); + + cy.get('cx-opf-checkout-billing-address-form') + .find('cx-address-form') + .should('not.exist'); + + cy.get('.cx-custom-address-info').should('exist'); + }); + + it('should put shipping address as payment address when check checkbox again', () => { + const checkbox = cy + .get('cx-opf-checkout-billing-address-form') + .find('input.form-check-input'); + + cy.intercept( + 'PUT', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/users/current/carts/*/addresses/billing?lang=en&curr=USD` + ).as('billing'); + + checkbox.uncheck(); + checkbox.check(); + + cy.get('@billing').then(() => { + cy.get('cx-card') + .find('.cx-card-label-container') + .first() + .should('contain', `${user.firstName} ${user.lastName}`); + }); + }); + + it('should show new payment address after save', () => { + const checkbox = cy + .get('cx-opf-checkout-billing-address-form') + .find('input.form-check-input'); + + cy.intercept( + 'PUT', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/users/current/carts/*/addresses/billing?lang=en&curr=USD` + ).as('billing'); + + checkbox.uncheck(); + + fillPaymentAddress(mockPaymentAddress, true); + + cy.get('@billing').then(() => { + checkAddressForAllRequiredFields(mockPaymentAddress); + }); + }); + + it('should uncheck the checkbox when new payment address was provided', () => { + const checkbox = cy + .get('cx-opf-checkout-billing-address-form') + .find('input.form-check-input'); + + cy.intercept( + 'PUT', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/users/current/carts/*/addresses/billing?lang=en&curr=USD` + ).as('billing'); + + checkbox.uncheck(); + + fillPaymentAddress(mockPaymentAddress, true); + + cy.get('@billing').then(() => checkbox.should('not.be.checked')); + }); + + it('should open edit form when click edit button and show new value after save', () => { + const checkbox = cy + .get('cx-opf-checkout-billing-address-form') + .find('input.form-check-input'); + + cy.intercept( + 'PUT', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env( + 'BASE_SITE' + )}/users/current/carts/*/addresses/billing?lang=en&curr=USD` + ).as('billing'); + + checkbox.uncheck(); + + fillPaymentAddress(mockPaymentAddress, true); + + const mockedLastName = 'Cypress'; + + cy.get('@billing').then(() => { + cy.get('.cx-custom-address-info').within(() => { + cy.get('button').click(); + }); + + changeLastNameOnPaymentForm(mockedLastName); + + cy.get('@billing').then(() => { + cy.get('.cx-custom-address-info') + .find('.cx-card-container') + .within(() => { + cy.get('.cx-card-label-bold').should('contain', mockedLastName); + }); + }); + }); + }); + }); + }); +}); diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/opf/opf-payment-options.e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/opf/opf-payment-options.e2e.cy.ts new file mode 100644 index 00000000000..18cb9215d8a --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/opf/opf-payment-options.e2e.cy.ts @@ -0,0 +1,115 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitForPage } from '../../../helpers/checkout-flow'; +import { fillShippingAddress } from '../../../helpers/checkout-forms'; +import { + proceedToCheckoutWithFirstProductFromSearch, + verifyDeliveryMethod, +} from '../../../helpers/opf'; +import { viewportContext } from '../../../helpers/viewport-context'; +import { getSampleUser } from '../../../sample-data/checkout-flow'; + +const user = getSampleUser(); + +describe('OPF payment options', () => { + viewportContext(['mobile', 'desktop'], () => { + describe('payment options', () => { + beforeEach(() => { + cy.requireLoggedIn(); + cy.visit('/'); + + proceedToCheckoutWithFirstProductFromSearch(); + + const deliveryModePage = waitForPage( + '/checkout/delivery-mode', + 'getDeliveryModePage' + ); + + fillShippingAddress(user); + + cy.wait(`@${deliveryModePage}`) + .its('response.statusCode') + .should('eq', 200); + + verifyDeliveryMethod(); + }); + + it('should show disabled payment option after entering payment tab', () => { + const paymentOptionsContainer = cy + .get('cx-opf-checkout-payment-and-review') + .find('cx-opf-checkout-payments'); + + paymentOptionsContainer.should('exist'); + + cy.get('.cx-payment-options-list') + .first() + .find('input.form-check-input') + .should('be.disabled'); + }); + + it('should show enabled payment option after accepting terms and conditions and disable it after the checkbox being unchecked', () => { + const checkbox = cy + .get('.cx-opf-terms-and-conditions') + .find('input.form-check-input'); + + checkbox.check(); + + cy.get('.cx-payment-options-list') + .first() + .find('input.form-check-input') + .should('not.be.disabled'); + + checkbox.uncheck(); + + cy.get('.cx-payment-options-list') + .first() + .find('input.form-check-input') + .should('be.disabled'); + }); + + it('should show payment wrapper after selecting payment method', () => { + const termsAndConditionsCheckbox = cy + .get('.cx-opf-terms-and-conditions') + .find('input.form-check-input'); + + termsAndConditionsCheckbox.check(); + + cy.intercept( + 'POST', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env('BASE_SITE')}/payments` + ).as('payments'); + + cy.get('.cx-payment-options-list input[type="radio"]').first().check(); + + cy.get('@payments').then(() => { + cy.get('cx-opf-checkout-payment-wrapper').should('exist'); + }); + }); + + it('should hide payment wrapper after terms and conditions uncheck', () => { + const termsAndConditionsCheckbox = cy + .get('.cx-opf-terms-and-conditions') + .find('input.form-check-input'); + + termsAndConditionsCheckbox.check(); + + cy.intercept( + 'POST', + `${Cypress.env('OCC_PREFIX')}/${Cypress.env('BASE_SITE')}/payments` + ).as('payments'); + + cy.get('.cx-payment-options-list input[type="radio"]').first().check(); + + cy.get('@payments').then(() => { + termsAndConditionsCheckbox.uncheck(); + + cy.get('cx-opf-checkout-payment-wrapper').should('not.exist'); + }); + }); + }); + }); +}); diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/opf.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/opf.ts new file mode 100644 index 00000000000..49508be494e --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/opf.ts @@ -0,0 +1,144 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SampleProduct } from '../sample-data/checkout-flow'; +import { interceptGet } from '../support/utils/intercept'; +import { waitForPage } from './checkout-flow'; +import { AddressData } from './checkout-forms'; +import { searchForProduct } from './product-search'; + +export const product: SampleProduct = { + name: 'Alpha 350', + code: '1446509', +}; + +export const mockPaymentAddress = { + titleCode: 'Mr', + firstName: 'Test', + lastName: 'Address', + fullName: 'Test Address', + password: '123.', + email: 'test@mail.com', + phone: '123 456 789', + cellphone: '987 654 321', + address: { + city: 'Miami-Beach', + line1: 'Collins Ave', + line2: '8616 Surfside', + country: 'United States', + state: 'Florida', + postal: '33254', + }, + payment: { + card: 'Visa', + number: '4111111111111111', + expires: { + month: '10', + year: '2028', + }, + cvv: '123', + }, +}; + +export function verifyDeliveryMethod() { + cy.log('🛒 Selecting delivery method'); + + cy.get('.cx-checkout-title').should('contain', 'Delivery Method'); + + cy.get('cx-delivery-mode input').first().should('be.checked'); + + const reviewPage = waitForPage('/checkout/review-order', 'getReviewPage'); + cy.get('.cx-checkout-btns button.btn-primary') + .should('be.enabled') + .click({ force: true }); + cy.wait(`@${reviewPage}`).its('response.statusCode').should('eq', 200); +} + +export function proceedToCheckoutWithFirstProductFromSearch() { + searchForProduct(product.name); + interceptGet( + 'cart_refresh', + '/users/*/carts/*?fields=DEFAULT,potentialProductPromotions*' + ); + cy.get('cx-add-to-cart') + .findByText(/Add To Cart/i) + .click(); + cy.wait(`@cart_refresh`); + + cy.findByText(/proceed to checkout/i).click(); +} + +export function fillPaymentAddress( + address: Partial, + submitForm: boolean = true +) { + cy.wait(3000); + cy.get('button.btn-primary').should('be.visible'); + cy.get('cx-address-form').within(() => { + if (address) { + address?.address?.country && + cy + .get('.country-select[formcontrolname="isocode"]') + .ngSelect(address.address.country); + cy.get('[formcontrolname="titleCode"]').ngSelect('Mr'); + address?.firstName && + cy.get('[formcontrolname="firstName"]').clear().type(address.firstName); + + address?.lastName && + cy.get('[formcontrolname="lastName"]').clear().type(address.lastName); + address?.address?.line1 && + cy.get('[formcontrolname="line1"]').clear().type(address.address.line1); + address?.address?.line2 && + cy.get('[formcontrolname="line2"]').clear().type(address.address.line2); + address?.address?.city && + cy.get('[formcontrolname="town"]').clear().type(address.address.city); + address?.address?.state && + cy + .get('.region-select[formcontrolname="isocode"]') + .ngSelect(address.address.state); + address?.address?.postal && + cy + .get('[formcontrolname="postalCode"]') + .clear() + .type(address.address.postal); + address?.phone && + cy.get('[formcontrolname="phone"]').clear().type(address.phone); + } + if (submitForm) { + cy.get('button.btn-primary').click(); + } + }); +} + +export function checkAddressForAllRequiredFields( + address: Partial +) { + cy.get('.cx-custom-address-info') + .find('.cx-card-container') + .within(() => { + cy.get('.cx-card-label-bold').should( + 'contain', + `${address.firstName} ${address.lastName}` + ); + cy.get('.cx-card-label').eq(0).should('contain', address.address.line1); + cy.get('.cx-card-label').eq(1).should('contain', address.address.line2); + cy.get('.cx-card-label') + .eq(2) + .should('contain', `${address.address.city}, US-FL, US`); + cy.get('.cx-card-label').eq(3).should('contain', address.address.postal); + cy.get('.cx-card-label').eq(4).should('contain', address.phone); + }); +} + +export function changeLastNameOnPaymentForm(lastName: string) { + cy.get('cx-opf-checkout-billing-address-form') + .find('cx-address-form') + .should('exist') + .within(() => { + cy.get('[formcontrolname="lastName"]').clear().type(lastName); + cy.get('button.btn-primary').click(); + }); +} diff --git a/projects/storefrontapp/project.json b/projects/storefrontapp/project.json index d9b49a61431..743b972702c 100644 --- a/projects/storefrontapp/project.json +++ b/projects/storefrontapp/project.json @@ -101,6 +101,10 @@ "input": "projects/storefrontapp/src/styles/lib-s4om.scss", "bundleName": "s4om" }, + { + "input": "projects/storefrontapp/src/styles/lib-opf.scss", + "bundleName": "opf" + }, { "input": "projects/storefrontapp/src/styles/lib-quote.scss", "bundleName": "quote" diff --git a/projects/storefrontapp/src/app/spartacus/features/checkout/checkout-feature.module.ts b/projects/storefrontapp/src/app/spartacus/features/checkout/checkout-feature.module.ts index 0bbc813734b..ad0c044d5a6 100644 --- a/projects/storefrontapp/src/app/spartacus/features/checkout/checkout-feature.module.ts +++ b/projects/storefrontapp/src/app/spartacus/features/checkout/checkout-feature.module.ts @@ -15,8 +15,8 @@ import { checkoutTranslations, } from '@spartacus/checkout/base/assets'; import { - CheckoutRootModule, CHECKOUT_FEATURE, + CheckoutRootModule, } from '@spartacus/checkout/base/root'; import { checkoutScheduledReplenishmentTranslationChunksConfig, diff --git a/projects/storefrontapp/src/app/spartacus/features/checkout/checkout-wrapper.module.ts b/projects/storefrontapp/src/app/spartacus/features/checkout/checkout-wrapper.module.ts index dba5eedeb96..5dff36cfb51 100644 --- a/projects/storefrontapp/src/app/spartacus/features/checkout/checkout-wrapper.module.ts +++ b/projects/storefrontapp/src/app/spartacus/features/checkout/checkout-wrapper.module.ts @@ -9,8 +9,9 @@ import { CheckoutB2BModule } from '@spartacus/checkout/b2b'; import { CheckoutModule } from '@spartacus/checkout/base'; import { CheckoutScheduledReplenishmentModule } from '@spartacus/checkout/scheduled-replenishment'; import { DigitalPaymentsModule } from '@spartacus/digital-payments'; -import { environment } from '../../../../environments/environment'; + import { S4ServiceCheckoutModule } from '@spartacus/s4-service/checkout'; +import { environment } from '../../../../environments/environment'; const extensions: Type[] = []; @@ -21,6 +22,7 @@ if (environment.b2b) { if (environment.digitalPayments) { extensions.push(DigitalPaymentsModule); } + if (environment.s4Service) { extensions.push(S4ServiceCheckoutModule); } diff --git a/projects/storefrontapp/src/app/spartacus/features/opf/opf-feature.module.ts b/projects/storefrontapp/src/app/spartacus/features/opf/opf-feature.module.ts new file mode 100644 index 00000000000..09762bf0e1f --- /dev/null +++ b/projects/storefrontapp/src/app/spartacus/features/opf/opf-feature.module.ts @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { NgModule, Provider } from '@angular/core'; +import { I18nConfig, provideConfig, RoutingConfig } from '@spartacus/core'; +import { + OPF_BASE_FEATURE, + OpfBaseRootModule, + OpfConfig, +} from '@spartacus/opf/base/root'; +import { + opfCheckoutTranslationChunksConfig, + opfCheckoutTranslations, +} from '@spartacus/opf/checkout/assets'; +import { + defaultOpfCheckoutB2bConfig, + defaultOpfCheckoutConfig, + OPF_CHECKOUT_FEATURE, + OpfCheckoutRootModule, +} from '@spartacus/opf/checkout/root'; +import { + opfPaymentTranslationChunksConfig, + opfPaymentTranslations, +} from '@spartacus/opf/payment/assets'; + +import { OPF_CTA_FEATURE, OpfCtaRootModule } from '@spartacus/opf/cta/root'; +import { + OPF_GLOBAL_FUNCTIONS_FEATURE, + OpfGlobalFunctionsRootModule, +} from '@spartacus/opf/global-functions/root'; +import { + OPF_PAYMENT_FEATURE, + OpfPaymentRootModule, +} from '@spartacus/opf/payment/root'; +import { + OPF_QUICK_BUY_FEATURE, + OpfQuickBuyRootModule, +} from '@spartacus/opf/quick-buy/root'; +import { environment } from '../../../../environments/environment'; + +const extensionProviders: Provider[] = []; +if (environment.b2b) { + extensionProviders.push(provideConfig(defaultOpfCheckoutB2bConfig)); +} else { + extensionProviders.push(provideConfig(defaultOpfCheckoutConfig)); +} + +@NgModule({ + imports: [ + OpfBaseRootModule, + OpfPaymentRootModule, + OpfCheckoutRootModule, + OpfCtaRootModule, + OpfGlobalFunctionsRootModule, + OpfQuickBuyRootModule, + ], + providers: [ + provideConfig({ + featureModules: { + [OPF_BASE_FEATURE]: { + module: () => + import('@spartacus/opf/base').then((m) => m.OpfBaseModule), + }, + [OPF_PAYMENT_FEATURE]: { + module: () => + import('@spartacus/opf/payment').then((m) => m.OpfPaymentModule), + }, + [OPF_CHECKOUT_FEATURE]: { + module: () => + import('@spartacus/opf/checkout').then((m) => m.OpfCheckoutModule), + }, + [OPF_CTA_FEATURE]: { + module: () => + import('@spartacus/opf/cta').then((m) => m.OpfCtaModule), + }, + [OPF_GLOBAL_FUNCTIONS_FEATURE]: { + module: () => + import('@spartacus/opf/global-functions').then( + (m) => m.OpfGlobalFunctionsModule + ), + }, + [OPF_QUICK_BUY_FEATURE]: { + module: () => + import('@spartacus/opf/quick-buy').then((m) => m.OpfQuickBuyModule), + }, + }, + }), + + provideConfig({ + routing: { + routes: { + paymentVerificationResult: { + paths: ['redirect/success'], + }, + paymentVerificationCancel: { + paths: ['redirect/failure'], + }, + }, + }, + }), + provideConfig({ + i18n: { + resources: opfCheckoutTranslations, + chunks: opfCheckoutTranslationChunksConfig, + fallbackLang: 'en', + }, + }), + provideConfig({ + i18n: { + resources: opfPaymentTranslations, + chunks: opfPaymentTranslationChunksConfig, + fallbackLang: 'en', + }, + }), + provideConfig({ + opf: { + opfBaseUrl: + 'https://opf-iss-d0.opf.commerce.stage.context.cloud.sap/commerce-cloud-adapter/storefront/', + commerceCloudPublicKey: 'ab4RhYGZ+w5B0SALMPOPlepWk/kmDQjTy2FU5hrQoFg=', + }, + }), + ...extensionProviders, + ], +}) +export class OpfFeatureModule {} diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-b2c-configuration.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus-b2c-configuration.module.ts index 45236911822..0f6f374713d 100644 --- a/projects/storefrontapp/src/app/spartacus/spartacus-b2c-configuration.module.ts +++ b/projects/storefrontapp/src/app/spartacus/spartacus-b2c-configuration.module.ts @@ -17,6 +17,7 @@ import { environment } from '../../environments/environment'; const defaultBaseSite = [ 'electronics-spa', + 'electronics-spa-standalone', 'electronics', 'electronics-standalone', 'apparel-de', diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts index 766af891d6a..1a434b84f80 100644 --- a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts +++ b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts @@ -78,6 +78,8 @@ import { CpqQuoteFeatureModule } from './features/cpq-quote/cpq-quote-feature.mo import { CustomerTicketingFeatureModule } from './features/customer-ticketing/customer-ticketing-feature.module'; import { DigitalPaymentsFeatureModule } from './features/digital-payments/digital-payments-feature.module'; import { EpdVisualizationFeatureModule } from './features/epd-visualization/epd-visualization-feature.module'; +import { QuoteFeatureModule } from './features/quote-feature.module'; +import { OpfFeatureModule } from './features/opf/opf-feature.module'; import { EstimatedDeliveryDateFeatureModule } from './features/estimated-delivery-date/estimated-delivery-date-feature.module'; import { OmfFeatureModule } from './features/omf/omf-feature.module'; import { OppsFeatureModule } from './features/opps/opps-feature.module'; @@ -97,7 +99,6 @@ import { FutureStockFeatureModule } from './features/product/product-future-stoc import { ImageZoomFeatureModule } from './features/product/product-image-zoom-feature.module'; import { VariantsFeatureModule } from './features/product/product-variants-feature.module'; import { QualtricsFeatureModule } from './features/qualtrics/qualtrics-feature.module'; -import { QuoteFeatureModule } from './features/quote-feature.module'; import { OrganizationUserRegistrationFeatureModule } from './features/registration-feature.module'; import { RequestedDeliveryDateFeatureModule } from './features/requested-delivery-date/requested-delivery-date-feature.module'; import { S4ServiceFeatureModule } from './features/s4-service/s4-service-feature.module'; @@ -148,6 +149,9 @@ if (environment.opps) { if (environment.s4om) { featureModules.push(S4OMFeatureModule); } +if (environment.opf) { + featureModules.push(OpfFeatureModule); +} if (environment.segmentRefs) { featureModules.push(SegmentRefsFeatureModule); } diff --git a/projects/storefrontapp/src/environments/environment.prod.ts b/projects/storefrontapp/src/environments/environment.prod.ts index 55d6ec33c5a..a907ea27730 100644 --- a/projects/storefrontapp/src/environments/environment.prod.ts +++ b/projects/storefrontapp/src/environments/environment.prod.ts @@ -18,6 +18,7 @@ export const environment: Environment = { digitalPayments: buildProcess.env.CX_DIGITAL_PAYMENTS, epdVisualization: buildProcess.env.CX_EPD_VISUALIZATION, s4om: buildProcess.env.CX_S4OM, + opf: buildProcess.env.CX_OPF, omf: buildProcess.env.CX_OMF, segmentRefs: buildProcess.env.CX_SEGMENT_REFS, opps: buildProcess.env.CX_OPPS, diff --git a/projects/storefrontapp/src/environments/environment.ts b/projects/storefrontapp/src/environments/environment.ts index b09532c3ede..825e966fe21 100644 --- a/projects/storefrontapp/src/environments/environment.ts +++ b/projects/storefrontapp/src/environments/environment.ts @@ -31,6 +31,7 @@ export const environment: Environment = { digitalPayments: buildProcess.env.CX_DIGITAL_PAYMENTS ?? false, epdVisualization: buildProcess.env.CX_EPD_VISUALIZATION ?? false, s4om: buildProcess.env.CX_S4OM ?? false, + opf: buildProcess.env.CX_OPF ?? false, omf: buildProcess.env.CX_OMF ?? false, segmentRefs: buildProcess.env.CX_SEGMENT_REFS ?? false, opps: buildProcess.env.CX_OPPS ?? false, diff --git a/projects/storefrontapp/src/environments/models/build.process.env.d.ts b/projects/storefrontapp/src/environments/models/build.process.env.d.ts index 57cee274138..4e85c96f8aa 100644 --- a/projects/storefrontapp/src/environments/models/build.process.env.d.ts +++ b/projects/storefrontapp/src/environments/models/build.process.env.d.ts @@ -20,6 +20,7 @@ interface Env { CX_DIGITAL_PAYMENTS: boolean; CX_EPD_VISUALIZATION: boolean; CX_S4OM: boolean; + CX_OPF: boolean; CX_OMF: boolean; CX_SEGMENT_REFS: boolean; CX_OPPS: boolean; diff --git a/projects/storefrontapp/src/environments/models/environment.model.ts b/projects/storefrontapp/src/environments/models/environment.model.ts index e565b8fd7ef..57b8ea4dc35 100644 --- a/projects/storefrontapp/src/environments/models/environment.model.ts +++ b/projects/storefrontapp/src/environments/models/environment.model.ts @@ -16,6 +16,7 @@ export interface Environment { digitalPayments: boolean; epdVisualization: boolean; s4om: boolean; + opf: boolean; omf: boolean; segmentRefs: boolean; opps: boolean; diff --git a/projects/storefrontapp/src/styles/lib-opf.scss b/projects/storefrontapp/src/styles/lib-opf.scss new file mode 100644 index 00000000000..56bcef77964 --- /dev/null +++ b/projects/storefrontapp/src/styles/lib-opf.scss @@ -0,0 +1,2 @@ +@import '../styles-config'; +@import '@spartacus/opf'; diff --git a/projects/storefrontapp/tsconfig.app.prod.json b/projects/storefrontapp/tsconfig.app.prod.json index f69c0de0f96..df2a46f10b3 100644 --- a/projects/storefrontapp/tsconfig.app.prod.json +++ b/projects/storefrontapp/tsconfig.app.prod.json @@ -403,6 +403,38 @@ "@spartacus/omf": ["dist/omf"], "@spartacus/omf/order": ["dist/omf/order"], "@spartacus/omf/root": ["dist/omf/root"], + "@spartacus/opf/base/components": ["dist/opf/base/components"], + "@spartacus/opf/base/core": ["dist/opf/base/core"], + "@spartacus/opf/base": ["dist/opf/base"], + "@spartacus/opf/base/opf-api": ["dist/opf/base/opf-api"], + "@spartacus/opf/base/root": ["dist/opf/base/root"], + "@spartacus/opf/checkout/assets": ["dist/opf/checkout/assets"], + "@spartacus/opf/checkout/components": ["dist/opf/checkout/components"], + "@spartacus/opf/checkout": ["dist/opf/checkout"], + "@spartacus/opf/checkout/root": ["dist/opf/checkout/root"], + "@spartacus/opf/cta/components": ["dist/opf/cta/components"], + "@spartacus/opf/cta/core": ["dist/opf/cta/core"], + "@spartacus/opf/cta": ["dist/opf/cta"], + "@spartacus/opf/cta/opf-api": ["dist/opf/cta/opf-api"], + "@spartacus/opf/cta/root": ["dist/opf/cta/root"], + "@spartacus/opf/global-functions/core": [ + "dist/opf/global-functions/core" + ], + "@spartacus/opf/global-functions": ["dist/opf/global-functions"], + "@spartacus/opf/global-functions/root": [ + "dist/opf/global-functions/root" + ], + "@spartacus/opf": ["dist/opf"], + "@spartacus/opf/payment/assets": ["dist/opf/payment/assets"], + "@spartacus/opf/payment/core": ["dist/opf/payment/core"], + "@spartacus/opf/payment": ["dist/opf/payment"], + "@spartacus/opf/payment/opf-api": ["dist/opf/payment/opf-api"], + "@spartacus/opf/payment/root": ["dist/opf/payment/root"], + "@spartacus/opf/quick-buy/components": ["dist/opf/quick-buy/components"], + "@spartacus/opf/quick-buy/core": ["dist/opf/quick-buy/core"], + "@spartacus/opf/quick-buy": ["dist/opf/quick-buy"], + "@spartacus/opf/quick-buy/opf-api": ["dist/opf/quick-buy/opf-api"], + "@spartacus/opf/quick-buy/root": ["dist/opf/quick-buy/root"], "@spartacus/opps": ["dist/opps"], "@spartacus/opps/root": ["dist/opps/root"], "@spartacus/s4-service/assets": ["dist/s4-service/assets"], diff --git a/projects/storefrontapp/tsconfig.server.json b/projects/storefrontapp/tsconfig.server.json index 7adbca4f867..8406663b49d 100644 --- a/projects/storefrontapp/tsconfig.server.json +++ b/projects/storefrontapp/tsconfig.server.json @@ -615,6 +615,84 @@ "@spartacus/omf": ["../../integration-libs/omf/public_api"], "@spartacus/omf/order": ["../../integration-libs/omf/order/public_api"], "@spartacus/omf/root": ["../../integration-libs/omf/root/public_api"], + "@spartacus/opf/base/components": [ + "../../integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "../../integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": ["../../integration-libs/opf/base/public_api"], + "@spartacus/opf/base/opf-api": [ + "../../integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "../../integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "../../integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "../../integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "../../integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "../../integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "../../integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "../../integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": ["../../integration-libs/opf/cta/public_api"], + "@spartacus/opf/cta/opf-api": [ + "../../integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "../../integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "../../integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "../../integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "../../integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": ["../../integration-libs/opf/public_api"], + "@spartacus/opf/payment/assets": [ + "../../integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "../../integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "../../integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "../../integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "../../integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "../../integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "../../integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "../../integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "../../integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "../../integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": ["../../integration-libs/opps/public_api"], "@spartacus/opps/root": ["../../integration-libs/opps/root/public_api"], "@spartacus/s4-service/assets": [ diff --git a/projects/storefrontapp/tsconfig.server.prod.json b/projects/storefrontapp/tsconfig.server.prod.json index 76f40ce3d9d..95f512dee0a 100644 --- a/projects/storefrontapp/tsconfig.server.prod.json +++ b/projects/storefrontapp/tsconfig.server.prod.json @@ -460,6 +460,42 @@ "@spartacus/omf": ["../../dist/omf"], "@spartacus/omf/order": ["../../dist/omf/order"], "@spartacus/omf/root": ["../../dist/omf/root"], + "@spartacus/opf/base/components": ["../../dist/opf/base/components"], + "@spartacus/opf/base/core": ["../../dist/opf/base/core"], + "@spartacus/opf/base": ["../../dist/opf/base"], + "@spartacus/opf/base/opf-api": ["../../dist/opf/base/opf-api"], + "@spartacus/opf/base/root": ["../../dist/opf/base/root"], + "@spartacus/opf/checkout/assets": ["../../dist/opf/checkout/assets"], + "@spartacus/opf/checkout/components": [ + "../../dist/opf/checkout/components" + ], + "@spartacus/opf/checkout": ["../../dist/opf/checkout"], + "@spartacus/opf/checkout/root": ["../../dist/opf/checkout/root"], + "@spartacus/opf/cta/components": ["../../dist/opf/cta/components"], + "@spartacus/opf/cta/core": ["../../dist/opf/cta/core"], + "@spartacus/opf/cta": ["../../dist/opf/cta"], + "@spartacus/opf/cta/opf-api": ["../../dist/opf/cta/opf-api"], + "@spartacus/opf/cta/root": ["../../dist/opf/cta/root"], + "@spartacus/opf/global-functions/core": [ + "../../dist/opf/global-functions/core" + ], + "@spartacus/opf/global-functions": ["../../dist/opf/global-functions"], + "@spartacus/opf/global-functions/root": [ + "../../dist/opf/global-functions/root" + ], + "@spartacus/opf": ["../../dist/opf"], + "@spartacus/opf/payment/assets": ["../../dist/opf/payment/assets"], + "@spartacus/opf/payment/core": ["../../dist/opf/payment/core"], + "@spartacus/opf/payment": ["../../dist/opf/payment"], + "@spartacus/opf/payment/opf-api": ["../../dist/opf/payment/opf-api"], + "@spartacus/opf/payment/root": ["../../dist/opf/payment/root"], + "@spartacus/opf/quick-buy/components": [ + "../../dist/opf/quick-buy/components" + ], + "@spartacus/opf/quick-buy/core": ["../../dist/opf/quick-buy/core"], + "@spartacus/opf/quick-buy": ["../../dist/opf/quick-buy"], + "@spartacus/opf/quick-buy/opf-api": ["../../dist/opf/quick-buy/opf-api"], + "@spartacus/opf/quick-buy/root": ["../../dist/opf/quick-buy/root"], "@spartacus/opps": ["../../dist/opps"], "@spartacus/opps/root": ["../../dist/opps/root"], "@spartacus/s4-service/assets": ["../../dist/s4-service/assets"], diff --git a/projects/storefrontlib/shared/components/item-counter/index.ts b/projects/storefrontlib/shared/components/item-counter/index.ts index f0154a60080..2ba98d9b308 100644 --- a/projects/storefrontlib/shared/components/item-counter/index.ts +++ b/projects/storefrontlib/shared/components/item-counter/index.ts @@ -4,5 +4,5 @@ * SPDX-License-Identifier: Apache-2.0 */ -export * from './item-counter.module'; export * from './item-counter.component'; +export * from './item-counter.module'; diff --git a/scripts/install/config.default.sh b/scripts/install/config.default.sh index c07c5cca887..7939b6e3e48 100644 --- a/scripts/install/config.default.sh +++ b/scripts/install/config.default.sh @@ -15,6 +15,7 @@ BASE_SITE= OCC_PREFIX="/occ/v2/" URL_PARAMETERS="baseSite,language,currency" +CURRENCY="USD,EUR" SPARTACUS_PROJECTS=( "dist/core:projects/core" @@ -39,6 +40,7 @@ SPARTACUS_PROJECTS=( "dist/cdp:integration-libs/cdp" "dist/opps:integration-libs/opps" "dist/epd-visualization:integration-libs/epd-visualization" + "dist/opf:integration-libs/opf" "dist/product-configurator:feature-libs/product-configurator" "dist/product-multi-dimensional:feature-libs/product-multi-dimensional" "dist/pickup-in-store:feature-libs/pickup-in-store" @@ -79,6 +81,8 @@ ADD_OPPS=false # config.epd-visualization.sh contains default values to use in your config.sh when ADD_EPD_VISUALIZATION is true. ADD_EPD_VISUALIZATION=false ADD_S4OM=false +# config.opf.sh contains default values to use in your config.sh when ADD_OPF is true. +ADD_OPF=false ADD_CPQ_QUOTE=false ADD_S4_SERVICE=false ADD_PRODUCT_MULTI_DIMENSIONAL=false @@ -86,6 +90,10 @@ ADD_PRODUCT_MULTI_DIMENSIONAL=false # The base URL (origin) of the SAP EPD Visualization Fiori launchpad EPD_VISUALIZATION_BASE_URL= +# The base URL and public key values are required for connection to Cloud Commerce Adapter (OPF) +OPF_BASE_URL= +OPF_CLIENT_PUBLIC_KEY= + #NPM connection info #NPM_URL must start by 'https://' and end with '/' char NPM_TOKEN= diff --git a/scripts/install/config.opf.sh b/scripts/install/config.opf.sh new file mode 100644 index 00000000000..f2ad2f3b090 --- /dev/null +++ b/scripts/install/config.opf.sh @@ -0,0 +1,13 @@ +# This file contains default values to use in your config.sh when @spartacus/opf is being included + +ADD_OPF=true + +BACKEND_URL="https://api.cp96avkh5f-integrati2-d2-public.model-t.cc.commerce.ondemand.com" +BASE_SITE="electronics-spa-standalone" + +# The base URL and public key values are required for connection to Cloud Commerce Adapter (OPF) +OPF_BASE_URL="https://cp96avkh5f-integrati2-d2.opf.commerce.stage.context.cloud.sap/commerce-cloud-adapter-stage/storefront" +OPF_CLIENT_PUBLIC_KEY="k2N3m3TJPLragwia5ZUvS/qkIPVQoy5qjUkOAB6Db+U=" + +# TODO: Comment out the line below after OPF branch is merged into develop. +BRANCH="epic/opf" diff --git a/scripts/install/functions.sh b/scripts/install/functions.sh index a36abea20c7..c8ad7e646e1 100644 --- a/scripts/install/functions.sh +++ b/scripts/install/functions.sh @@ -134,6 +134,12 @@ function add_epd_visualization { fi } +function add_opf { + if [ "$ADD_OPF" = true ] ; then + ng add @spartacus/opf@${SPARTACUS_VERSION} --opf-base-url ${OPF_BASE_URL} --commerce-cloud-public-key ${OPF_CLIENT_PUBLIC_KEY} --skip-confirmation --no-interactive + fi +} + function add_product_configurator { ng add @spartacus/product-configurator@${SPARTACUS_VERSION} --skip-confirmation --no-interactive ng add @spartacus/product-configurator --skip-confirmation --no-interactive --features "Textfield-Configurator" --features "VC-Configurator" @@ -208,14 +214,15 @@ function add_spartacus_csr { create_npmrc ${CSR_APP_NAME} fi if [ "$BASE_SITE" = "" ] ; then - ng add @spartacus/schematics@${SPARTACUS_VERSION} --skip-confirmation --overwrite-app-component --base-url ${BACKEND_URL} --occ-prefix ${OCC_PREFIX} --url-parameters ${URL_PARAMETERS} --no-interactive + ng add @spartacus/schematics@${SPARTACUS_VERSION} --skip-confirmation --overwrite-app-component --base-url ${BACKEND_URL} --occ-prefix ${OCC_PREFIX} --currency ${CURRENCY} --url-parameters ${URL_PARAMETERS} --no-interactive else - ng add @spartacus/schematics@${SPARTACUS_VERSION} --skip-confirmation --overwrite-app-component --base-url ${BACKEND_URL} --occ-prefix ${OCC_PREFIX} --base-site ${BASE_SITE} --url-parameters ${URL_PARAMETERS} --no-interactive + ng add @spartacus/schematics@${SPARTACUS_VERSION} --skip-confirmation --overwrite-app-component --base-url ${BACKEND_URL} --occ-prefix ${OCC_PREFIX} --base-site ${BASE_SITE} --currency ${CURRENCY} --url-parameters ${URL_PARAMETERS} --no-interactive fi add_feature_libs add_b2b add_cdc add_epd_visualization + add_opf add_product_configurator add_product_multi_dimensional add_quote @@ -237,14 +244,15 @@ function add_spartacus_ssr { fi if [ "$BASE_SITE" = "" ] ; then - ng add @spartacus/schematics@${SPARTACUS_VERSION} --overwrite-app-component --base-url ${BACKEND_URL} --occ-prefix ${OCC_PREFIX} --url-parameters ${URL_PARAMETERS} --ssr --no-interactive --skip-confirmation + ng add @spartacus/schematics@${SPARTACUS_VERSION} --overwrite-app-component --base-url ${BACKEND_URL} --occ-prefix ${OCC_PREFIX} --currency ${CURRENCY} --url-parameters ${URL_PARAMETERS} --ssr --no-interactive --skip-confirmation else - ng add @spartacus/schematics@${SPARTACUS_VERSION} --overwrite-app-component --base-url ${BACKEND_URL} --occ-prefix ${OCC_PREFIX} --base-site ${BASE_SITE} --url-parameters ${URL_PARAMETERS} --ssr --no-interactive --skip-confirmation + ng add @spartacus/schematics@${SPARTACUS_VERSION} --overwrite-app-component --base-url ${BACKEND_URL} --occ-prefix ${OCC_PREFIX} --base-site ${BASE_SITE} --currency ${CURRENCY} --url-parameters ${URL_PARAMETERS} --ssr --no-interactive --skip-confirmation fi add_feature_libs add_b2b add_cdc add_epd_visualization + add_opf add_product_configurator add_product_multi_dimensional add_quote @@ -265,14 +273,15 @@ function add_spartacus_ssr_pwa { create_npmrc ${SSR_PWA_APP_NAME} fi if [ "$BASE_SITE" = "" ] ; then - ng add @spartacus/schematics@${SPARTACUS_VERSION} --overwrite-app-component --base-url ${BACKEND_URL} --occ-prefix ${OCC_PREFIX} --url-parameters ${URL_PARAMETERS} --ssr --pwa --no-interactive --skip-confirmation + ng add @spartacus/schematics@${SPARTACUS_VERSION} --overwrite-app-component --base-url ${BACKEND_URL} --occ-prefix ${OCC_PREFIX} --currency ${CURRENCY} --url-parameters ${URL_PARAMETERS} --ssr --pwa --no-interactive --skip-confirmation else - ng add @spartacus/schematics@${SPARTACUS_VERSION} --overwrite-app-component --base-url ${BACKEND_URL} --occ-prefix ${OCC_PREFIX} --base-site ${BASE_SITE} --url-parameters ${URL_PARAMETERS} --ssr --pwa --no-interactive --skip-confirmation + ng add @spartacus/schematics@${SPARTACUS_VERSION} --overwrite-app-component --base-url ${BACKEND_URL} --occ-prefix ${OCC_PREFIX} --base-site ${BASE_SITE} --currency ${CURRENCY} --url-parameters ${URL_PARAMETERS} --ssr --pwa --no-interactive --skip-confirmation fi add_feature_libs add_b2b add_cdc add_epd_visualization + add_opf add_product_configurator add_product_multi_dimensional add_s4om @@ -839,6 +848,11 @@ function parseInstallArgs { echo "➖ Added PDF Invoices" shift ;; + opf) + ADD_OPF=true + echo "➖ Added OPF" + shift + ;; -*|--*) echo "Unknown option $1" exit 1 diff --git a/tools/schematics/testing.ts b/tools/schematics/testing.ts index a12753a749c..8172b524968 100644 --- a/tools/schematics/testing.ts +++ b/tools/schematics/testing.ts @@ -39,6 +39,7 @@ const integrationLibsFolders: string[] = [ 'digital-payments', 'epd-visualization', 's4om', + 'opf', 'segment-refs', 'opps', 's4-service', @@ -58,6 +59,7 @@ const commands = [ 'build cds/schematics', 'build digital-payments/schematics', 'build epd-visualization/schematics', + 'build opf/schematics', 'build organization/schematics', 'build pdf-invoices/schematics', 'build pickup-in-store/schematics', @@ -217,6 +219,7 @@ async function executeCommand(command: Command): Promise { case 'build cds/schematics': case 'build digital-payments/schematics': case 'build epd-visualization/schematics': + case 'build opf/schematics': case 'build organization/schematics': case 'build pdf-invoices/schematics': case 'build pickup-in-store/schematics': diff --git a/tsconfig.compodoc.json b/tsconfig.compodoc.json index 5c7d06c2701..e25047c7be2 100644 --- a/tsconfig.compodoc.json +++ b/tsconfig.compodoc.json @@ -716,6 +716,90 @@ "@spartacus/omf/root": [ "integration-libs/omf/root/public_api" ], + "@spartacus/opf/base/components": [ + "integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": [ + "integration-libs/opf/base/public_api" + ], + "@spartacus/opf/base/opf-api": [ + "integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": [ + "integration-libs/opf/cta/public_api" + ], + "@spartacus/opf/cta/opf-api": [ + "integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": [ + "integration-libs/opf/public_api" + ], + "@spartacus/opf/payment/assets": [ + "integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": [ "integration-libs/opps/public_api" ], diff --git a/tsconfig.json b/tsconfig.json index cd0aff89ff4..c298410e2ef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -720,6 +720,90 @@ "@spartacus/omf/root": [ "integration-libs/omf/root/public_api" ], + "@spartacus/opf/base/components": [ + "integration-libs/opf/base/components/public_api" + ], + "@spartacus/opf/base/core": [ + "integration-libs/opf/base/core/public_api" + ], + "@spartacus/opf/base": [ + "integration-libs/opf/base/public_api" + ], + "@spartacus/opf/base/opf-api": [ + "integration-libs/opf/base/opf-api/public_api" + ], + "@spartacus/opf/base/root": [ + "integration-libs/opf/base/root/public_api" + ], + "@spartacus/opf/checkout/assets": [ + "integration-libs/opf/checkout/assets/public_api" + ], + "@spartacus/opf/checkout/components": [ + "integration-libs/opf/checkout/components/public_api" + ], + "@spartacus/opf/checkout": [ + "integration-libs/opf/checkout/public_api" + ], + "@spartacus/opf/checkout/root": [ + "integration-libs/opf/checkout/root/public_api" + ], + "@spartacus/opf/cta/components": [ + "integration-libs/opf/cta/components/public_api" + ], + "@spartacus/opf/cta/core": [ + "integration-libs/opf/cta/core/public_api" + ], + "@spartacus/opf/cta": [ + "integration-libs/opf/cta/public_api" + ], + "@spartacus/opf/cta/opf-api": [ + "integration-libs/opf/cta/opf-api/public_api" + ], + "@spartacus/opf/cta/root": [ + "integration-libs/opf/cta/root/public_api" + ], + "@spartacus/opf/global-functions/core": [ + "integration-libs/opf/global-functions/core/public_api" + ], + "@spartacus/opf/global-functions": [ + "integration-libs/opf/global-functions/public_api" + ], + "@spartacus/opf/global-functions/root": [ + "integration-libs/opf/global-functions/root/public_api" + ], + "@spartacus/opf": [ + "integration-libs/opf/public_api" + ], + "@spartacus/opf/payment/assets": [ + "integration-libs/opf/payment/assets/public_api" + ], + "@spartacus/opf/payment/core": [ + "integration-libs/opf/payment/core/public_api" + ], + "@spartacus/opf/payment": [ + "integration-libs/opf/payment/public_api" + ], + "@spartacus/opf/payment/opf-api": [ + "integration-libs/opf/payment/opf-api/public_api" + ], + "@spartacus/opf/payment/root": [ + "integration-libs/opf/payment/root/public_api" + ], + "@spartacus/opf/quick-buy/components": [ + "integration-libs/opf/quick-buy/components/public_api" + ], + "@spartacus/opf/quick-buy/core": [ + "integration-libs/opf/quick-buy/core/public_api" + ], + "@spartacus/opf/quick-buy": [ + "integration-libs/opf/quick-buy/public_api" + ], + "@spartacus/opf/quick-buy/opf-api": [ + "integration-libs/opf/quick-buy/opf-api/public_api" + ], + "@spartacus/opf/quick-buy/root": [ + "integration-libs/opf/quick-buy/root/public_api" + ], "@spartacus/opps": [ "integration-libs/opps/public_api" ],