diff --git a/integration-libs/opf/_index.scss b/integration-libs/opf/_index.scss index 8c04072d73c..c94d32af805 100644 --- a/integration-libs/opf/_index.scss +++ b/integration-libs/opf/_index.scss @@ -9,7 +9,7 @@ $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 !default; + cx-opf-error-modal, cx-opf-cta-element !default; $skipComponentStyles: () !default; diff --git a/integration-libs/opf/base/components/opf-base-components.module.ts b/integration-libs/opf/base/components/opf-base-components.module.ts index 6d45b6e82bc..0ca9ebb6e54 100644 --- a/integration-libs/opf/base/components/opf-base-components.module.ts +++ b/integration-libs/opf/base/components/opf-base-components.module.ts @@ -5,10 +5,11 @@ */ import { NgModule } from '@angular/core'; +import { OpfCtaScriptsModule } from './opf-cta/opf-cta-scripts'; import { OpfErrorModalModule } from './opf-error-modal/opf-error-modal.module'; @NgModule({ - imports: [OpfErrorModalModule], + imports: [OpfErrorModalModule, OpfCtaScriptsModule], providers: [], }) export class OpfBaseComponentsModule {} diff --git a/integration-libs/opf/base/components/opf-cta/opf-cta-element/index.ts b/integration-libs/opf/base/components/opf-cta/opf-cta-element/index.ts new file mode 100644 index 00000000000..50ca90e8760 --- /dev/null +++ b/integration-libs/opf/base/components/opf-cta/opf-cta-element/index.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: 2023 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/base/components/opf-cta/opf-cta-element/opf-cta-element.component.html b/integration-libs/opf/base/components/opf-cta/opf-cta-element/opf-cta-element.component.html new file mode 100644 index 00000000000..6e9ed00faba --- /dev/null +++ b/integration-libs/opf/base/components/opf-cta/opf-cta-element/opf-cta-element.component.html @@ -0,0 +1 @@ +
diff --git a/integration-libs/opf/base/components/opf-cta/opf-cta-element/opf-cta-element.component.spec.ts b/integration-libs/opf/base/components/opf-cta/opf-cta-element/opf-cta-element.component.spec.ts new file mode 100644 index 00000000000..462eceb577f --- /dev/null +++ b/integration-libs/opf/base/components/opf-cta/opf-cta-element/opf-cta-element.component.spec.ts @@ -0,0 +1,19 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { OpfCtaElementComponent } from './opf-cta-element.component'; + +describe('OpfCtaButton', () => { + let component: OpfCtaElementComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [OpfCtaElementComponent], + }); + fixture = TestBed.createComponent(OpfCtaElementComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/integration-libs/opf/base/components/opf-cta/opf-cta-element/opf-cta-element.component.ts b/integration-libs/opf/base/components/opf-cta/opf-cta-element/opf-cta-element.component.ts new file mode 100644 index 00000000000..2fde76ae273 --- /dev/null +++ b/integration-libs/opf/base/components/opf-cta/opf-cta-element/opf-cta-element.component.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +@Component({ + selector: 'cx-opf-cta-element', + templateUrl: './opf-cta-element.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OpfCtaElementComponent { + @Input() ctaScriptHtml: string; +} diff --git a/integration-libs/opf/base/components/opf-cta/opf-cta-element/opf-cta-element.module.ts b/integration-libs/opf/base/components/opf-cta/opf-cta-element/opf-cta-element.module.ts new file mode 100644 index 00000000000..58accaee2db --- /dev/null +++ b/integration-libs/opf/base/components/opf-cta/opf-cta-element/opf-cta-element.module.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SafeHtmlModule } from '@spartacus/storefront'; +import { OpfCtaElementComponent } from './opf-cta-element.component'; + +@NgModule({ + declarations: [OpfCtaElementComponent], + imports: [CommonModule, SafeHtmlModule], + exports: [OpfCtaElementComponent], +}) +export class OpfCtaElementModule {} diff --git a/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/index.ts b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/index.ts new file mode 100644 index 00000000000..694e5ed3841 --- /dev/null +++ b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/index.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2023 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/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.component.html b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.component.html new file mode 100644 index 00000000000..a7578a5c230 --- /dev/null +++ b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.component.html @@ -0,0 +1,13 @@ + + + + + + +
+ +
+
diff --git a/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.component.spec.ts b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.component.spec.ts new file mode 100644 index 00000000000..fb508138263 --- /dev/null +++ b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.component.spec.ts @@ -0,0 +1,78 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +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 = [ + '

Thanks for purchasing our great products

Please use promo code:123abc for your next purchase

', + '

Thanks again for purchasing our great products

Please use promo code:123abc for your next purchase

', +]; +const ctaElementSelector = 'cx-opf-cta-element'; +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], + 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(); + }); + }); + + it('should display spinner when html list is undefined', (done) => { + opfCtaScriptsService.getCtaHtmlslList = createSpy().and.returnValue( + of(undefined) + ); + createComponentInstance(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('cx-spinner')).toBeTruthy(); + done(); + }); +}); diff --git a/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.component.ts b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.component.ts new file mode 100644 index 00000000000..1b7ca592a16 --- /dev/null +++ b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.component.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2023 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/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.module.ts b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.module.ts new file mode 100644 index 00000000000..36e22ceda41 --- /dev/null +++ b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.module.ts @@ -0,0 +1,32 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { + CmsConfig, + FeaturesConfig, + provideDefaultConfig, +} from '@spartacus/core'; +import { SpinnerModule } from '@spartacus/storefront'; +import { OpfCtaElementModule } from '../opf-cta-element'; +import { OpfCtaScriptsComponent } from './opf-cta-scripts.component'; + +@NgModule({ + declarations: [OpfCtaScriptsComponent], + providers: [ + provideDefaultConfig({ + cmsComponents: { + OpfCtaScriptsComponent: { + component: OpfCtaScriptsComponent, + }, + }, + }), + ], + exports: [OpfCtaScriptsComponent], + imports: [CommonModule, OpfCtaElementModule, SpinnerModule], +}) +export class OpfCtaScriptsModule {} diff --git a/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.service.spec.ts b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.service.spec.ts new file mode 100644 index 00000000000..d3c7365ac69 --- /dev/null +++ b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.service.spec.ts @@ -0,0 +1,277 @@ +import { TestBed } from '@angular/core/testing'; +import { CmsService, Page, Product, QueryState } from '@spartacus/core'; +import { + ActiveConfiguration, + CtaScriptsResponse, + OpfPaymentFacade, + OpfPaymentProviderType, + OpfResourceLoaderService, +} from '@spartacus/opf/base/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'; + +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 opfPaymentFacadeMock: 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', + ]); + opfPaymentFacadeMock = jasmine.createSpyObj('OpfPaymentFacade', [ + 'getCtaScripts', + 'getActiveConfigurationsState', + ]); + + 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: OpfPaymentFacade, useValue: opfPaymentFacadeMock }, + ], + }); + service = TestBed.inject(OpfCtaScriptsService); + + 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)); + opfPaymentFacadeMock.getActiveConfigurationsState.and.returnValue( + of(activeConfigurationsMock) + ); + opfPaymentFacadeMock.getCtaScripts.and.returnValue( + of(ctaScriptsresponseMock) + ); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call orderHistoryFacade for CTA on ConfirmationPage', (done) => { + service.getCtaHtmlslList().subscribe((htmlsList) => { + expect(htmlsList[0]).toContain( + 'Thanks for purchasing our great products' + ); + expect(orderHistoryFacadeMock.getOrderDetails).not.toHaveBeenCalled(); + expect(orderFacadeMock.getOrderDetails).toHaveBeenCalled(); + done(); + }); + }); + + it('should call OrderFacade for CTA on PDP', (done) => { + cmsServiceMock.getCurrentPage.and.returnValue( + of({ ...mockPage, pageId: 'order' }) + ); + + service.getCtaHtmlslList().subscribe((htmlsList) => { + expect(htmlsList[0]).toContain( + 'Thanks for purchasing our great products' + ); + expect(orderHistoryFacadeMock.getOrderDetails).toHaveBeenCalled(); + expect(orderFacadeMock.getOrderDetails).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should call currentProductService for CTA on PDP', (done) => { + cmsServiceMock.getCurrentPage.and.returnValue( + of({ ...mockPage, pageId: 'productDetails' }) + ); + + service.getCtaHtmlslList().subscribe((htmlsList) => { + expect(htmlsList[0]).toContain( + 'Thanks for purchasing our great products' + ); + expect(currentProductMock.getProduct).toHaveBeenCalled(); + expect(orderFacadeMock.getOrderDetails).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should throw an error when empty CTA scripts response from OPF server', (done) => { + opfPaymentFacadeMock.getCtaScripts.and.returnValue(of({ value: [] })); + + service.getCtaHtmlslList().subscribe({ + error: (error) => { + expect(error).toEqual('Invalid CTA Scripts Response'); + + done(); + }, + }); + }); + + it('should throw an error when empty ScriptLocation is invalid', (done) => { + cmsServiceMock.getCurrentPage.and.returnValue( + of({ ...mockPage, pageId: 'testPage' }) + ); + + service.getCtaHtmlslList().subscribe({ + error: (error) => { + expect(error).toEqual('Invalid Script Location'); + done(); + }, + }); + }); + + it('should not load html snippet when its associated resource files fail to load', (done) => { + opfResourceLoaderServiceMock.loadProviderResources.and.returnValue( + Promise.reject() + ); + + service.getCtaHtmlslList().subscribe({ + next: (htmlsList) => { + expect(htmlsList.length).toEqual(0); + done(); + }, + }); + }); + + it('should not load html snippet when html returned from server is empty ', (done) => { + opfPaymentFacadeMock.getCtaScripts.and.returnValue( + of({ + ...ctaScriptsresponseMock, + value: [ + { + ...ctaScriptsresponseMock.value[0], + dynamicScript: { + ...ctaScriptsresponseMock.value[0].dynamicScript, + html: '', + }, + }, + ], + }) + ); + + service.getCtaHtmlslList().subscribe({ + next: (htmlsList) => { + expect(htmlsList.length).toEqual(0); + done(); + }, + }); + }); + + it('should remove all script tags from html snippet', (done) => { + opfPaymentFacadeMock.getCtaScripts.and.returnValue( + of({ + ...ctaScriptsresponseMock, + value: [ + { + ...ctaScriptsresponseMock.value[0], + dynamicScript: { + ...ctaScriptsresponseMock.value[0].dynamicScript, + html: "

Thanks for purchasing our great products

Please use promo code:123abc for your next purchase

", + }, + }, + ], + }) + ); + + service.getCtaHtmlslList().subscribe({ + next: (htmlsList) => { + expect(htmlsList[0]).not.toContain('', + 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', + entries: [ + { + product: { + code: '11', + }, + quantity: 1, + }, + { + product: { + code: '22', + }, + quantity: 1, + }, + ], + }; +}); diff --git a/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.service.ts b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.service.ts new file mode 100644 index 00000000000..c859271a078 --- /dev/null +++ b/integration-libs/opf/base/components/opf-cta/opf-cta-scripts/opf-cta-scripts.service.ts @@ -0,0 +1,265 @@ +/* + * SPDX-FileCopyrightText: 2023 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Injectable, inject } from '@angular/core'; +import { CmsService, Product, isNotNullable } from '@spartacus/core'; +import { Order, OrderFacade, OrderHistoryFacade } from '@spartacus/order/root'; +import { Observable, from, of, throwError } from 'rxjs'; +import { + concatMap, + filter, + finalize, + map, + reduce, + switchMap, + take, +} from 'rxjs/operators'; + +import { OrderEntry } from '@spartacus/cart/base/root'; +import { + CmsPageLocation, + CtaScriptsLocation, + CtaScriptsRequest, + CtaScriptsResponse, + OpfDynamicScript, + OpfPaymentFacade, + OpfResourceLoaderService, +} from '@spartacus/opf/base/root'; +import { CurrentProductService } from '@spartacus/storefront'; + +@Injectable({ + providedIn: 'root', +}) +export class OpfCtaScriptsService { + protected opfPaymentFacade = inject(OpfPaymentFacade); + protected orderDetailsService = inject(OrderFacade); + protected orderHistoryService = inject(OrderHistoryFacade); + protected opfResourceLoaderService = inject(OpfResourceLoaderService); + protected cmsService = inject(CmsService); + protected currentProductService = inject(CurrentProductService); + + getCtaHtmlslList(): Observable { + return this.fillCtaScriptRequest().pipe( + switchMap((ctaScriptsRequest) => this.fetchCtaScripts(ctaScriptsRequest)), + switchMap((scriptslist) => this.runCtaScripts(scriptslist)), + finalize(() => { + this.clearResources(); + }) + ); + } + + protected clearResources() { + this.opfResourceLoaderService.clearAllProviderResources(); + } + + protected fetchCtaScripts( + ctaScriptsRequest: CtaScriptsRequest + ): Observable { + return this.opfPaymentFacade.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() { + 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 toBeImplementedException = () => throwError('to be implemented'); + const locationToFunctionMap: Record< + CtaScriptsLocation, + () => Observable + > = { + [CtaScriptsLocation.PDP_QUICK_BUY]: () => + this.fillCtaRequestforPDP(scriptsLocation, paymentAccountIds), + [CtaScriptsLocation.ORDER_HISTORY_PAYMENT_GUIDE]: () => + this.fillCtaRequestforPagesWithOrder( + scriptsLocation, + paymentAccountIds + ), + [CtaScriptsLocation.ORDER_CONFIRMATION_PAYMENT_GUIDE]: () => + this.fillCtaRequestforPagesWithOrder( + scriptsLocation, + paymentAccountIds + ), + [CtaScriptsLocation.CART_MESSAGING]: toBeImplementedException, + [CtaScriptsLocation.CART_QUICK_BUY]: toBeImplementedException, + [CtaScriptsLocation.CHECKOUT_QUICK_BUY]: toBeImplementedException, + [CtaScriptsLocation.PDP_MESSAGING]: toBeImplementedException, + }; + + const selectedFunction = locationToFunctionMap[scriptsLocation]; + + return selectedFunction + ? selectedFunction() + : throwError('Invalid Script Location'); + } + + protected fillCtaRequestforPagesWithOrder( + scriptLocation: CtaScriptsLocation, + paymentAccountIds: number[] + ): Observable { + return this.getOrderDetails(scriptLocation).pipe( + map((order) => { + const ctaScriptsRequest: CtaScriptsRequest = { + orderId: order?.code, + ctaProductItems: this.getProductItems(order as Order), + paymentAccountIds: paymentAccountIds, + scriptLocations: [scriptLocation], + }; + + return ctaScriptsRequest; + }) + ); + } + + protected fillCtaRequestforPDP( + scriptLocation: CtaScriptsLocation, + paymentAccountIds: number[] + ) { + return this.currentProductService.getProduct().pipe( + filter(isNotNullable), + map((product: Product) => { + return { + orderId: undefined, + ctaProductItems: [{ productId: product?.code, quantity: 1 }], + paymentAccountIds: paymentAccountIds, + scriptLocations: [scriptLocation], + } as CtaScriptsRequest; + }) + ); + } + + protected runCtaScripts(scripts: OpfDynamicScript[]) { + return from(scripts).pipe( + concatMap((script) => from(this.loadAndRunScript(script))), + reduce((loadedList: string[], script) => { + if (script?.html) { + loadedList.push(script.html); + } + return loadedList; + }, []), + map((list) => { + return this.removeScriptTags(list); + }) + ); + } + + 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_QUICK_BUY, + [CmsPageLocation.CART_PAGE]: CtaScriptsLocation.CART_QUICK_BUY, + }; + return this.cmsService.getCurrentPage().pipe( + take(1), + map((page) => + page.pageId + ? cmsToCtaLocationMap[page.pageId as CmsPageLocation] + : undefined + ) + ); + } + + protected getOrderDetails(scriptsLocation: CtaScriptsLocation) { + const order$ = + scriptsLocation === CtaScriptsLocation.ORDER_CONFIRMATION_PAYMENT_GUIDE + ? this.orderDetailsService.getOrderDetails() + : this.orderHistoryService.getOrderDetails(); + return order$.pipe( + filter((order) => !!order?.entries) + ) as Observable; + } + + protected getPaymentAccountIds() { + return this.opfPaymentFacade.getActiveConfigurationsState().pipe( + filter( + (state) => !state.loading && !state.error && Boolean(state.data?.length) + ), + map((state) => state.data?.map((val) => val.id) as number[]) + ); + } + + 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, + }; + }); + } + + protected 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); + }); + } + ); + } + + protected removeScriptTags(htmls: string[]) { + return htmls.map((html) => { + 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/base/core/connectors/opf-payment.adapter.ts b/integration-libs/opf/base/core/connectors/opf-payment.adapter.ts index a7f2473c223..7f4df58539f 100644 --- a/integration-libs/opf/base/core/connectors/opf-payment.adapter.ts +++ b/integration-libs/opf/base/core/connectors/opf-payment.adapter.ts @@ -7,6 +7,8 @@ import { ActiveConfiguration, AfterRedirectScriptResponse, + CtaScriptsRequest, + CtaScriptsResponse, OpfPaymentVerificationPayload, OpfPaymentVerificationResponse, SubmitCompleteRequest, @@ -46,6 +48,9 @@ export abstract class OpfPaymentAdapter { paymentSessionId: string ): Observable; + /** + * Abstract method used to get AfterRedirect scripts used in hosted-fields pattern + */ abstract afterRedirectScripts( paymentSessionId: string ): Observable; @@ -54,4 +59,11 @@ export abstract class OpfPaymentAdapter { * Abstract method used to get payment active configurations */ abstract getActiveConfigurations(): Observable; + + /** + * Abstract method used to get CTA scripts list, used by QuickBuy functionality + */ + abstract getCtaScripts( + ctaScriptsRequest: CtaScriptsRequest + ): Observable; } diff --git a/integration-libs/opf/base/core/connectors/opf-payment.connector.spec.ts b/integration-libs/opf/base/core/connectors/opf-payment.connector.spec.ts index a0f5f9568df..ee9123aa0a8 100644 --- a/integration-libs/opf/base/core/connectors/opf-payment.connector.spec.ts +++ b/integration-libs/opf/base/core/connectors/opf-payment.connector.spec.ts @@ -11,6 +11,7 @@ class MockOpfPaymentAdapter implements OpfPaymentAdapter { submitCompletePayment = createSpy().and.returnValue(of({})); afterRedirectScripts = createSpy().and.returnValue(of({})); getActiveConfigurations = createSpy().and.returnValue(of({})); + getCtaScripts = createSpy().and.returnValue(of({})); } describe('OpfPaymentConnector', () => { @@ -67,4 +68,10 @@ describe('OpfPaymentConnector', () => { done(); }); }); + it('getCtaScripts should call adapter', (done) => { + service.getCtaScripts({}).subscribe(() => { + expect(adapter.getCtaScripts).toHaveBeenCalled(); + done(); + }); + }); }); diff --git a/integration-libs/opf/base/core/connectors/opf-payment.connector.ts b/integration-libs/opf/base/core/connectors/opf-payment.connector.ts index 44ea553aa01..cb84e3c077e 100644 --- a/integration-libs/opf/base/core/connectors/opf-payment.connector.ts +++ b/integration-libs/opf/base/core/connectors/opf-payment.connector.ts @@ -8,6 +8,8 @@ import { Injectable } from '@angular/core'; import { ActiveConfiguration, AfterRedirectScriptResponse, + CtaScriptsRequest, + CtaScriptsResponse, OpfPaymentVerificationPayload, OpfPaymentVerificationResponse, SubmitCompleteRequest, @@ -59,4 +61,10 @@ export class OpfPaymentConnector { public getActiveConfigurations(): Observable { return this.adapter.getActiveConfigurations(); } + + public getCtaScripts( + ctaScriptsRequest: CtaScriptsRequest + ): Observable { + return this.adapter.getCtaScripts(ctaScriptsRequest); + } } diff --git a/integration-libs/opf/base/core/facade/opf-payment.service.spec.ts b/integration-libs/opf/base/core/facade/opf-payment.service.spec.ts index 8a07d8c3665..86ee052aa43 100644 --- a/integration-libs/opf/base/core/facade/opf-payment.service.spec.ts +++ b/integration-libs/opf/base/core/facade/opf-payment.service.spec.ts @@ -10,6 +10,9 @@ import { Observable, of } from 'rxjs'; import { ActiveConfiguration, AfterRedirectScriptResponse, + CtaScriptsLocation, + CtaScriptsRequest, + CtaScriptsResponse, OpfPaymentProviderType, OpfPaymentVerificationPayload, OpfPaymentVerificationResponse, @@ -22,23 +25,26 @@ import { OpfPaymentService } from './opf-payment.service'; class MockPaymentConnector implements Partial { verifyPayment( - paymentSessionId: string, - payload: OpfPaymentVerificationPayload + _paymentSessionId: string, + _payload: OpfPaymentVerificationPayload ): Observable { - console.log(paymentSessionId, payload); return of({ result: 'result', }) as Observable; } afterRedirectScripts( - paymentSessionId: string + _paymentSessionId: string ): Observable { - console.log(paymentSessionId); return of({ afterRedirectScript: {} }); } getActiveConfigurations(): Observable { return of(mockActiveConfigurations); } + getCtaScripts( + _ctaScriptsRequest: CtaScriptsRequest + ): Observable { + return of(MockCtaScriptsResponse); + } } class MockOpfPaymentHostedFieldsService { @@ -68,6 +74,48 @@ const mockActiveConfigurations: ActiveConfiguration[] = [ }, ]; +const MockCtaRequest: CtaScriptsRequest = { + orderId: '00259012', + ctaProductItems: [ + { + productId: '301233', + quantity: 1, + }, + { + productId: '2231913', + quantity: 1, + }, + { + productId: '1776948', + quantity: 1, + }, + ], + scriptLocations: [CtaScriptsLocation.ORDER_HISTORY_PAYMENT_GUIDE], +}; + +const MockCtaScriptsResponse: CtaScriptsResponse = { + value: [ + { + paymentAccountId: 1, + dynamicScript: { + html: "

CTA Html snippet #1

", + 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: '', + }, + ], + }, + }, + ], +}; + describe('OpfPaymentService', () => { let service: OpfPaymentService; let paymentConnector: MockPaymentConnector; @@ -254,16 +302,26 @@ describe('OpfPaymentService', () => { expect(connectorSpy).toHaveBeenCalledWith(paymentSessionId); }); - describe(`getActiveConfigurationsState`, () => { - it(`should return mockActiveConfigurations data`, (done) => { - service.getActiveConfigurationsState().subscribe((state) => { - expect(state).toEqual({ - loading: false, - error: false, - data: mockActiveConfigurations, - }); - done(); + it(`should return mockActiveConfigurations data`, (done) => { + service.getActiveConfigurationsState().subscribe((state) => { + expect(state).toEqual({ + loading: false, + error: false, + data: mockActiveConfigurations, }); + done(); + }); + }); + + it(`should return ctaScripts data`, (done) => { + const connectorCtaSpy = spyOn( + paymentConnector, + 'getCtaScripts' + ).and.callThrough(); + + service.getCtaScripts(MockCtaRequest).subscribe(() => { + expect(connectorCtaSpy).toHaveBeenCalledWith(MockCtaRequest); + done(); }); }); }); diff --git a/integration-libs/opf/base/core/facade/opf-payment.service.ts b/integration-libs/opf/base/core/facade/opf-payment.service.ts index 471a22507a7..cd7ddc5dafd 100644 --- a/integration-libs/opf/base/core/facade/opf-payment.service.ts +++ b/integration-libs/opf/base/core/facade/opf-payment.service.ts @@ -15,6 +15,8 @@ import { import { ActiveConfiguration, AfterRedirectScriptResponse, + CtaScriptsRequest, + CtaScriptsResponse, OpfPaymentFacade, OpfPaymentVerificationPayload, OpfPaymentVerificationResponse, @@ -73,6 +75,15 @@ export class OpfPaymentService implements OpfPaymentFacade { ); }); + protected ctaScriptsCommand: Command< + { + ctaScriptsRequest: CtaScriptsRequest; + }, + CtaScriptsResponse + > = this.commandService.create((payload) => { + return this.opfPaymentConnector.getCtaScripts(payload.ctaScriptsRequest); + }); + protected activeConfigurationsQuery: Query = this.queryService.create(() => this.opfPaymentConnector.getActiveConfigurations() @@ -116,4 +127,8 @@ export class OpfPaymentService implements OpfPaymentFacade { > { return this.activeConfigurationsQuery.getState(); } + + getCtaScripts(ctaScriptsRequest: CtaScriptsRequest) { + return this.ctaScriptsCommand.execute({ ctaScriptsRequest }); + } } diff --git a/integration-libs/opf/base/core/tokens/tokens.ts b/integration-libs/opf/base/core/tokens/tokens.ts index 4d4ecb17827..140d71d058f 100644 --- a/integration-libs/opf/base/core/tokens/tokens.ts +++ b/integration-libs/opf/base/core/tokens/tokens.ts @@ -9,6 +9,7 @@ import { Converter } from '@spartacus/core'; import { ActiveConfiguration, AfterRedirectScriptResponse, + CtaScriptsResponse, OpfPaymentVerificationResponse, SubmitCompleteResponse, SubmitResponse, @@ -33,3 +34,7 @@ export const OPF_AFTER_REDIRECT_SCRIPTS_NORMALIZER = new InjectionToken< export const OPF_ACTIVE_CONFIGURATION_NORMALIZER = new InjectionToken< Converter >('OpfActiveConfigurationNormalizer'); + +export const OPF_CTA_SCRIPTS_NORMALIZER = new InjectionToken< + Converter +>('OpfCtaScriptsNormalizer'); diff --git a/integration-libs/opf/base/occ/adapters/occ-opf.adapter.ts b/integration-libs/opf/base/occ/adapters/occ-opf.adapter.ts index d6cf98a3714..bf57723b869 100644 --- a/integration-libs/opf/base/occ/adapters/occ-opf.adapter.ts +++ b/integration-libs/opf/base/occ/adapters/occ-opf.adapter.ts @@ -7,28 +7,31 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { - backOff, ConverterService, + backOff, isJaloError, normalizeHttpError, } from '@spartacus/core'; import { - OpfEndpointsService, - OpfPaymentAdapter, OPF_ACTIVE_CONFIGURATION_NORMALIZER, OPF_AFTER_REDIRECT_SCRIPTS_NORMALIZER, + OPF_CTA_SCRIPTS_NORMALIZER, OPF_PAYMENT_SUBMIT_COMPLETE_NORMALIZER, OPF_PAYMENT_SUBMIT_NORMALIZER, OPF_PAYMENT_VERIFICATION_NORMALIZER, + OpfEndpointsService, + OpfPaymentAdapter, } from '@spartacus/opf/base/core'; import { ActiveConfiguration, AfterRedirectScriptResponse, + CtaScriptsRequest, + CtaScriptsResponse, + OPF_CC_OTP_KEY, + OPF_CC_PUBLIC_KEY, OpfConfig, OpfPaymentVerificationPayload, OpfPaymentVerificationResponse, - OPF_CC_OTP_KEY, - OPF_CC_PUBLIC_KEY, SubmitCompleteRequest, SubmitCompleteResponse, SubmitRequest, @@ -165,7 +168,7 @@ export class OccOpfPaymentAdapter implements OpfPaymentAdapter { } getActiveConfigurations(): Observable { - const headers = new HttpHeaders().set( + const headers = new HttpHeaders(this.header).set( OPF_CC_PUBLIC_KEY, this.config.opf?.commerceCloudPublicKey || '' ); @@ -183,6 +186,31 @@ export class OccOpfPaymentAdapter implements OpfPaymentAdapter { ); } + getCtaScripts( + ctaScriptsRequest: CtaScriptsRequest + ): Observable { + const headers = new HttpHeaders(this.header).set( + OPF_CC_PUBLIC_KEY, + this.config.opf?.commerceCloudPublicKey || '' + ); + + const url = this.getCtaScriptsEndpoint(); + + return this.http + .post(url, ctaScriptsRequest, { headers }) + .pipe( + catchError((error) => throwError(normalizeHttpError(error))), + backOff({ + shouldRetry: isJaloError, + }), + backOff({ + shouldRetry: isHttp500Error, + maxTries: 2, + }), + this.converter.pipeable(OPF_CTA_SCRIPTS_NORMALIZER) + ); + } + protected verifyPaymentEndpoint(paymentSessionId: string): string { return this.opfEndpointsService.buildUrl('verifyPayment', { urlParams: { paymentSessionId }, @@ -210,4 +238,8 @@ export class OccOpfPaymentAdapter implements OpfPaymentAdapter { protected getActiveConfigurationsEndpoint(): string { return this.opfEndpointsService.buildUrl('getActiveConfigurations'); } + + protected getCtaScriptsEndpoint(): string { + return this.opfEndpointsService.buildUrl('getCtaScripts'); + } } diff --git a/integration-libs/opf/base/occ/config/default-occ-opf-config.ts b/integration-libs/opf/base/occ/config/default-occ-opf-config.ts index 042f6fad182..7f53b1bc3e3 100644 --- a/integration-libs/opf/base/occ/config/default-occ-opf-config.ts +++ b/integration-libs/opf/base/occ/config/default-occ-opf-config.ts @@ -16,6 +16,7 @@ export const defaultOccOpfConfig: OccConfig = { afterRedirectScripts: 'payments/${paymentSessionId}/after-redirect-scripts', getActiveConfigurations: 'active-configurations', + getCtaScripts: 'payments/cta-scripts-rendering', }, }, }, diff --git a/integration-libs/opf/base/occ/model/occ-opf-endpoints.model.ts b/integration-libs/opf/base/occ/model/occ-opf-endpoints.model.ts index 8ec11ff5fb8..b38a21bff55 100644 --- a/integration-libs/opf/base/occ/model/occ-opf-endpoints.model.ts +++ b/integration-libs/opf/base/occ/model/occ-opf-endpoints.model.ts @@ -29,5 +29,9 @@ declare module '@spartacus/core' { * Endpoint to get active payment configurations */ getActiveConfigurations?: string | OccEndpoint; + /** + * Endpoint to get CTA (Call To Action) Scripts used in QuickBuy functionality + */ + getCtaScripts?: string | OccEndpoint; } } diff --git a/integration-libs/opf/base/root/components/opf-payment-verification/opf-payment-verification.service.spec.ts b/integration-libs/opf/base/root/components/opf-payment-verification/opf-payment-verification.service.spec.ts index 0c4d6e8badf..1ddded30dc1 100644 --- a/integration-libs/opf/base/root/components/opf-payment-verification/opf-payment-verification.service.spec.ts +++ b/integration-libs/opf/base/root/components/opf-payment-verification/opf-payment-verification.service.spec.ts @@ -21,8 +21,8 @@ import { OpfPaymentFacade, } from '../../facade'; import { - AfterRedirectDynamicScript, GlobalFunctionsDomain, + OpfDynamicScript, OpfPaymentMetadata, OpfPaymentVerificationResponse, OpfPaymentVerificationResult, @@ -287,7 +287,7 @@ describe('OpfPaymentVerificationService', () => { }); describe('runHostedFieldsPattern', () => { - const dynamicScriptMock: AfterRedirectDynamicScript = { + const dynamicScriptMock: OpfDynamicScript = { cssUrls: [{ url: 'css url test', sri: 'css sri test' }], jsUrls: [{ url: 'js url test', sri: 'js sri test' }], html: 'html test', diff --git a/integration-libs/opf/base/root/components/opf-payment-verification/opf-payment-verification.service.ts b/integration-libs/opf/base/root/components/opf-payment-verification/opf-payment-verification.service.ts index b43033d12be..60189d25e45 100644 --- a/integration-libs/opf/base/root/components/opf-payment-verification/opf-payment-verification.service.ts +++ b/integration-libs/opf/base/root/components/opf-payment-verification/opf-payment-verification.service.ts @@ -27,9 +27,9 @@ import { OpfPaymentVerificationResult, } from '../../model'; import { - AfterRedirectDynamicScript, GlobalFunctionsDomain, KeyValuePair, + OpfDynamicScript, OpfPage, OpfPaymentMetadata, } from '../../model/opf.model'; @@ -221,7 +221,7 @@ export class OpfPaymentVerificationService { } protected renderAfterRedirectScripts( - script: AfterRedirectDynamicScript + script: OpfDynamicScript ): Promise { const html = script?.html; diff --git a/integration-libs/opf/base/root/facade/opf-payment.facade.ts b/integration-libs/opf/base/root/facade/opf-payment.facade.ts index 3519cd84371..8049d7cecff 100644 --- a/integration-libs/opf/base/root/facade/opf-payment.facade.ts +++ b/integration-libs/opf/base/root/facade/opf-payment.facade.ts @@ -11,6 +11,8 @@ import { OPF_BASE_FEATURE } from '../feature-name'; import { ActiveConfiguration, AfterRedirectScriptResponse, + CtaScriptsRequest, + CtaScriptsResponse, OpfPaymentVerificationPayload, OpfPaymentVerificationResponse, SubmitCompleteInput, @@ -29,6 +31,7 @@ import { 'submitCompletePayment', 'afterRedirectScripts', 'getActiveConfigurationsState', + 'getCtaScripts', ], }), }) @@ -75,4 +78,8 @@ export abstract class OpfPaymentFacade { abstract getActiveConfigurationsState(): Observable< QueryState >; + + abstract getCtaScripts( + ctaScriptsRequest: CtaScriptsRequest + ): Observable; } diff --git a/integration-libs/opf/base/root/model/opf-quick-buy.model.ts b/integration-libs/opf/base/root/model/opf-quick-buy.model.ts index e3a868de653..758bc9e3700 100644 --- a/integration-libs/opf/base/root/model/opf-quick-buy.model.ts +++ b/integration-libs/opf/base/root/model/opf-quick-buy.model.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { OpfDynamicScript } from './opf.model'; + export interface DigitalWalletQuickBuy { description?: string; provider?: OpfProviderType; @@ -17,3 +19,53 @@ export enum OpfProviderType { APPLE_PAY = 'APPLE_PAY', GOOGLE_PAY = 'GOOGLE_PAY', } + +export type CtaAdditionalDataKey = + | 'divisionId' + | 'experienceId' + | 'currency' + | 'fulfillmentLocationId' + | 'locale' + | 'scriptIdentifier'; +export interface CtaScriptsRequest { + paymentAccountIds?: Array; + orderId?: 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', + PDP_QUICK_BUY = 'PDP_QUICK_BUY', + CART_QUICK_BUY = 'CART_QUICK_BUY', + CHECKOUT_QUICK_BUY = 'CHECKOUT_QUICK_BUY', + ORDER_CONFIRMATION_PAYMENT_GUIDE = 'ORDER_CONFIRMATION_PAYMENT_GUIDE', + ORDER_HISTORY_PAYMENT_GUIDE = 'ORDER_HISTORY_PAYMENT_GUIDE', +} + +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; +} diff --git a/integration-libs/opf/base/root/model/opf.model.ts b/integration-libs/opf/base/root/model/opf.model.ts index 2e54eff7776..805dff0d098 100644 --- a/integration-libs/opf/base/root/model/opf.model.ts +++ b/integration-libs/opf/base/root/model/opf.model.ts @@ -133,23 +133,23 @@ export interface SubmitCompleteInput { } export interface AfterRedirectScriptResponse { - afterRedirectScript: AfterRedirectDynamicScript; + afterRedirectScript: OpfDynamicScript; } -export interface AfterRedirectDynamicScript { - cssUrls?: AfterRedirectDynamicScriptResource[]; - jsUrls?: AfterRedirectDynamicScriptResource[]; +export interface OpfDynamicScript { + cssUrls?: OpfDynamicScriptResource[]; + jsUrls?: OpfDynamicScriptResource[]; html?: string; } -export interface AfterRedirectDynamicScriptResource { +export interface OpfDynamicScriptResource { url?: string; sri?: string; attributes?: KeyValuePair[]; - type?: AfterRedirectDynamicScriptResourceType; + type?: OpfDynamicScriptResourceType; } -export enum AfterRedirectDynamicScriptResourceType { +export enum OpfDynamicScriptResourceType { SCRIPT = 'SCRIPT', STYLES = 'STYLES', } diff --git a/integration-libs/opf/base/root/opf-base-root.module.ts b/integration-libs/opf/base/root/opf-base-root.module.ts index ee5e896f7d9..7e6972ab2d8 100644 --- a/integration-libs/opf/base/root/opf-base-root.module.ts +++ b/integration-libs/opf/base/root/opf-base-root.module.ts @@ -7,15 +7,18 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { + CmsConfig, MODULE_INITIALIZER, provideConfigValidator, provideDefaultConfig, + provideDefaultConfigFactory, } from '@spartacus/core'; import { OpfPaymentVerificationComponent } from './components/opf-payment-verification'; import { defaultOpfRoutingConfig } from './config'; import { defaultOpfConfig } from './config/default-opf-config'; import { opfConfigValidator } from './config/opf-config-validator'; import { OpfEventModule } from './events/opf-event.module'; +import { OPF_BASE_FEATURE } from './feature-name'; import { OpfStatePersistenceService } from './services/opf-state-persistence.service'; export function opfStatePersistenceFactory( @@ -24,6 +27,17 @@ export function opfStatePersistenceFactory( return () => opfStatePersistenceService.initSync(); } +export function defaultOpfCtaScriptsComponentsConfig(): CmsConfig { + const config: CmsConfig = { + featureModules: { + [OPF_BASE_FEATURE]: { + cmsComponents: ['OpfCtaScriptsComponent'], + }, + }, + }; + return config; +} + @NgModule({ imports: [ RouterModule.forChild([ @@ -58,6 +72,7 @@ export function opfStatePersistenceFactory( // TODO OPF: uncomment once proper type and routing is set up provideDefaultConfig(defaultOpfRoutingConfig), provideConfigValidator(opfConfigValidator), + provideDefaultConfigFactory(defaultOpfCtaScriptsComponentsConfig), ], }) export class OpfBaseRootModule {} 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 index 81143acad39..0a9d7d59456 100644 --- 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 @@ -2,7 +2,7 @@ import { DOCUMENT } from '@angular/common'; import { PLATFORM_ID } from '@angular/core'; import { TestBed, fakeAsync } from '@angular/core/testing'; import { ScriptLoader } from '@spartacus/core'; -import { AfterRedirectDynamicScriptResourceType } from '../model'; +import { OpfDynamicScriptResourceType } from '../model'; import { OpfResourceLoaderService } from './opf-resource-loader.service'; describe('OpfResourceLoaderService', () => { @@ -54,12 +54,12 @@ describe('OpfResourceLoaderService', () => { it('should load provider resources successfully for both scripts and styles', fakeAsync(() => { const mockScriptResource = { url: 'script-url', - type: AfterRedirectDynamicScriptResourceType.SCRIPT, + type: OpfDynamicScriptResourceType.SCRIPT, }; const mockStyleResource = { url: 'style-url', - type: AfterRedirectDynamicScriptResourceType.STYLES, + type: OpfDynamicScriptResourceType.STYLES, }; spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); @@ -77,7 +77,7 @@ describe('OpfResourceLoaderService', () => { it('should load provider resources successfully for scripts', fakeAsync(() => { const mockScriptResource = { url: 'script-url', - type: AfterRedirectDynamicScriptResourceType.SCRIPT, + type: OpfDynamicScriptResourceType.SCRIPT, }; spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); @@ -92,7 +92,7 @@ describe('OpfResourceLoaderService', () => { it('should load provider resources successfully for styles', fakeAsync(() => { const mockStyleResource = { url: 'style-url', - type: AfterRedirectDynamicScriptResourceType.STYLES, + type: OpfDynamicScriptResourceType.STYLES, }; spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); @@ -106,7 +106,7 @@ describe('OpfResourceLoaderService', () => { it('should load provider resources successfully for styles with no url', fakeAsync(() => { const mockStyleResource = { - type: AfterRedirectDynamicScriptResourceType.STYLES, + type: OpfDynamicScriptResourceType.STYLES, }; spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); @@ -145,7 +145,7 @@ describe('OpfResourceLoaderService', () => { it('should mark resource as loaded when script is successfully loaded', fakeAsync(() => { const mockScriptResource = { url: 'script-url', - type: AfterRedirectDynamicScriptResourceType.SCRIPT, + type: OpfDynamicScriptResourceType.SCRIPT, }; spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); @@ -173,7 +173,7 @@ describe('OpfResourceLoaderService', () => { it('should handle resource loading error when script is not successfully loaded', fakeAsync(() => { const mockScriptResource = { url: 'script-url', - type: AfterRedirectDynamicScriptResourceType.SCRIPT, + type: OpfDynamicScriptResourceType.SCRIPT, }; spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); @@ -208,7 +208,7 @@ describe('OpfResourceLoaderService', () => { it('should mark resource as loaded when style is successfully loaded', fakeAsync(() => { const mockStylesResources = { url: 'style-url', - type: AfterRedirectDynamicScriptResourceType.STYLES, + type: OpfDynamicScriptResourceType.STYLES, }; spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); @@ -236,7 +236,7 @@ describe('OpfResourceLoaderService', () => { it('should handle resource loading error when style is not successfully loaded', fakeAsync(() => { const mockStylesResources = { url: 'style-url', - type: AfterRedirectDynamicScriptResourceType.STYLES, + type: OpfDynamicScriptResourceType.STYLES, }; spyOn(opfResourceLoaderService, 'loadScript').and.callThrough(); @@ -271,7 +271,7 @@ describe('OpfResourceLoaderService', () => { it('should not embed styles if there is no style in the element', fakeAsync(() => { const mockStyleResource = { url: 'style-url', - type: AfterRedirectDynamicScriptResourceType.STYLES, + type: OpfDynamicScriptResourceType.STYLES, }; spyOn(opfResourceLoaderService, 'embedStyles').and.callThrough(); @@ -294,7 +294,7 @@ describe('OpfResourceLoaderService', () => { it('should not embed script if there is no script in the element', fakeAsync(() => { const mockScriptResource = { url: 'script-url', - type: AfterRedirectDynamicScriptResourceType.SCRIPT, + type: OpfDynamicScriptResourceType.SCRIPT, }; spyOn(opfResourceLoaderService, 'embedScript').and.callThrough(); @@ -324,7 +324,7 @@ describe('OpfResourceLoaderService', () => { it('should embed styles with SSR when platform is set to server', fakeAsync(() => { const mockStyleResource = { url: 'style-url', - type: AfterRedirectDynamicScriptResourceType.STYLES, + type: OpfDynamicScriptResourceType.STYLES, }; spyOn(opfResourceLoaderService, 'embedStyles').and.callThrough(); 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 index 823c1407512..db3df6f9631 100644 --- a/integration-libs/opf/base/root/services/opf-resource-loader.service.ts +++ b/integration-libs/opf/base/root/services/opf-resource-loader.service.ts @@ -10,8 +10,8 @@ import { ScriptLoader } from '@spartacus/core'; import { throwError } from 'rxjs'; import { - AfterRedirectDynamicScriptResource, - AfterRedirectDynamicScriptResourceType, + OpfDynamicScriptResource, + OpfDynamicScriptResourceType, } from '../model'; @Injectable({ @@ -27,7 +27,7 @@ export class OpfResourceLoaderService extends ScriptLoader { protected readonly OPF_RESOURCE_ATTRIBUTE_KEY = 'data-opf-resource'; - protected loadedResources: AfterRedirectDynamicScriptResource[] = []; + protected loadedResources: OpfDynamicScriptResource[] = []; protected embedStyles(embedOptions: { src: string; @@ -71,15 +71,13 @@ export class OpfResourceLoaderService extends ScriptLoader { return throwError(`Error while loading external ${src} resource.`); } - protected isResourceLoadingCompleted( - resources: AfterRedirectDynamicScriptResource[] - ) { + protected isResourceLoadingCompleted(resources: OpfDynamicScriptResource[]) { return resources.length === this.loadedResources.length; } protected markResourceAsLoaded( - resource: AfterRedirectDynamicScriptResource, - resources: AfterRedirectDynamicScriptResource[], + resource: OpfDynamicScriptResource, + resources: OpfDynamicScriptResource[], resolve: (value: void | PromiseLike) => void ) { this.loadedResources.push(resource); @@ -89,8 +87,8 @@ export class OpfResourceLoaderService extends ScriptLoader { } protected loadScript( - resource: AfterRedirectDynamicScriptResource, - resources: AfterRedirectDynamicScriptResource[], + resource: OpfDynamicScriptResource, + resources: OpfDynamicScriptResource[], resolve: (value: void | PromiseLike) => void ) { if (resource.url && !this.hasScript(resource.url)) { @@ -101,8 +99,12 @@ export class OpfResourceLoaderService extends ScriptLoader { [this.OPF_RESOURCE_ATTRIBUTE_KEY]: true, }, - callback: () => this.markResourceAsLoaded(resource, resources, resolve), - errorCallback: () => this.handleLoadingResourceError(resource.url), + callback: () => { + this.markResourceAsLoaded(resource, resources, resolve); + }, + errorCallback: () => { + this.handleLoadingResourceError(resource.url); + }, }); } else { this.markResourceAsLoaded(resource, resources, resolve); @@ -110,15 +112,17 @@ export class OpfResourceLoaderService extends ScriptLoader { } protected loadStyles( - resource: AfterRedirectDynamicScriptResource, - resources: AfterRedirectDynamicScriptResource[], + 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(resource.url), + errorCallback: () => { + this.handleLoadingResourceError(resource.url); + }, }); } else { this.markResourceAsLoaded(resource, resources, resolve); @@ -144,32 +148,32 @@ export class OpfResourceLoaderService extends ScriptLoader { } loadProviderResources( - scripts: AfterRedirectDynamicScriptResource[] = [], - styles: AfterRedirectDynamicScriptResource[] = [] + scripts: OpfDynamicScriptResource[] = [], + styles: OpfDynamicScriptResource[] = [] ): Promise { - const resources: AfterRedirectDynamicScriptResource[] = [ + const resources: OpfDynamicScriptResource[] = [ ...scripts.map((script) => ({ ...script, - type: AfterRedirectDynamicScriptResourceType.SCRIPT, + type: OpfDynamicScriptResourceType.SCRIPT, })), ...styles.map((style) => ({ ...style, - type: AfterRedirectDynamicScriptResourceType.STYLES, + type: OpfDynamicScriptResourceType.STYLES, })), ]; return new Promise((resolve) => { this.loadedResources = []; - resources.forEach((resource: AfterRedirectDynamicScriptResource) => { + resources.forEach((resource: OpfDynamicScriptResource) => { if (!resource.url) { this.markResourceAsLoaded(resource, resources, resolve); } else { switch (resource.type) { - case AfterRedirectDynamicScriptResourceType.SCRIPT: + case OpfDynamicScriptResourceType.SCRIPT: this.loadScript(resource, resources, resolve); break; - case AfterRedirectDynamicScriptResourceType.STYLES: + case OpfDynamicScriptResourceType.STYLES: this.loadStyles(resource, resources, resolve); break; default: diff --git a/integration-libs/opf/base/styles/components/_index.scss b/integration-libs/opf/base/styles/components/_index.scss index f79abba97cd..7fab0bcd06e 100644 --- a/integration-libs/opf/base/styles/components/_index.scss +++ b/integration-libs/opf/base/styles/components/_index.scss @@ -1 +1,2 @@ @import './opf-error-modal'; +@import './opf-cta-element'; diff --git a/integration-libs/opf/base/styles/components/_opf-cta-element.scss b/integration-libs/opf/base/styles/components/_opf-cta-element.scss new file mode 100644 index 00000000000..bf6c20ed03d --- /dev/null +++ b/integration-libs/opf/base/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/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 index f64fa00a33c..88a7b2bef63 100644 --- 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 @@ -7,7 +7,7 @@ import { UserIdService, } from '@spartacus/core'; import { - AfterRedirectDynamicScriptResourceType, + OpfDynamicScriptResourceType, OpfOrderFacade, OpfOtpFacade, OpfResourceLoaderService, @@ -118,13 +118,13 @@ describe('OpfCheckoutPaymentWrapperService', () => { jsUrls: [ { url: 'script.js', - type: AfterRedirectDynamicScriptResourceType.SCRIPT, + type: OpfDynamicScriptResourceType.SCRIPT, }, ], cssUrls: [ { url: 'styles.css', - type: AfterRedirectDynamicScriptResourceType.STYLES, + type: OpfDynamicScriptResourceType.STYLES, }, ], }, @@ -165,13 +165,13 @@ describe('OpfCheckoutPaymentWrapperService', () => { [ { url: 'script.js', - type: AfterRedirectDynamicScriptResourceType.SCRIPT, + type: OpfDynamicScriptResourceType.SCRIPT, }, ], [ { url: 'styles.css', - type: AfterRedirectDynamicScriptResourceType.STYLES, + type: OpfDynamicScriptResourceType.STYLES, }, ] ); @@ -182,13 +182,13 @@ describe('OpfCheckoutPaymentWrapperService', () => { jsUrls: [ { url: 'script.js', - type: AfterRedirectDynamicScriptResourceType.SCRIPT, + type: OpfDynamicScriptResourceType.SCRIPT, }, ], cssUrls: [ { url: 'styles.css', - type: AfterRedirectDynamicScriptResourceType.STYLES, + type: OpfDynamicScriptResourceType.STYLES, }, ], }, @@ -302,13 +302,13 @@ describe('OpfCheckoutPaymentWrapperService', () => { jsUrls: [ { url: 'script.js', - type: AfterRedirectDynamicScriptResourceType.SCRIPT, + type: OpfDynamicScriptResourceType.SCRIPT, }, ], cssUrls: [ { url: 'styles.css', - type: AfterRedirectDynamicScriptResourceType.STYLES, + type: OpfDynamicScriptResourceType.STYLES, }, ], }, @@ -326,13 +326,13 @@ describe('OpfCheckoutPaymentWrapperService', () => { [ { url: 'script.js', - type: AfterRedirectDynamicScriptResourceType.SCRIPT, + type: OpfDynamicScriptResourceType.SCRIPT, }, ], [ { url: 'styles.css', - type: AfterRedirectDynamicScriptResourceType.STYLES, + type: OpfDynamicScriptResourceType.STYLES, }, ] ); diff --git a/integration-libs/opf/checkout/root/model/opf-payment.model.ts b/integration-libs/opf/checkout/root/model/opf-payment.model.ts index bc04345e9df..86d93d05ec3 100644 --- a/integration-libs/opf/checkout/root/model/opf-payment.model.ts +++ b/integration-libs/opf/checkout/root/model/opf-payment.model.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AfterRedirectDynamicScript } from '@spartacus/opf/base/root'; +import { OpfDynamicScript } from '@spartacus/opf/base/root'; export interface PaymentInitiationConfig { otpKey?: string; @@ -46,7 +46,7 @@ export interface PaymentSessionData { paymentIntent?: string; pattern?: string; destination?: PaymentDestination; - dynamicScript?: AfterRedirectDynamicScript; + dynamicScript?: OpfDynamicScript; } export interface PaymentDestination {