diff --git a/projects/assets/src/translations/en/myAccount.json b/projects/assets/src/translations/en/myAccount.json index 7e55fccbbae..f370006eed1 100644 --- a/projects/assets/src/translations/en/myAccount.json +++ b/projects/assets/src/translations/en/myAccount.json @@ -63,6 +63,13 @@ "findProducts": "Find Products", "status": "Status:", "dialogTitle": "Coupon", + "claimCoupondialogTitle": "Add To Your Coupon List", + "claimCouponCode": { + "label": "Coupon Code", + "placeholder": "Enter the coupon code to claim a coupon" + }, + "reset": "RESET", + "claim": "CLAIM", "claimCustomerCoupon": "You have successfully claimed this coupon.", "myCoupons": "My coupons", "startDateAsc": "Start Date (ascending)", diff --git a/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts b/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts index 2cc07d1aeec..d8dfcd9bc33 100644 --- a/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts +++ b/projects/core/src/features-config/feature-toggles/config/feature-toggles.ts @@ -834,6 +834,14 @@ export interface FeatureTogglesInterface { */ enableConsecutiveCharactersPasswordRequirement?: boolean; + /** + * In CustomerCouponConnector, Enables claiming customer coupon with coupon code in httpRequest body with POST method. + * + * When set to `false`, claiming customer coupon works with coupon code as parameter in URL, which exposes sensitive data and has security risk. + * When set to `true`, claiming customer coupon works with coupon code in httpRequest body with POST method, which avoids security risk. + */ + enableClaimCustomerCouponWithCodeInRequestBody?: boolean; + /** * Enables a validation that prevents new passwords from matching the current password * in the password update form. @@ -1069,4 +1077,5 @@ export const defaultFeatureToggles: Required = { a11yScrollToTopPositioning: false, enableSecurePasswordValidation: false, enableCarouselCategoryProducts: false, + enableClaimCustomerCouponWithCodeInRequestBody: false, }; diff --git a/projects/core/src/occ/adapters/user/default-occ-user-config.ts b/projects/core/src/occ/adapters/user/default-occ-user-config.ts index e2858c0c820..a680ed16065 100644 --- a/projects/core/src/occ/adapters/user/default-occ-user-config.ts +++ b/projects/core/src/occ/adapters/user/default-occ-user-config.ts @@ -22,6 +22,7 @@ export const defaultOccUserConfig: OccConfig = { addressVerification: 'users/${userId}/addresses/verification', customerCoupons: 'users/${userId}/customercoupons', claimCoupon: 'users/${userId}/customercoupons/${couponCode}/claim', + claimCustomerCoupon: 'users/${userId}/customercoupons/claim', couponNotification: 'users/${userId}/customercoupons/${couponCode}/notification', notificationPreference: 'users/${userId}/notificationpreferences', diff --git a/projects/core/src/occ/adapters/user/occ-customer-coupon.adapter.spec.ts b/projects/core/src/occ/adapters/user/occ-customer-coupon.adapter.spec.ts index 52cffcd589d..de0216173ba 100644 --- a/projects/core/src/occ/adapters/user/occ-customer-coupon.adapter.spec.ts +++ b/projects/core/src/occ/adapters/user/occ-customer-coupon.adapter.spec.ts @@ -235,4 +235,45 @@ describe('OccCustomerCouponAdapter', () => { mockReq.flush(customerCoupon2Customer); }); }); + describe('claim customer coupon with code in body', () => { + it('should claim a customer coupon with code in request body for a given user id', () => { + const customerCoupon: CustomerCoupon = { + couponId: couponCode, + name: 'coupon 1', + startDate: '', + endDate: '', + status: 'Effective', + description: '', + notificationOn: true, + }; + const customerCoupon2Customer: CustomerCoupon2Customer = { + coupon: customerCoupon, + customer: {}, + }; + + occCustomerCouponAdapter + .claimCustomerCouponWithCodeInBody(userId, couponCode) + .subscribe((result) => { + expect(result).toEqual(customerCoupon2Customer); + }); + + const mockReq = httpMock.expectOne((req) => { + return req.method === 'POST'; + }); + + expect(occEnpointsService.buildUrl).toHaveBeenCalledWith( + 'claimCustomerCoupon', + { + urlParams: { + userId: userId, + }, + } + ); + + expect(mockReq.cancelled).toBeFalsy(); + expect(mockReq.request.body).toEqual({ couponCode: couponCode }); + expect(mockReq.request.responseType).toEqual('json'); + mockReq.flush(customerCoupon2Customer); + }); + }); }); diff --git a/projects/core/src/occ/adapters/user/occ-customer-coupon.adapter.ts b/projects/core/src/occ/adapters/user/occ-customer-coupon.adapter.ts index 62b85dad52c..115f06a2f5f 100644 --- a/projects/core/src/occ/adapters/user/occ-customer-coupon.adapter.ts +++ b/projects/core/src/occ/adapters/user/occ-customer-coupon.adapter.ts @@ -79,6 +79,21 @@ export class OccCustomerCouponAdapter implements CustomerCouponAdapter { return this.http.post(url, { headers }); } + claimCustomerCouponWithCodeInBody( + userId: string, + codeVal: string + ): Observable { + const url = this.occEndpoints.buildUrl('claimCustomerCoupon', { + urlParams: { userId }, + }); + const toClaim = { + couponCode: codeVal, + }; + const headers = this.newHttpHeader(); + + return this.http.post(url, toClaim, { headers }); + } + claimCustomerCoupon( userId: string, couponCode: string diff --git a/projects/core/src/occ/occ-models/occ-endpoints.model.ts b/projects/core/src/occ/occ-models/occ-endpoints.model.ts index 37fa616a1b5..534738c03ac 100644 --- a/projects/core/src/occ/occ-models/occ-endpoints.model.ts +++ b/projects/core/src/occ/occ-models/occ-endpoints.model.ts @@ -237,6 +237,12 @@ export interface OccEndpoints { * @member {string} */ claimCoupon?: string | OccEndpoint; + /** + * Endpoint for claiming coupon with code in request body + * + * @member {string} + */ + claimCustomerCoupon?: string | OccEndpoint; /** * Endpoint for coupons * diff --git a/projects/core/src/user/connectors/customer-coupon/customer-coupon.adapter.ts b/projects/core/src/user/connectors/customer-coupon/customer-coupon.adapter.ts index 81e029587a5..41f18f79178 100644 --- a/projects/core/src/user/connectors/customer-coupon/customer-coupon.adapter.ts +++ b/projects/core/src/user/connectors/customer-coupon/customer-coupon.adapter.ts @@ -29,6 +29,11 @@ export abstract class CustomerCouponAdapter { couponCode: string ): Observable<{}>; + abstract claimCustomerCouponWithCodeInBody( + userId: string, + couponVal: string + ): Observable; + abstract claimCustomerCoupon( userId: string, couponCode: string diff --git a/projects/core/src/user/connectors/customer-coupon/customer-coupon.connector.spec.ts b/projects/core/src/user/connectors/customer-coupon/customer-coupon.connector.spec.ts index ff670877e56..4d0163a7316 100644 --- a/projects/core/src/user/connectors/customer-coupon/customer-coupon.connector.spec.ts +++ b/projects/core/src/user/connectors/customer-coupon/customer-coupon.connector.spec.ts @@ -3,6 +3,7 @@ import { of } from 'rxjs'; import { CustomerCouponAdapter } from './customer-coupon.adapter'; import { CustomerCouponConnector } from './customer-coupon.connector'; import createSpy = jasmine.createSpy; +import { FeatureConfigService } from '../../../features-config/services/feature-config.service'; const PAGE_SIZE = 5; const currentPage = 1; @@ -21,6 +22,9 @@ class MockUserAdapter implements CustomerCouponAdapter { claimCustomerCoupon = createSpy('claimCustomerCoupon').and.callFake( (userId) => of(`claim-${userId}`) ); + claimCustomerCouponWithCodeInBody = createSpy( + 'claimCustomerCouponWithCodeInBody' + ).and.callFake((userId) => of(`claim-${userId}`)); disclaimCustomerCoupon = createSpy('disclaimCustomerCoupon').and.callFake( (userId) => of(`disclaim-${userId}`) ); @@ -29,6 +33,7 @@ class MockUserAdapter implements CustomerCouponAdapter { describe('CustomerCouponConnector', () => { let service: CustomerCouponConnector; let adapter: CustomerCouponAdapter; + let featureConfigService: FeatureConfigService; beforeEach(() => { TestBed.configureTestingModule({ @@ -39,6 +44,7 @@ describe('CustomerCouponConnector', () => { service = TestBed.inject(CustomerCouponConnector); adapter = TestBed.inject(CustomerCouponAdapter); + featureConfigService = TestBed.inject(FeatureConfigService); }); it('should be created', () => { @@ -83,8 +89,9 @@ describe('CustomerCouponConnector', () => { ); }); - it('claimCustomerCoupon should call adapter', () => { + it('claimCustomerCoupon should call adapter.claimCustomerCoupon in case enableClaimCustomerCouponWithCodeInRequestBody is disabled', () => { let result; + spyOn(featureConfigService, 'isEnabled').and.returnValue(false); service .claimCustomerCoupon('userId', 'couponCode') .subscribe((res) => (result = res)); @@ -95,6 +102,19 @@ describe('CustomerCouponConnector', () => { ); }); + it('claimCustomerCoupon should call adapter.claimCustomerCouponWithCodeInBody in case enableClaimCustomerCouponWithCodeInRequestBody is enabled', () => { + let result; + spyOn(featureConfigService, 'isEnabled').and.returnValue(true); + service + .claimCustomerCoupon('userId', 'couponCode') + .subscribe((res) => (result = res)); + expect(result).toEqual('claim-userId'); + expect(adapter.claimCustomerCouponWithCodeInBody).toHaveBeenCalledWith( + 'userId', + 'couponCode' + ); + }); + it('disclaimCustomerCoupon should call adapter', () => { let result; service diff --git a/projects/core/src/user/connectors/customer-coupon/customer-coupon.connector.ts b/projects/core/src/user/connectors/customer-coupon/customer-coupon.connector.ts index d2b39592974..1be02658d2f 100644 --- a/projects/core/src/user/connectors/customer-coupon/customer-coupon.connector.ts +++ b/projects/core/src/user/connectors/customer-coupon/customer-coupon.connector.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { CustomerCoupon2Customer, @@ -12,11 +12,13 @@ import { CustomerCouponSearchResult, } from '../../../model/customer-coupon.model'; import { CustomerCouponAdapter } from './customer-coupon.adapter'; +import { FeatureConfigService } from '../../../features-config/services/feature-config.service'; @Injectable({ providedIn: 'root', }) export class CustomerCouponConnector { + private featureConfigService = inject(FeatureConfigService); constructor(protected adapter: CustomerCouponAdapter) {} getCustomerCoupons( @@ -43,7 +45,15 @@ export class CustomerCouponConnector { userId: string, couponCode: string ): Observable { - return this.adapter.claimCustomerCoupon(userId, couponCode); + if ( + this.featureConfigService.isEnabled( + 'enableClaimCustomerCouponWithCodeInRequestBody' + ) + ) { + return this.adapter.claimCustomerCouponWithCodeInBody(userId, couponCode); + } else { + return this.adapter.claimCustomerCoupon(userId, couponCode); + } } disclaimCustomerCoupon( diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/b2b/regression/asm/customer360-promotion.e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/b2b/regression/asm/customer360-promotion.e2e.cy.ts index 9d7320b589e..4d879be9766 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/e2e/b2b/regression/asm/customer360-promotion.e2e.cy.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/b2b/regression/asm/customer360-promotion.e2e.cy.ts @@ -111,7 +111,7 @@ context('Assisted Service Module', () => { it('should be able to sent customer coupon for customer coupon (CXSPA-3945)', () => { interceptPost( 'claim_customer_coupon', - '/users/*/customercoupons/*/claim?*' + '/users/*/customercoupons/claim?*' ); cy.get('.cx-asm-customer-360-promotion-listing-row') .contains(customer_coupon.name) @@ -125,8 +125,10 @@ context('Assisted Service Module', () => { 'not.contain', customer_coupon.name ); + cy.wait(5000); }); it('should be able to remove customer coupon for customer coupon (CXSPA-3945)', () => { + cy.wait(3000); cy.get('.cx-tab-header').contains('Sent').click(); interceptDelete( 'disclaim_customer_coupon', diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/asm/customer360.e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/asm/customer360.e2e.cy.ts index fb086fdaed7..04050273752 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/asm/customer360.e2e.cy.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/asm/customer360.e2e.cy.ts @@ -290,7 +290,7 @@ context('Assisted Service Module', () => { it('should be able to sent customer coupon for customer coupon (CXSPA-3945)', () => { interceptPost( 'claim_customer_coupon', - '/users/*/customercoupons/*/claim?*' + '/users/*/customercoupons/claim?*' ); cy.get('.cx-asm-customer-360-promotion-listing-row') .contains('Buy over $1000 get 20% off on cart') @@ -304,8 +304,10 @@ context('Assisted Service Module', () => { 'not.contain', 'Buy over $1000 get 20% off on cart' ); + cy.wait(5000); }); it('should be able to remove customer coupon for customer coupon (CXSPA-3945)', () => { + cy.wait(3000); cy.get('.cx-tab-header').contains('Sent').click(); interceptDelete( 'disclaim_customer_coupon', diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/coupons/my-coupons.e2e-spec-flaky.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/coupons/my-coupons.e2e-spec-flaky.cy.ts index beba7761dba..62ccc630fe9 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/coupons/my-coupons.e2e-spec-flaky.cy.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/coupons/my-coupons.e2e-spec-flaky.cy.ts @@ -34,6 +34,16 @@ viewportContext(['mobile', 'desktop'], () => { }); }); + describe('My coupons - claim coupons with code in body using authenticated user', () => { + beforeEach(() => { + cy.window().then((win) => { + win.sessionStorage.clear(); + }); + cy.requireLoggedIn(); + }); + myCoupons.testClaimCustomerCouponWithCodeInBody(); + }); + describe('My coupons - Authenticated user', () => { beforeEach(() => { cy.window().then((win) => { diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/coupons/my-coupons.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/coupons/my-coupons.ts index 34ae3e040b5..043bba3502b 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/coupons/my-coupons.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/coupons/my-coupons.ts @@ -70,6 +70,14 @@ export function verifyClaimCouponSuccess(couponCode: string) { }); } +export function verifClaimCouponSuccessWithCodeInBody(couponCode: string) { + claimCouponWithCodeInBody(couponCode); + cy.location('pathname').should('contain', myCouponsUrl); + cy.get('.cx-coupon-card').within(() => { + cy.get('.cx-coupon-card-id').should('contain', couponCode); + }); +} + export function verifyClaimCouponFail(couponCode: string) { claimCoupon(couponCode); cy.location('pathname').should('contain', myCouponsUrl); @@ -78,6 +86,14 @@ export function verifyClaimCouponFail(couponCode: string) { }); } +export function verifyClaimCouponFailWithCodeInBody(couponCode: string) { + claimCouponWithCodeInBody(couponCode); + cy.location('pathname').should('contain', myCouponsUrl); + cy.get('.cx-coupon-card').within(() => { + cy.get('.cx-coupon-card-id').should('not.contain', couponCode); + }); +} + export function goMyCoupon() { cy.visit('/my-account/coupons'); cy.get('.cx-coupon-card').should('have.length', 3); @@ -100,7 +116,7 @@ export function claimCoupon(couponCode: string) { 'getClaimedCouponPage' ); - const claimCoupon = waitForClaimCoupon(couponCode); + const claimCoupon = waitForClaimCouponWithCodeInBody(couponCode); const getCoupons = waitForGetCoupons(); @@ -115,6 +131,24 @@ export function claimCoupon(couponCode: string) { cy.wait(`@${getCoupons}`); } +export function claimCouponWithCodeInBody(couponCode: string) { + const claimCoupon = waitForClaimCouponWithCodeInBody(couponCode); + const getCoupons = waitForGetCoupons(); + const couponsPage = waitForPage(myCouponsUrl, 'getCouponsPage'); + cy.visit(myCouponsUrl + '#' + couponCode); + + verifyClaimDialog(); + cy.wait(`@${claimCoupon}`); + + cy.wait(`@${couponsPage}`); + cy.wait(`@${getCoupons}`); +} + +export function verifyResetClaimCouponCode(couponCode: string) { + cy.visit(myCouponsUrl + '#' + couponCode); + verifyResetByClickButton(couponCode); +} + export function createStandardUser() { standardUser.registrationData.email = generateMail(randomString(), true); cy.requireLoggedIn(standardUser); @@ -174,6 +208,24 @@ export function verifyReadMore() { cy.get('.cx-dialog-header span').click(); } +export function verifyClaimDialog() { + cy.get('cx-claim-dialog').should('exist'); + cy.get('.cx-dialog-body .cx-dialog-row-submit-button .btn:first').click({ + force: true, + }); +} + +export function verifyResetByClickButton(couponCode: string) { + cy.get('cx-claim-dialog').should('exist'); + cy.get('.cx-dialog-body input').should('have.value', couponCode); + cy.get('[formcontrolname="couponCode"]').clear().type('resetTest'); + cy.get('.cx-dialog-body input').should('have.value', 'resetTest'); + cy.get('.cx-dialog-body .cx-dialog-row--reset-button .btn:first').click({ + force: true, + }); + cy.get('.cx-dialog-body input').should('have.value', couponCode); +} + export function verifyFindProduct(couponCode: string, productNumber: number) { const productSearchPage = waitForPage('search', 'getProductSearchPage'); @@ -214,6 +266,15 @@ export function waitForGetCoupons(): string { return `${aliasName}`; } +export function waitForClaimCouponWithCodeInBody(couponCode: string): string { + const aliasName = `claimCouponInBody_${couponCode}`; + cy.intercept({ + method: 'POST', + url: `${pageUrl}/users/current/customercoupons/claim*`, + }).as(aliasName); + return `${aliasName}`; +} + export function testClaimCustomerCoupon() { describe('Claim customer coupon', () => { it('should claim customer coupon successfully', () => { @@ -227,3 +288,22 @@ export function testClaimCustomerCoupon() { }); }); } + +export function testClaimCustomerCouponWithCodeInBody() { + describe('Claim customer coupon with code in requestBody', () => { + it('should claim customer coupon successfully with code in requestBody', () => { + verifClaimCouponSuccessWithCodeInBody(validCouponCode); + cy.saveLocalStorage(); + }); + + it('should not claim invalid customer coupon', () => { + cy.restoreLocalStorage(); + verifyClaimCouponFailWithCodeInBody(invalidCouponCode); + }); + + it('should reset coupon code val after clicking reset button', () => { + cy.restoreLocalStorage(); + verifyResetClaimCouponCode(validCouponCode); + }); + }); +} diff --git a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts index 38178f20e23..2d93e211aa2 100644 --- a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts +++ b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts @@ -430,6 +430,7 @@ if (environment.cpq) { a11yWrapReviewOrderInSection: true, enableCarouselCategoryProducts: true, enableSecurePasswordValidation: true, + enableClaimCustomerCouponWithCodeInRequestBody: true, }; return appFeatureToggles; }), diff --git a/projects/storefrontlib/cms-components/myaccount/my-coupons/claim-dialog/claim-dialog.component.html b/projects/storefrontlib/cms-components/myaccount/my-coupons/claim-dialog/claim-dialog.component.html new file mode 100644 index 00000000000..1f89d111417 --- /dev/null +++ b/projects/storefrontlib/cms-components/myaccount/my-coupons/claim-dialog/claim-dialog.component.html @@ -0,0 +1,83 @@ + + + + + diff --git a/projects/storefrontlib/cms-components/myaccount/my-coupons/claim-dialog/claim-dialog.component.spec.ts b/projects/storefrontlib/cms-components/myaccount/my-coupons/claim-dialog/claim-dialog.component.spec.ts new file mode 100644 index 00000000000..8482dbe984c --- /dev/null +++ b/projects/storefrontlib/cms-components/myaccount/my-coupons/claim-dialog/claim-dialog.component.spec.ts @@ -0,0 +1,160 @@ +import { Component, Input } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { I18nTestingModule } from '@spartacus/core'; +import { + RoutingService, + CustomerCouponService, + GlobalMessageService, + GlobalMessageType, +} from '@spartacus/core'; +import { + ReactiveFormsModule, + FormControl, + FormGroup, + Validators, +} from '@angular/forms'; +import { FocusDirective, FormErrorsModule } from '@spartacus/storefront'; +import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive'; +import { Observable, of } from 'rxjs'; +import { ICON_TYPE } from '../../../../cms-components/misc/icon/index'; +import { LaunchDialogService } from '../../../../layout/index'; +import { ClaimDialogComponent } from './claim-dialog.component'; + +const mockCoupon: string = 'testCode'; +const form = new FormGroup({ + couponCode: new FormControl('', [Validators.required]), +}); + +@Component({ + selector: 'cx-icon', + template: '', +}) +class MockCxIconComponent { + @Input() type: ICON_TYPE; +} +class MockLaunchDialogService implements Partial { + get data$(): Observable { + return of({ coupon: 'testCode', pageSize: 10 }); + } + + closeDialog(_reason: string): void {} +} + +describe('ClaimDialogComponent', () => { + let component: ClaimDialogComponent; + let fixture: ComponentFixture; + let launchDialogService: LaunchDialogService; + + const couponService = jasmine.createSpyObj('CustomerCouponService', [ + 'claimCustomerCoupon', + 'getClaimCustomerCouponResultSuccess', + 'loadCustomerCoupons', + ]); + const routingService = jasmine.createSpyObj('RoutingService', ['go']); + const globalMessageService = jasmine.createSpyObj('GlobalMessageService', [ + 'add', + ]); + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + ClaimDialogComponent, + MockCxIconComponent, + FocusDirective, + MockFeatureDirective, + ], + imports: [ReactiveFormsModule, I18nTestingModule, FormErrorsModule], + providers: [ + { provide: LaunchDialogService, useClass: MockLaunchDialogService }, + { provide: CustomerCouponService, useValue: couponService }, + { provide: RoutingService, useValue: routingService }, + { provide: GlobalMessageService, useValue: globalMessageService }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ClaimDialogComponent); + component = fixture.componentInstance; + launchDialogService = TestBed.inject(LaunchDialogService); + component.couponCode = mockCoupon; + component.form = form; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should be able to show claim customer coupon dialog', () => { + fixture.detectChanges(); + const dialogTitle = fixture.debugElement.query(By.css('.cx-dialog-title')) + .nativeElement.textContent; + expect(dialogTitle).toContain('myCoupons.claimCoupondialogTitle'); + + const closeBtn = fixture.debugElement.query(By.css('button')); + expect(closeBtn).toBeTruthy(); + + const couponLabel = fixture.debugElement.query( + By.css('.cx-dialog-body .label-content') + ).nativeElement.textContent; + expect(couponLabel).toContain('myCoupons.claimCouponCode.label'); + }); + + it('should be able to close dialog', () => { + spyOn(launchDialogService, 'closeDialog').and.stub(); + fixture.detectChanges(); + const closeBtn = fixture.debugElement.query(By.css('button')); + closeBtn.nativeElement.click(); + expect(launchDialogService.closeDialog).toHaveBeenCalled(); + }); + + describe('Form Interactions', () => { + it('should reset the coupon code after click reset button', () => { + component.ngOnInit(); + expect(component.couponCode).toBe(mockCoupon); + + (form.get('couponCode') as FormControl).setValue('testcodechanged'); + + component.cancelEdit(); + fixture.detectChanges(); + expect((form.get('couponCode') as FormControl).value).toBe(mockCoupon); + }); + + it('should succeed on submit', () => { + (form.get('couponCode') as FormControl).setValue(mockCoupon); + fixture.detectChanges(); + couponService.claimCustomerCoupon.and.stub(); + couponService.loadCustomerCoupons.and.stub(); + couponService.getClaimCustomerCouponResultSuccess.and.returnValue( + of(true) + ); + routingService.go.and.stub(); + globalMessageService.add.and.stub(); + component.onSubmit(); + + expect(globalMessageService.add).toHaveBeenCalledWith( + { key: 'myCoupons.claimCustomerCoupon' }, + GlobalMessageType.MSG_TYPE_CONFIRMATION + ); + expect(routingService.go).toHaveBeenCalledWith({ cxRoute: 'coupons' }); + expect(couponService.claimCustomerCoupon).toHaveBeenCalledTimes(1); + expect(couponService.loadCustomerCoupons).toHaveBeenCalledTimes(1); + }); + + it('should fail on submit', () => { + (form.get('couponCode') as FormControl).setValue(mockCoupon); + fixture.detectChanges(); + couponService.claimCustomerCoupon.and.stub(); + couponService.loadCustomerCoupons.and.stub(); + couponService.getClaimCustomerCouponResultSuccess.and.returnValue( + of(false) + ); + routingService.go.and.stub(); + globalMessageService.add.and.stub(); + component.onSubmit(); + expect(routingService.go).toHaveBeenCalledWith({ cxRoute: 'coupons' }); + }); + }); +}); diff --git a/projects/storefrontlib/cms-components/myaccount/my-coupons/claim-dialog/claim-dialog.component.ts b/projects/storefrontlib/cms-components/myaccount/my-coupons/claim-dialog/claim-dialog.component.ts new file mode 100644 index 00000000000..a7b8fb3144a --- /dev/null +++ b/projects/storefrontlib/cms-components/myaccount/my-coupons/claim-dialog/claim-dialog.component.ts @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, + inject, +} from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; + +import { Subscription } from 'rxjs'; +import { + RoutingService, + CustomerCouponService, + GlobalMessageService, + GlobalMessageType, +} from '@spartacus/core'; +import { FocusConfig, LaunchDialogService } from '../../../../layout/index'; +import { ICON_TYPE } from '../../../../cms-components/misc/icon/index'; + +@Component({ + selector: 'cx-claim-dialog', + templateUrl: './claim-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ClaimDialogComponent implements OnDestroy, OnInit { + private subscription = new Subscription(); + iconTypes = ICON_TYPE; + protected pageSize = 10; + couponCode: string; + + focusConfig: FocusConfig = { + trap: true, + block: true, + autofocus: 'button', + focusOnEscape: true, + }; + + form: FormGroup = new FormGroup({ + couponCode: new FormControl('', [Validators.required]), + }); + + protected couponService = inject(CustomerCouponService); + protected routingService = inject(RoutingService); + protected messageService = inject(GlobalMessageService); + protected launchDialogService = inject(LaunchDialogService); + + ngOnInit(): void { + this.subscription.add( + this.launchDialogService.data$.subscribe((data) => { + if (data) { + this.couponCode = data.coupon; + this.pageSize = data.pageSize; + (this.form.get('couponCode') as FormControl).setValue( + this.couponCode + ); + } + }) + ); + } + + onSubmit(): void { + if (!this.form.valid) { + this.form.markAllAsTouched(); + return; + } + const couponVal = (this.form.get('couponCode') as FormControl).value; + if (couponVal) { + this.couponService.claimCustomerCoupon(couponVal); + this.subscription = this.couponService + .getClaimCustomerCouponResultSuccess() + .subscribe((success) => { + if (success) { + this.messageService.add( + { key: 'myCoupons.claimCustomerCoupon' }, + GlobalMessageType.MSG_TYPE_CONFIRMATION + ); + } + this.routingService.go({ cxRoute: 'coupons' }); + this.couponService.loadCustomerCoupons(this.pageSize); + this.close('Cross click'); + }); + } else { + this.routingService.go({ cxRoute: 'notFound' }); + } + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + } + + close(reason?: any): void { + this.launchDialogService.closeDialog(reason); + } + + cancelEdit(): void { + (this.form.get('couponCode') as FormControl).setValue(this.couponCode); + } +} diff --git a/projects/storefrontlib/cms-components/myaccount/my-coupons/default-coupon-card-layout.config.ts b/projects/storefrontlib/cms-components/myaccount/my-coupons/default-coupon-card-layout.config.ts index a2efb49e111..4f52718cd84 100644 --- a/projects/storefrontlib/cms-components/myaccount/my-coupons/default-coupon-card-layout.config.ts +++ b/projects/storefrontlib/cms-components/myaccount/my-coupons/default-coupon-card-layout.config.ts @@ -6,6 +6,7 @@ import { DIALOG_TYPE, LayoutConfig } from '../../../layout/index'; import { CouponDialogComponent } from './coupon-card/coupon-dialog/coupon-dialog.component'; +import { ClaimDialogComponent } from './claim-dialog/claim-dialog.component'; export const defaultCouponLayoutConfig: LayoutConfig = { launch: { @@ -14,5 +15,10 @@ export const defaultCouponLayoutConfig: LayoutConfig = { component: CouponDialogComponent, dialogType: DIALOG_TYPE.DIALOG, }, + CLAIM_DIALOG: { + inlineRoot: true, + component: ClaimDialogComponent, + dialogType: DIALOG_TYPE.DIALOG, + }, }, }; diff --git a/projects/storefrontlib/cms-components/myaccount/my-coupons/index.ts b/projects/storefrontlib/cms-components/myaccount/my-coupons/index.ts index 72d79ea90ce..446efc231c6 100644 --- a/projects/storefrontlib/cms-components/myaccount/my-coupons/index.ts +++ b/projects/storefrontlib/cms-components/myaccount/my-coupons/index.ts @@ -8,5 +8,6 @@ export * from './my-coupons.component'; export * from './my-coupons.module'; export * from './coupon-card/coupon-card.component'; export * from './coupon-card/coupon-dialog/coupon-dialog.component'; +export * from './claim-dialog/claim-dialog.component'; export * from './coupon-claim/coupon-claim.component'; export * from './my-coupons.component.service'; diff --git a/projects/storefrontlib/cms-components/myaccount/my-coupons/my-coupons.component.spec.ts b/projects/storefrontlib/cms-components/myaccount/my-coupons/my-coupons.component.spec.ts index 2669ecf45f7..3cfaf810207 100644 --- a/projects/storefrontlib/cms-components/myaccount/my-coupons/my-coupons.component.spec.ts +++ b/projects/storefrontlib/cms-components/myaccount/my-coupons/my-coupons.component.spec.ts @@ -2,6 +2,7 @@ import { Component, DebugElement, EventEmitter, + ElementRef, Input, Output, } from '@angular/core'; @@ -15,8 +16,9 @@ import { FeaturesConfig, I18nTestingModule, } from '@spartacus/core'; +import { LAUNCH_CALLER, LaunchDialogService } from '../../../layout/index'; import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive'; -import { BehaviorSubject, Observable, of } from 'rxjs'; +import { BehaviorSubject, EMPTY, Observable, of } from 'rxjs'; import { SpinnerModule } from '../../../shared/components/spinner/spinner.module'; import { ICON_TYPE } from '../../misc/icon/icon.model'; import { MyCouponsComponent } from './my-coupons.component'; @@ -143,10 +145,17 @@ class MockSortingComponent { @Output() sortListEvent = new EventEmitter(); } +class MockLaunchDialogService implements Partial { + openDialogAndSubscribe(_caller: LAUNCH_CALLER, _openElement?: ElementRef) { + return EMPTY; + } +} + describe('MyCouponsComponent', () => { let component: MyCouponsComponent; let fixture: ComponentFixture; let el: DebugElement; + let launchDialogService: LaunchDialogService; const customerCouponService = jasmine.createSpyObj('CustomerCouponService', [ 'getCustomerCoupons', @@ -183,6 +192,7 @@ describe('MyCouponsComponent', () => { provide: MyCouponsComponentService, useValue: myCouponsComponentService, }, + { provide: LaunchDialogService, useClass: MockLaunchDialogService }, { provide: FeaturesConfig, useValue: { @@ -197,6 +207,7 @@ describe('MyCouponsComponent', () => { fixture = TestBed.createComponent(MyCouponsComponent); component = fixture.componentInstance; el = fixture.debugElement; + launchDialogService = TestBed.inject(LaunchDialogService); customerCouponService.getCustomerCoupons.and.returnValue( of(emptyCouponResult) @@ -318,4 +329,12 @@ describe('MyCouponsComponent', () => { PAGE_SIZE ); }); + + it('should be able to open coupon claim dialog if has hash str in location', () => { + spyOn(component, 'getHashStr').and.returnValue(String('#testcode')); + component.ngOnInit(); + spyOn(launchDialogService, 'openDialogAndSubscribe').and.stub(); + fixture.detectChanges(); + expect(launchDialogService.openDialogAndSubscribe).toHaveBeenCalled(); + }); }); diff --git a/projects/storefrontlib/cms-components/myaccount/my-coupons/my-coupons.component.ts b/projects/storefrontlib/cms-components/myaccount/my-coupons/my-coupons.component.ts index 5f864926fe5..e7a2f8be5ff 100644 --- a/projects/storefrontlib/cms-components/myaccount/my-coupons/my-coupons.component.ts +++ b/projects/storefrontlib/cms-components/myaccount/my-coupons/my-coupons.component.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, inject, OnDestroy, OnInit } from '@angular/core'; import { CustomerCouponSearchResult, CustomerCouponService, @@ -13,6 +13,7 @@ import { import { combineLatest, Observable, Subscription } from 'rxjs'; import { map, tap } from 'rxjs/operators'; import { ICON_TYPE } from '../../misc/icon/icon.model'; +import { LaunchDialogService, LAUNCH_CALLER } from '../../../layout/index'; import { MyCouponsComponentService } from './my-coupons.component.service'; @Component({ @@ -64,6 +65,8 @@ export class MyCouponsComponent implements OnInit, OnDestroy { byEndDateDesc: string; }>; + protected launchDialogService = inject(LaunchDialogService); + constructor( protected couponService: CustomerCouponService, protected myCouponsComponentService: MyCouponsComponentService @@ -107,6 +110,23 @@ export class MyCouponsComponent implements OnInit, OnDestroy { this.subscriptionFail(error); }) ); + + const resultStr = decodeURIComponent(this.getHashStr()); + const index = resultStr.indexOf('#'); + if (index !== -1) { + const couponCode = resultStr.substring(index + 1); + if (couponCode !== undefined && couponCode.length > 0) { + this.launchDialogService.openDialogAndSubscribe( + LAUNCH_CALLER.CLAIM_DIALOG, + undefined, + { coupon: couponCode, pageSize: this.PAGE_SIZE } + ); + } + } + } + + getHashStr() { + return location.hash; } private subscriptionFail(error: boolean) { diff --git a/projects/storefrontlib/cms-components/myaccount/my-coupons/my-coupons.module.ts b/projects/storefrontlib/cms-components/myaccount/my-coupons/my-coupons.module.ts index 3bc89d2d92b..2e9dc27ece4 100644 --- a/projects/storefrontlib/cms-components/myaccount/my-coupons/my-coupons.module.ts +++ b/projects/storefrontlib/cms-components/myaccount/my-coupons/my-coupons.module.ts @@ -7,6 +7,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; +import { ReactiveFormsModule } from '@angular/forms'; import { AuthGuard, CmsConfig, @@ -21,16 +22,20 @@ import { KeyboardFocusModule } from '../../../layout/index'; import { CardModule } from '../../../shared/components/card/card.module'; import { ListNavigationModule } from '../../../shared/components/list-navigation/list-navigation.module'; import { SpinnerModule } from '../../../shared/components/spinner/spinner.module'; +import { FormErrorsModule } from '../../../shared/components/form/form-errors'; import { IconModule } from '../../misc/icon/icon.module'; import { CouponCardComponent } from './coupon-card/coupon-card.component'; import { CouponDialogComponent } from './coupon-card/coupon-dialog/coupon-dialog.component'; import { CouponClaimComponent } from './coupon-claim/coupon-claim.component'; +import { ClaimDialogComponent } from './claim-dialog/claim-dialog.component'; import { defaultCouponLayoutConfig } from './default-coupon-card-layout.config'; import { MyCouponsComponent } from './my-coupons.component'; @NgModule({ imports: [ CommonModule, + ReactiveFormsModule, + FormErrorsModule, CardModule, SpinnerModule, I18nModule, @@ -55,6 +60,7 @@ import { MyCouponsComponent } from './my-coupons.component'; CouponCardComponent, CouponDialogComponent, CouponClaimComponent, + ClaimDialogComponent, ], providers: [ provideDefaultConfig({ @@ -67,10 +73,14 @@ import { MyCouponsComponent } from './my-coupons.component'; component: CouponClaimComponent, guards: [AuthGuard], }, + ClaimDialogComponent: { + component: ClaimDialogComponent, + guards: [AuthGuard], + }, }, }), provideDefaultConfig(defaultCouponLayoutConfig), ], - exports: [MyCouponsComponent, CouponClaimComponent], + exports: [MyCouponsComponent, CouponClaimComponent, ClaimDialogComponent], }) export class MyCouponsModule {} diff --git a/projects/storefrontlib/layout/launch-dialog/config/launch-config.ts b/projects/storefrontlib/layout/launch-dialog/config/launch-config.ts index 8a086e1886e..a90de59d2c0 100644 --- a/projects/storefrontlib/layout/launch-dialog/config/launch-config.ts +++ b/projects/storefrontlib/layout/launch-dialog/config/launch-config.ts @@ -98,5 +98,6 @@ export const enum LAUNCH_CALLER { PLACE_ORDER_SPINNER = 'PLACE_ORDER_SPINNER', SUGGESTED_ADDRESSES = 'SUGGESTED_ADDRESSES', COUPON = 'COUPON', + CLAIM_DIALOG = 'CLAIM_DIALOG', STOCK_NOTIFICATION = 'STOCK_NOTIFICATION', } diff --git a/projects/storefrontstyles/scss/components/myaccount/_index.scss b/projects/storefrontstyles/scss/components/myaccount/_index.scss index ba7a3d4de36..e922e308cc5 100644 --- a/projects/storefrontstyles/scss/components/myaccount/_index.scss +++ b/projects/storefrontstyles/scss/components/myaccount/_index.scss @@ -9,12 +9,13 @@ @import './my-coupons'; @import './my-coupons-card'; @import './my-coupons-dialog'; +@import './my-claim-dialog'; @import './my-interests'; @import './cx-my-account-v2-notification-preference'; $myaccount-components-allowlist: cx-anonymous-consent-management-banner, cx-anonymous-consent-dialog, cx-anonymous-consent-open-dialog, cx-consent-management-form, cx-consent-management, cx-my-interests, - cx-my-coupons, cx-coupon-card, cx-coupon-dialog, cx-payment-methods, - cx-my-account-v2-notification-preference, + cx-my-coupons, cx-coupon-card, cx-coupon-dialog, cx-claim-dialog, + cx-payment-methods, cx-my-account-v2-notification-preference, cx-my-account-v2-consent-management-form, cx-my-account-v2-consent-management !default; diff --git a/projects/storefrontstyles/scss/components/myaccount/_my-claim-dialog.scss b/projects/storefrontstyles/scss/components/myaccount/_my-claim-dialog.scss new file mode 100644 index 00000000000..87fa3a320c8 --- /dev/null +++ b/projects/storefrontstyles/scss/components/myaccount/_my-claim-dialog.scss @@ -0,0 +1,65 @@ +%cx-claim-dialog { + background-color: rgba(0, 0, 0, 0.5); + .cx-coupon-dialog { + @extend .modal-dialog; + @extend .modal-dialog-centered; + @extend .modal-lg; + + .cx-coupon-container { + @extend .modal-content; + .cx-dialog-item { + padding-inline-end: 1.75rem; + padding-inline-start: 1.75rem; + } + + .cx-dialog-header { + padding-top: 2rem; + padding-inline-end: 1.75rem; + padding-bottom: 0.85rem; + padding-inline-start: 5.75rem; + border-width: 0; + @include cx-highContrastTheme { + background-color: var(--cx-color-background); + } + } + + .cx-dialog-title { + @include type('3'); + } + + .cx-dialog-body { + padding-top: 1rem; + padding-inline-end: 5.75rem; + padding-bottom: 0; + padding-inline-start: 5.75rem; + @include media-breakpoint-down(sm) { + padding: 0; + } + @include cx-highContrastTheme { + background-color: var(--cx-color-background); + } + } + + .cx-dialog-row { + margin: 0; + display: flex; + padding: 0 0 2.875rem; + max-width: 100%; + margin-top: 2.875rem; + margin-bottom: 1.5rem; + + @include media-breakpoint-down(sm) { + padding: 0; + } + } + + .cx-dialog-row--reset-button { + padding: 0 12px 0 0; + } + + .cx-dialog-row-submit-button { + padding: 0 0 0 12px; + } + } + } +}