diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a76339bc13d..282b9ef6594 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,7 +225,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup node - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} - name: Cache node_modules @@ -277,7 +277,7 @@ jobs: - name: Notify the slack channel of when build conclusion failed env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_TOKEN }} - uses: slackapi/slack-github-action@v1.27.0 + uses: slackapi/slack-github-action@v1.27.1 with: channel-id: ${{ secrets.SLACK_NOTIFICATION_CHANNEL }} payload: | diff --git a/core-libs/setup/package.json b/core-libs/setup/package.json index 55537ae3115..a5cf4e6e4e8 100644 --- a/core-libs/setup/package.json +++ b/core-libs/setup/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/setup", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Includes features that makes Spartacus and it's setup easier and streamlined.", "keywords": [ "spartacus", @@ -16,7 +16,7 @@ "test": "../../node_modules/.bin/jest --config ./jest.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular/core": "^18.2.9", diff --git a/feature-libs/asm/components/csagent-login-form/csagent-login-form.component.ts b/feature-libs/asm/components/csagent-login-form/csagent-login-form.component.ts index 0395366b38a..2a2f4b9beeb 100644 --- a/feature-libs/asm/components/csagent-login-form/csagent-login-form.component.ts +++ b/feature-libs/asm/components/csagent-login-form/csagent-login-form.component.ts @@ -26,7 +26,7 @@ export class CSAgentLoginFormComponent implements OnInit { submitEvent = new EventEmitter<{ userId: string; password: string }>(); constructor(protected fb: UntypedFormBuilder) { - useFeatureStyles('a11yPasswordVisibilityBtnValueOverflow'); + useFeatureStyles('a11yPasswordVisibliltyBtnValueOverflow'); useFeatureStyles('a11yTextSpacingAdjustments'); } diff --git a/feature-libs/asm/package.json b/feature-libs/asm/package.json index fa67208ade4..ad3dad8164e 100644 --- a/feature-libs/asm/package.json +++ b/feature-libs/asm/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/asm", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "ASM feature library for Spartacus", "keywords": [ "spartacus", @@ -22,7 +22,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/feature-libs/cart/base/components/mini-cart/mini-cart.component.ts b/feature-libs/cart/base/components/mini-cart/mini-cart.component.ts index 471803f9dae..f5ed0177d50 100644 --- a/feature-libs/cart/base/components/mini-cart/mini-cart.component.ts +++ b/feature-libs/cart/base/components/mini-cart/mini-cart.component.ts @@ -6,6 +6,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ICON_TYPE } from '@spartacus/storefront'; +import { useFeatureStyles } from '@spartacus/core'; import { Observable } from 'rxjs'; import { MiniCartComponentService } from './mini-cart-component.service'; @@ -21,5 +22,7 @@ export class MiniCartComponent { total$: Observable = this.miniCartComponentService.getTotalPrice(); - constructor(protected miniCartComponentService: MiniCartComponentService) {} + constructor(protected miniCartComponentService: MiniCartComponentService) { + useFeatureStyles('a11yMiniCartFocusOnMobile'); + } } diff --git a/feature-libs/cart/base/styles/components/_mini-cart.scss b/feature-libs/cart/base/styles/components/_mini-cart.scss index 2a0022a780b..dcdbb5b99c9 100644 --- a/feature-libs/cart/base/styles/components/_mini-cart.scss +++ b/feature-libs/cart/base/styles/components/_mini-cart.scss @@ -51,6 +51,18 @@ } } + @include forFeature('a11yMiniCartFocusOnMobile') { + @include media-breakpoint-down(md) { + a { + &:focus { + outline-offset: -4px; + outline-color: var(--cx-color-inverse); + box-shadow: 0 0 0 2px var(--cx-color-visual-focus) inset; + } + } + } + } + @mixin native-high-contrast-fix { @media (forced-colors: active) { a { diff --git a/feature-libs/cart/package.json b/feature-libs/cart/package.json index c3a3cd684b6..0927a195dad 100644 --- a/feature-libs/cart/package.json +++ b/feature-libs/cart/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/cart", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "", "keywords": [ "spartacus", @@ -26,7 +26,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/feature-libs/cart/quick-order/components/quick-order/form/quick-order-form.component.html b/feature-libs/cart/quick-order/components/quick-order/form/quick-order-form.component.html index 9677c8c9ba7..0f38b0d9f18 100644 --- a/feature-libs/cart/quick-order/components/quick-order/form/quick-order-form.component.html +++ b/feature-libs/cart/quick-order/components/quick-order/form/quick-order-form.component.html @@ -71,6 +71,7 @@ class="quick-order-results-product-container" > + diff --git a/feature-libs/cart/quick-order/components/quick-order/form/quick-order-form.component.spec.ts b/feature-libs/cart/quick-order/components/quick-order/form/quick-order-form.component.spec.ts index 3a080868a6c..a525c31dd52 100644 --- a/feature-libs/cart/quick-order/components/quick-order/form/quick-order-form.component.spec.ts +++ b/feature-libs/cart/quick-order/components/quick-order/form/quick-order-form.component.spec.ts @@ -7,6 +7,7 @@ import { QuickOrderFacade, } from '@spartacus/cart/quick-order/root'; import { + FeatureConfigService, FeaturesConfig, GlobalMessageService, GlobalMessageType, @@ -64,6 +65,12 @@ class MockGlobalMessageService implements Partial { ): void {} } +class MockFeatureConfigService { + isEnabled() { + return true; + } +} + @Component({ selector: 'cx-icon', template: '', @@ -101,6 +108,7 @@ describe('QuickOrderFormComponent', () => { features: { level: '5.1' }, }, }, + { provide: FeatureConfigService, useClass: MockFeatureConfigService }, ], }).compileComponents(); @@ -214,6 +222,20 @@ describe('QuickOrderFormComponent', () => { component.clear(ev as Event); expect(ev.preventDefault).toHaveBeenCalled(); }); + + it('sets focus back to the input if results box was open', (done) => { + const inputSearch: HTMLElement = fixture.debugElement.query( + By.css('input') + ).nativeElement; + + component.open(); + expect(inputSearch).not.toBe(getFocusedElement()); + component.clear(); + requestAnimationFrame(() => { + expect(inputSearch).toBe(getFocusedElement()); + done(); + }); + }); }); it('should not change focus on focusNextChild if results list is empty', () => { diff --git a/feature-libs/cart/quick-order/components/quick-order/form/quick-order-form.component.ts b/feature-libs/cart/quick-order/components/quick-order/form/quick-order-form.component.ts index 11d033d994f..485e895d860 100644 --- a/feature-libs/cart/quick-order/components/quick-order/form/quick-order-form.component.ts +++ b/feature-libs/cart/quick-order/components/quick-order/form/quick-order-form.component.ts @@ -9,6 +9,7 @@ import { ChangeDetectorRef, Component, ElementRef, + inject, Input, OnDestroy, OnInit, @@ -16,7 +17,13 @@ import { } from '@angular/core'; import { UntypedFormControl, UntypedFormGroup } from '@angular/forms'; import { QuickOrderFacade } from '@spartacus/cart/quick-order/root'; -import { Config, Product, WindowRef, useFeatureStyles } from '@spartacus/core'; +import { + Config, + FeatureConfigService, + Product, + useFeatureStyles, + WindowRef, +} from '@spartacus/core'; import { ICON_TYPE } from '@spartacus/storefront'; import { Observable, Subscription } from 'rxjs'; import { @@ -45,6 +52,7 @@ export class QuickOrderFormComponent implements OnInit, OnDestroy { @ViewChild('quickOrderInput') quickOrderInput: ElementRef; + private featureConfigService = inject(FeatureConfigService); protected subscription = new Subscription(); protected searchSubscription = new Subscription(); @@ -78,6 +86,15 @@ export class QuickOrderFormComponent implements OnInit, OnDestroy { if (this.isResultsBoxOpen()) { this.toggleBodyClass(SEARCH_BOX_ACTIVE_CLASS, false); + if ( + this.featureConfigService.isEnabled( + 'a11yQuickOrderSearchBoxRefocusOnClose' + ) + ) { + requestAnimationFrame(() => { + this.quickOrderInput.nativeElement.focus(); + }); + } } const product = this.form.get('product')?.value; @@ -140,6 +157,15 @@ export class QuickOrderFormComponent implements OnInit, OnDestroy { // Focus on first index moving to last if (results.length) { + if ( + this.featureConfigService.isEnabled( + 'a11ySearchableDropdownFirstElementFocus' + ) + ) { + this.winRef.document + .querySelector('main') + ?.classList.remove('mouse-focus'); + } if (focusedIndex >= results.length - 1) { results[0].focus(); } else { diff --git a/feature-libs/checkout/base/components/checkout-delivery-address/checkout-delivery-address.component.spec.ts b/feature-libs/checkout/base/components/checkout-delivery-address/checkout-delivery-address.component.spec.ts index 782e22c6dba..53a504d2601 100644 --- a/feature-libs/checkout/base/components/checkout-delivery-address/checkout-delivery-address.component.spec.ts +++ b/feature-libs/checkout/base/components/checkout-delivery-address/checkout-delivery-address.component.spec.ts @@ -1,5 +1,11 @@ import { ChangeDetectionStrategy, Component, Input, Type } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; import { ActiveCartFacade } from '@spartacus/cart/base/root'; @@ -547,4 +553,25 @@ describe('CheckoutDeliveryAddressComponent', () => { expect(getSpinner()).toBeFalsy(); }); }); + + describe('focusCardAfterSelecting', () => { + it('should refocus the selected card after updating', fakeAsync(() => { + const card = document.createElement('cx-card'); + const selectButton = document.createElement('button'); + card.appendChild(selectButton); + card.tabIndex = 0; + document.body.appendChild(card); + selectButton.focus(); + component['isUpdating$'] = of(false); + spyOn(card, 'focus'); + spyOn(component['focusService'], 'findFirstFocusable').and.returnValue( + card + ); + + component.focusCardAfterSelecting(); + tick(16); // Wait for requestAnimationFrame + + expect(card.focus).toHaveBeenCalled(); + })); + }); }); diff --git a/feature-libs/checkout/base/components/checkout-delivery-address/checkout-delivery-address.component.ts b/feature-libs/checkout/base/components/checkout-delivery-address/checkout-delivery-address.component.ts index c1d9e5b34fd..1cbf8ebf925 100644 --- a/feature-libs/checkout/base/components/checkout-delivery-address/checkout-delivery-address.component.ts +++ b/feature-libs/checkout/base/components/checkout-delivery-address/checkout-delivery-address.component.ts @@ -24,15 +24,21 @@ import { GlobalMessageType, TranslationService, UserAddressService, + WindowRef, getLastValueSync, } from '@spartacus/core'; -import { Card, getAddressNumbers } from '@spartacus/storefront'; +import { + Card, + SelectFocusUtility, + getAddressNumbers, +} from '@spartacus/storefront'; import { BehaviorSubject, Observable, combineLatest } from 'rxjs'; import { distinctUntilChanged, filter, map, switchMap, + take, tap, } from 'rxjs/operators'; import { CheckoutConfigService } from '../services'; @@ -79,6 +85,9 @@ export class CheckoutDeliveryAddressComponent implements OnInit { ); } + @Optional() protected focusService = inject(SelectFocusUtility); + @Optional() protected windowRef = inject(WindowRef); + constructor( protected userAddressService: UserAddressService, protected checkoutDeliveryAddressFacade: CheckoutDeliveryAddressFacade, @@ -155,6 +164,38 @@ export class CheckoutDeliveryAddressComponent implements OnInit { ); this.setAddress(address); + if (this.featureConfigService?.isEnabled('a11yFocusOnCardAfterSelecting')) { + this.focusCardAfterSelecting(); + } + } + + /** + * Restores the focus to the Card component after it has been selected and the checkout has finished updating. + * The focus is lost due to DOM changes making it otherwise impossible to target elements that have been removed. + */ + focusCardAfterSelecting(): void { + const cardNodes = Array.from( + this.windowRef?.document.querySelectorAll('cx-card') + ); + const triggeredCard = + this.windowRef?.document.activeElement?.closest('cx-card'); + + if (triggeredCard) { + const selectedCardIndex = cardNodes.indexOf(triggeredCard); + this.isUpdating$ + .pipe( + filter((isUpdating) => !isUpdating), + take(1) + ) + .subscribe(() => { + requestAnimationFrame(() => { + const selectedCard = this.windowRef?.document.querySelectorAll( + 'cx-card' + )[selectedCardIndex] as HTMLElement; + this.focusService.findFirstFocusable(selectedCard)?.focus(); + }); + }); + } } addAddress(address: Address | undefined): void { diff --git a/feature-libs/checkout/base/components/checkout-payment-method/checkout-payment-method.component.spec.ts b/feature-libs/checkout/base/components/checkout-payment-method/checkout-payment-method.component.spec.ts index 4065eef6967..1d0270d368c 100644 --- a/feature-libs/checkout/base/components/checkout-payment-method/checkout-payment-method.component.spec.ts +++ b/feature-libs/checkout/base/components/checkout-payment-method/checkout-payment-method.component.spec.ts @@ -1,5 +1,11 @@ import { Component, Input, Type } from '@angular/core'; -import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; import { ActiveCartFacade } from '@spartacus/cart/base/root'; @@ -19,7 +25,7 @@ import { } from '@spartacus/core'; import { CardComponent, ICON_TYPE } from '@spartacus/storefront'; import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive'; -import { BehaviorSubject, EMPTY, Observable, Subject, of } from 'rxjs'; +import { BehaviorSubject, EMPTY, Observable, of, Subject } from 'rxjs'; import { CheckoutStepService } from '../services/checkout-step.service'; import { CheckoutPaymentMethodComponent } from './checkout-payment-method.component'; import createSpy = jasmine.createSpy; @@ -667,5 +673,26 @@ describe('CheckoutPaymentMethodComponent', () => { ).toEqual('button'); }); }); + + describe('focusCardAfterSelecting', () => { + it('should refocus the selected card after updating', fakeAsync(() => { + const card = document.createElement('cx-card'); + const selectButton = document.createElement('button'); + card.appendChild(selectButton); + card.tabIndex = 0; + document.body.appendChild(card); + selectButton.focus(); + component['isUpdating$'] = of(false); + spyOn(card, 'focus'); + spyOn(component['focusService'], 'findFirstFocusable').and.returnValue( + card + ); + + component.focusCardAfterSelecting(); + tick(16); // Wait for requestAnimationFrame + + expect(card.focus).toHaveBeenCalled(); + })); + }); }); }); diff --git a/feature-libs/checkout/base/components/checkout-payment-method/checkout-payment-method.component.ts b/feature-libs/checkout/base/components/checkout-payment-method/checkout-payment-method.component.ts index 1753de43e99..958383bc586 100644 --- a/feature-libs/checkout/base/components/checkout-payment-method/checkout-payment-method.component.ts +++ b/feature-libs/checkout/base/components/checkout-payment-method/checkout-payment-method.component.ts @@ -7,10 +7,10 @@ import { ChangeDetectionStrategy, Component, + inject, OnDestroy, OnInit, Optional, - inject, } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { ActiveCartFacade } from '@spartacus/cart/base/root'; @@ -27,8 +27,9 @@ import { PaymentDetails, TranslationService, UserPaymentService, + WindowRef, } from '@spartacus/core'; -import { Card, ICON_TYPE } from '@spartacus/storefront'; +import { Card, ICON_TYPE, SelectFocusUtility } from '@spartacus/storefront'; import { BehaviorSubject, combineLatest, @@ -58,6 +59,8 @@ export class CheckoutPaymentMethodComponent implements OnInit, OnDestroy { @Optional() protected featureConfigService = inject(FeatureConfigService, { optional: true, }); + @Optional() protected focusService = inject(SelectFocusUtility); + @Optional() protected windowRef = inject(WindowRef); cards$: Observable<{ content: Card; paymentMethod: PaymentDetails }[]>; iconTypes = ICON_TYPE; @@ -218,6 +221,38 @@ export class CheckoutPaymentMethodComponent implements OnInit, OnDestroy { ); this.savePaymentMethod(paymentDetails); + if (this.featureConfigService?.isEnabled('a11yFocusOnCardAfterSelecting')) { + this.focusCardAfterSelecting(); + } + } + + /** + * Restores the focus to the Card component after it has been selected and the checkout has finished updating. + * The focus is lost due to DOM changes making it otherwise impossible to target elements that have been removed. + */ + focusCardAfterSelecting(): void { + const cardNodes = Array.from( + this.windowRef?.document.querySelectorAll('cx-card') + ); + const triggeredCard = + this.windowRef?.document.activeElement?.closest('cx-card'); + + if (triggeredCard) { + const selectedCardIndex = cardNodes.indexOf(triggeredCard); + this.isUpdating$ + .pipe( + filter((isUpdating) => !isUpdating), + take(1) + ) + .subscribe(() => { + requestAnimationFrame(() => { + const selectedCard = this.windowRef?.document.querySelectorAll( + 'cx-card' + )[selectedCardIndex] as HTMLElement; + this.focusService.findFirstFocusable(selectedCard)?.focus(); + }); + }); + } } showNewPaymentForm(): void { diff --git a/feature-libs/checkout/package.json b/feature-libs/checkout/package.json index bc2d645a0ee..d238052853e 100644 --- a/feature-libs/checkout/package.json +++ b/feature-libs/checkout/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/checkout", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Checkout feature library for Spartacus", "keywords": [ "spartacus", @@ -22,7 +22,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/feature-libs/customer-ticketing/package.json b/feature-libs/customer-ticketing/package.json index 2a48a9bf3fb..106f6ab866e 100644 --- a/feature-libs/customer-ticketing/package.json +++ b/feature-libs/customer-ticketing/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/customer-ticketing", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Customer-Ticketing library for Spartacus", "keywords": [ "spartacus", @@ -22,7 +22,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/feature-libs/estimated-delivery-date/package.json b/feature-libs/estimated-delivery-date/package.json index 6b99d9dac16..ce859a42fbe 100644 --- a/feature-libs/estimated-delivery-date/package.json +++ b/feature-libs/estimated-delivery-date/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/estimated-delivery-date", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Estimated Delivery Date library for Spartacus", "keywords": [ "spartacus", @@ -22,7 +22,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/feature-libs/order/components/order-confirmation/order-guest-register-form/order-guest-register-form.component.ts b/feature-libs/order/components/order-confirmation/order-guest-register-form/order-guest-register-form.component.ts index d6890c11f8f..9b88f12f628 100644 --- a/feature-libs/order/components/order-confirmation/order-guest-register-form/order-guest-register-form.component.ts +++ b/feature-libs/order/components/order-confirmation/order-guest-register-form/order-guest-register-form.component.ts @@ -74,7 +74,7 @@ export class OrderGuestRegisterFormComponent implements OnDestroy { protected authService: AuthService, protected fb: UntypedFormBuilder ) { - useFeatureStyles('a11yPasswordVisibilityBtnValueOverflow'); + useFeatureStyles('a11yPasswordVisibliltyBtnValueOverflow'); } submit() { diff --git a/feature-libs/order/core/facade/my-account-v2-order-history.service.spec.ts b/feature-libs/order/core/facade/my-account-v2-order-history.service.spec.ts index 7239571eba0..a6852191d84 100644 --- a/feature-libs/order/core/facade/my-account-v2-order-history.service.spec.ts +++ b/feature-libs/order/core/facade/my-account-v2-order-history.service.spec.ts @@ -223,51 +223,6 @@ describe('MyAccountV2OrderHistoryService', () => { }) ); }); - }); - describe('getOrderDetailsV2', () => { - it('should load order details when not present in the store', fakeAsync(() => { - spyOn(userService, 'takeUserId').and.callThrough(); - const sub = service.getOrderDetailsV2(orderCode).subscribe(); - - actions$ - .pipe(ofType(OrderActions.LOAD_ORDER_BY_ID), take(1)) - .subscribe((action) => { - expect(action).toEqual( - new OrderActions.LoadOrderById({ - userId: OCC_USER_ID_CURRENT, - code: orderCode, - }) - ); - }); - - tick(); - expect(userService.takeUserId).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith( - new OrderActions.LoadOrderById({ - code: orderCode, - userId: OCC_USER_ID_CURRENT, - }) - ); - sub.unsubscribe(); - })); - - it('should be able to return order without loading when present in the store', () => { - spyOn(userService, 'takeUserId').and.callThrough(); - store.dispatch(new OrderActions.LoadOrderByIdSuccess(order1)); - service - .getOrderDetailsV2(orderCode) - .subscribe((data) => { - expect(data).toEqual(order1); - }) - .unsubscribe(); - expect(userService.takeUserId).not.toHaveBeenCalled(); - expect(store.dispatch).not.toHaveBeenCalledWith( - new OrderActions.LoadOrderById({ - code: orderCode, - userId: OCC_USER_ID_CURRENT, - }) - ); - }); it('should return `undefined` in case of error when loading order', () => { spyOn(userService, 'takeUserId').and.callThrough(); store.dispatch( @@ -277,16 +232,27 @@ describe('MyAccountV2OrderHistoryService', () => { }) ); service - .getOrderDetailsV2('orderX') + .getOrderDetails('orderX') .subscribe((data) => { expect(data).toEqual(undefined); }) .unsubscribe(); }); + it('should not emit when success and error are null or undefined', () => { + spyOn(service as any, 'getOrderDetailsState').and.returnValue( + of({ success: null, error: undefined, loading: false, value: null }) + ); + service.getOrderDetails(orderCode).subscribe(() => { + fail('Should not emit any value'); + }); + expect((service as any).getOrderDetailsState).toHaveBeenCalledWith( + orderCode + ); + }); }); describe('getOrderDetailsWithTracking', () => { it('should return order details with consignment tracking', () => { - spyOn(service, 'getOrderDetailsV2').and.returnValue(of(order1)); + spyOn(service, 'getOrderDetails').and.returnValue(of(order1)); spyOn(service, 'getConsignmentTracking').and.returnValue(of(tracking1)); service.getOrderDetailsWithTracking(orderCode).subscribe((result) => { expect(result).toEqual({ @@ -303,7 +269,7 @@ describe('MyAccountV2OrderHistoryService', () => { }, ], }); - expect(service.getOrderDetailsV2).toHaveBeenCalledWith(orderCode); + expect(service.getOrderDetails).toHaveBeenCalledWith(orderCode); expect(service.getConsignmentTracking).toHaveBeenCalledWith( orderCode, consignmentCode @@ -311,7 +277,7 @@ describe('MyAccountV2OrderHistoryService', () => { }); }); it('should return order details without consignment tracking', () => { - spyOn(service, 'getOrderDetailsV2').and.returnValue(of(order2)); + spyOn(service, 'getOrderDetails').and.returnValue(of(order2)); spyOn(service, 'getConsignmentTracking').and.stub(); service.getOrderDetailsWithTracking(orderCode).subscribe((result) => { expect(result).toEqual({ @@ -323,7 +289,7 @@ describe('MyAccountV2OrderHistoryService', () => { }, ], }); - expect(service.getOrderDetailsV2).toHaveBeenCalledWith(orderCode); + expect(service.getOrderDetails).toHaveBeenCalledWith(orderCode); expect(service.getConsignmentTracking).not.toHaveBeenCalled(); }); }); diff --git a/feature-libs/order/core/facade/my-account-v2-order-history.service.ts b/feature-libs/order/core/facade/my-account-v2-order-history.service.ts index b085ec41052..ddfc977695c 100644 --- a/feature-libs/order/core/facade/my-account-v2-order-history.service.ts +++ b/feature-libs/order/core/facade/my-account-v2-order-history.service.ts @@ -46,7 +46,7 @@ export class MyAccountV2OrderHistoryService { getOrderDetailsWithTracking( orderCode: string ): Observable { - return this.getOrderDetailsV2(orderCode).pipe( + return this.getOrderDetails(orderCode).pipe( switchMap((order: Order | undefined) => { //-----------------> filling consignment tracking const orderView: OrderView = { ...order }; @@ -195,27 +195,7 @@ export class MyAccountV2OrderHistoryService { }); } - //TODO: CXINT-2896: Remove this method in next major release - /** - * @deprecated since 2211.20. Use getOrderDetailsV2 instead - */ - getOrderDetails(code: string): Observable { - const loading$ = this.getOrderDetailsState(code).pipe( - auditTime(0), - tap((state) => { - if (!(state.loading || state.success || state.error)) { - this.loadOrderDetails(code); - } - }) - ); - return using( - () => loading$.subscribe(), - () => this.getOrderDetailsValue(code) - ); - } - - //TODO: CXINT-2896: Rename this method to `getOrderDetails` in next major release - getOrderDetailsV2(code: string): Observable { + getOrderDetails(code: string): Observable { const loading$ = this.getOrderDetailsState(code).pipe( auditTime(0), tap((state) => { diff --git a/feature-libs/order/package.json b/feature-libs/order/package.json index 58baca82ff4..fbb30da98f0 100644 --- a/feature-libs/order/package.json +++ b/feature-libs/order/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/order", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Order feature library for Spartacus", "keywords": [ "spartacus", @@ -22,7 +22,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/feature-libs/organization/account-summary/styles/components/_cx-account-summary-document-filter.scss b/feature-libs/organization/account-summary/styles/components/_cx-account-summary-document-filter.scss index 4eb3451f05b..ba7853466a3 100644 --- a/feature-libs/organization/account-summary/styles/components/_cx-account-summary-document-filter.scss +++ b/feature-libs/organization/account-summary/styles/components/_cx-account-summary-document-filter.scss @@ -29,6 +29,9 @@ cx-account-summary-document-filter { border-style: solid; border-color: var(--cx-color-light); border-radius: 4px; + @include forFeature('a11yImproveContrast') { + border-color: var(--cx-color-dark); + } } .cx-account-summary-document-filter-form-button-block { diff --git a/feature-libs/organization/administration/components/user/change-password-form/user-change-password-form.component.ts b/feature-libs/organization/administration/components/user/change-password-form/user-change-password-form.component.ts index 158c873139c..af235ce430e 100644 --- a/feature-libs/organization/administration/components/user/change-password-form/user-change-password-form.component.ts +++ b/feature-libs/organization/administration/components/user/change-password-form/user-change-password-form.component.ts @@ -30,7 +30,7 @@ export class UserChangePasswordFormComponent { protected formService: UserChangePasswordFormService, protected messageService: MessageService ) { - useFeatureStyles('a11yPasswordVisibilityBtnValueOverflow'); + useFeatureStyles('a11yPasswordVisibliltyBtnValueOverflow'); } save(form: UntypedFormGroup): void { diff --git a/feature-libs/organization/package.json b/feature-libs/organization/package.json index f268deec846..d65df059cf6 100644 --- a/feature-libs/organization/package.json +++ b/feature-libs/organization/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/organization", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Organization library for Spartacus", "keywords": [ "spartacus", @@ -22,7 +22,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/feature-libs/organization/unit-order/components/unit-level-order-history/unit-level-order-history.component.html b/feature-libs/organization/unit-order/components/unit-level-order-history/unit-level-order-history.component.html index e59ed3f9a8b..70aa5287313 100644 --- a/feature-libs/organization/unit-order/components/unit-level-order-history/unit-level-order-history.component.html +++ b/feature-libs/organization/unit-order/components/unit-level-order-history/unit-level-order-history.component.html @@ -168,10 +168,19 @@ class="cx-unit-level-order-history-value" > {{ order?.orgCustomer?.name }} - {{ - order?.orgCustomer?.email - }} + + {{ order?.orgCustomer?.email }} + + + {{ + order?.orgCustomer?.email + }} + +
diff --git a/feature-libs/organization/unit-order/components/unit-level-order-history/unit-level-order-history.component.ts b/feature-libs/organization/unit-order/components/unit-level-order-history/unit-level-order-history.component.ts index 31e4e7ecb18..9020ccfef5f 100644 --- a/feature-libs/organization/unit-order/components/unit-level-order-history/unit-level-order-history.component.ts +++ b/feature-libs/organization/unit-order/components/unit-level-order-history/unit-level-order-history.component.ts @@ -38,6 +38,7 @@ export class UnitLevelOrderHistoryComponent implements OnDestroy { protected translation: TranslationService ) { useFeatureStyles('a11yTruncatedTextForResponsiveView'); + useFeatureStyles('a11yTruncatedTextUnitLevelOrderHistory'); } orders$: Observable = this.unitOrdersFacade diff --git a/feature-libs/organization/unit-order/styles/components/unit-level-order-history/_unit-level-order-history.scss b/feature-libs/organization/unit-order/styles/components/unit-level-order-history/_unit-level-order-history.scss index ba68d4329d0..82cfb8df943 100644 --- a/feature-libs/organization/unit-order/styles/components/unit-level-order-history/_unit-level-order-history.scss +++ b/feature-libs/organization/unit-order/styles/components/unit-level-order-history/_unit-level-order-history.scss @@ -23,6 +23,9 @@ .cx-unit-level-order-history-filter-label-wrapper { width: 200px; + @include forFeature('a11yTruncatedTextUnitLevelOrderHistory') { + width: unset; + } border: 1px solid var(--cx-color-secondary); border-radius: 3px; } @@ -106,7 +109,7 @@ padding-bottom: 1.25rem; } } - + // TODO: Remove following .text-ellipsis block once 'a11yTruncatedTextUnitLevelOrderHistory' feature flag is removed .text-ellipsis { @include media-breakpoint-up(md) { overflow: hidden; diff --git a/feature-libs/pdf-invoices/package.json b/feature-libs/pdf-invoices/package.json index a4a9adf36e4..9709cd263c7 100644 --- a/feature-libs/pdf-invoices/package.json +++ b/feature-libs/pdf-invoices/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/pdf-invoices", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Invoices library for Spartacus", "keywords": [ "spartacus", @@ -22,7 +22,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/feature-libs/pickup-in-store/components/container/pdp-pickup-options-container/pdp-pickup-options-container.component.spec.ts b/feature-libs/pickup-in-store/components/container/pdp-pickup-options-container/pdp-pickup-options-container.component.spec.ts index 682bcba5ebc..4acea335894 100644 --- a/feature-libs/pickup-in-store/components/container/pdp-pickup-options-container/pdp-pickup-options-container.component.spec.ts +++ b/feature-libs/pickup-in-store/components/container/pdp-pickup-options-container/pdp-pickup-options-container.component.spec.ts @@ -20,7 +20,7 @@ import { LAUNCH_CALLER, LaunchDialogService, } from '@spartacus/storefront'; -import { Observable, of, Subscription } from 'rxjs'; +import { firstValueFrom, Observable, of, Subscription } from 'rxjs'; import { PdpPickupOptionsContainerComponent } from './pdp-pickup-options-container.component'; import { MockIntendedPickupLocationService } from '../../../core/facade/intended-pickup-location.service.spec'; @@ -53,7 +53,7 @@ class MockPickupLocationsSearchFacade implements PickupLocationsSearchFacade { getStockLevelAtStore = createSpy().and.returnValue( of({ stockLevel: { displayName: 'London School' } }) ); - getStoreDetails = createSpy(); + getStoreDetails = createSpy().and.returnValue(of({ name: 'London School' })); loadStoreDetails = createSpy(); } @@ -220,6 +220,34 @@ describe('PdpPickupOptionsComponent', () => { expect(component.openDialog).not.toHaveBeenCalled(); }); + it('should return undefined if intendedLocation.displayName is not defined', async () => { + spyOn( + intendedPickupLocationService, + 'getIntendedLocation' + ).and.returnValue(of({ pickupOption: 'pickup', displayName: undefined })); + spyOn(component, 'setIntendedPickupLocation'); + const displayLocation = await firstValueFrom( + component.displayPickupLocation$ + ); + expect(displayLocation).toEqual(undefined); + }); + + it('setIntendedPickupLocation should set pickupOption as delivery', async () => { + spyOn( + preferredStoreFacade, + 'getPreferredStoreWithProductInStock' + ).and.returnValue( + of({ name: 'London School', displayName: 'London School' }) + ); + component.setIntendedPickupLocation('productCode'); + expect( + intendedPickupLocationService.setIntendedLocation + ).toHaveBeenCalledWith('productCode', { + name: 'London School', + pickupOption: 'delivery', + }); + }); + it('should open dialog if displayName is not set and a11yPickupOptionsTabs disabled', () => { spyOn(featureConfigService, 'isEnabled').and.returnValue(false); spyOn(component, 'openDialog'); diff --git a/feature-libs/pickup-in-store/components/container/pdp-pickup-options-container/pdp-pickup-options-container.component.ts b/feature-libs/pickup-in-store/components/container/pdp-pickup-options-container/pdp-pickup-options-container.component.ts index 930a66c4458..b416ba30bf1 100644 --- a/feature-libs/pickup-in-store/components/container/pdp-pickup-options-container/pdp-pickup-options-container.component.ts +++ b/feature-libs/pickup-in-store/components/container/pdp-pickup-options-container/pdp-pickup-options-container.component.ts @@ -32,7 +32,7 @@ import { LAUNCH_CALLER, LaunchDialogService, } from '@spartacus/storefront'; -import { combineLatest, iif, Observable, of, Subscription } from 'rxjs'; +import { combineLatest, Observable, of, Subscription } from 'rxjs'; import { concatMap, filter, @@ -114,34 +114,15 @@ export class PdpPickupOptionsContainerComponent implements OnInit, OnDestroy { .getIntendedLocation(productCode) .pipe(map((intendedLocation) => ({ intendedLocation, productCode }))) ), - switchMap(({ intendedLocation, productCode }) => - iif( - () => !!intendedLocation && !!intendedLocation.displayName, - of(getProperty(intendedLocation, 'displayName')), - this.preferredStoreFacade - .getPreferredStoreWithProductInStock(productCode) - .pipe( - map(({ name }) => name), - tap((storeName) => - this.pickupLocationsSearchService.loadStoreDetails(storeName) - ), - concatMap((storeName: string) => - this.pickupLocationsSearchService.getStoreDetails(storeName) - ), - filter((storeDetails) => !!storeDetails), - tap((storeDetails) => { - this.intendedPickupLocationService.setIntendedLocation( - productCode, - { - ...storeDetails, - pickupOption: 'delivery', - } - ); - }) - ) - ) - ), - tap(() => (this.displayNameIsSet = true)) + switchMap(({ intendedLocation, productCode }) => { + if (intendedLocation?.displayName) { + this.displayNameIsSet = true; + return of(getProperty(intendedLocation, 'displayName')); + } + + this.setIntendedPickupLocation(productCode); + return of(undefined); + }) ); this.intendedPickupLocation$ = this.currentProductService.getProduct().pipe( @@ -177,6 +158,29 @@ export class PdpPickupOptionsContainerComponent implements OnInit, OnDestroy { this.subscription.unsubscribe(); } + setIntendedPickupLocation(productCode: string) { + this.subscription.add( + this.preferredStoreFacade + .getPreferredStoreWithProductInStock(productCode) + .pipe( + map(({ name }) => name), + tap((storeName) => + this.pickupLocationsSearchService.loadStoreDetails(storeName) + ), + concatMap((storeName: string) => + this.pickupLocationsSearchService.getStoreDetails(storeName) + ), + filter((storeDetails) => !!storeDetails) + ) + .subscribe((storeDetails) => { + this.intendedPickupLocationService.setIntendedLocation(productCode, { + ...storeDetails, + pickupOption: 'delivery', + }); + }) + ); + } + // TODO: Make argument required once 'a11yDialogTriggerRefocus' feature flag is removed. /** * @deprecated since 2211.28.0 - The use of TriggerElement param will become mandatory. diff --git a/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.ts b/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.ts index 4a9a126017c..9ca1ec5fb60 100644 --- a/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.ts +++ b/feature-libs/pickup-in-store/components/presentational/pickup-options/pickup-options.component.ts @@ -18,6 +18,7 @@ import { ViewChild, TemplateRef, Optional, + ChangeDetectionStrategy, } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { FeatureConfigService, useFeatureStyles } from '@spartacus/core'; @@ -32,6 +33,7 @@ import { PickupOptionsTabs } from './pickup-options.model'; @Component({ selector: 'cx-pickup-options', templateUrl: './pickup-options.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class PickupOptionsComponent implements OnChanges, AfterViewInit, OnDestroy diff --git a/feature-libs/pickup-in-store/package.json b/feature-libs/pickup-in-store/package.json index dd74c094ed2..b6a39271e1a 100644 --- a/feature-libs/pickup-in-store/package.json +++ b/feature-libs/pickup-in-store/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/pickup-in-store", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "", "keywords": [ "spartacus", @@ -22,7 +22,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/feature-libs/pickup-in-store/styles/_set-preferred-store.scss b/feature-libs/pickup-in-store/styles/_set-preferred-store.scss index a016eccd775..94294d63e3b 100644 --- a/feature-libs/pickup-in-store/styles/_set-preferred-store.scss +++ b/feature-libs/pickup-in-store/styles/_set-preferred-store.scss @@ -16,6 +16,7 @@ text-decoration: underline; border: none; background: none; + color: inherit; @include forFeature('a11yVisibleFocusOverflows') { padding-inline-start: 0; diff --git a/feature-libs/product-configurator/common/assets/translations/en/configurator.json b/feature-libs/product-configurator/common/assets/translations/en/configurator.json index 5cf31a3fe2f..94f3018f201 100644 --- a/feature-libs/product-configurator/common/assets/translations/en/configurator.json +++ b/feature-libs/product-configurator/common/assets/translations/en/configurator.json @@ -196,8 +196,8 @@ "iconIncomplete": "Group has required attributes without selected values.", "iconComplete": "Group is complete.", "iconSubGroup": "Group has a sub-group.", - "next": "Navigate to next group.", - "previous": "Navigate to previous group.", + "next": "Navigate to next group: {{ group }}", + "previous": "Navigate to previous group: {{ group }}", "showMoreProductInfo": "Show more information for product {{ product }} or continue to configuration.", "showLessProductInfo": "Show less information for product {{ product }} or continue to configuration.", "productName": "Product Name", diff --git a/feature-libs/product-configurator/package.json b/feature-libs/product-configurator/package.json index 464a9f355e8..4dc3c4cbb14 100644 --- a/feature-libs/product-configurator/package.json +++ b/feature-libs/product-configurator/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/product-configurator", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Product configurator feature library for Spartacus", "keywords": [ "spartacus", @@ -22,7 +22,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.ts b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.ts index 8fe40f18375..ecac363e22c 100644 --- a/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.ts +++ b/feature-libs/product-configurator/rulebased/components/attribute/types/multi-selection-image/configurator-attribute-multi-selection-image.component.ts @@ -53,6 +53,7 @@ export class ConfiguratorAttributeMultiSelectionImageComponent ); useFeatureStyles('productConfiguratorAttributeTypesV2'); + useFeatureStyles('a11yDifferentiateFocusedAndSelected'); } attributeCheckBoxForms = new Array(); diff --git a/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.html b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.html index fc43361b3e8..6dd627c3884 100644 --- a/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.html +++ b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.html @@ -4,7 +4,13 @@ class="btn btn-block btn-secondary cx-previous" [disabled]="isFirstGroup(configuration.owner) | async" (click)="onPrevious(configuration)" - [attr.aria-label]="'configurator.a11y.previous' | cxTranslate" + [attr.aria-label]=" + 'configurator.a11y.previous' + | cxTranslate + : { + group: getPreviousGroupDescription(configuration) | async, + } + " > {{ 'configurator.button.previous' | cxTranslate }} @@ -12,7 +18,13 @@ class="btn btn-block btn-secondary cx-next" [disabled]="isLastGroup(configuration.owner) | async" (click)="onNext(configuration)" - [attr.aria-label]="'configurator.a11y.next' | cxTranslate" + [attr.aria-label]=" + 'configurator.a11y.next' + | cxTranslate + : { + group: getNextGroupDescription(configuration) | async, + } + " > {{ 'configurator.button.next' | cxTranslate }} diff --git a/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.spec.ts b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.spec.ts index 13e5028491a..1e1d95e56dc 100644 --- a/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.spec.ts +++ b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.spec.ts @@ -45,6 +45,14 @@ class MockConfiguratorGroupsService { } navigateToGroup() {} + + getPreviousGroupDescription() { + return of('previousGroupDescription'); + } + + getNextGroupDescription() { + return of('nextGroupDescription'); + } } const groups: Configurator.Group = { @@ -336,7 +344,7 @@ describe('ConfigPreviousNextButtonsComponent', () => { 'cx-previous', 0, 'aria-label', - 'configurator.a11y.previous', + 'configurator.a11y.previous group:previousGroupDescription', 'configurator.button.previous' ); }); @@ -349,7 +357,7 @@ describe('ConfigPreviousNextButtonsComponent', () => { 'cx-next', 0, 'aria-label', - 'configurator.a11y.next', + 'configurator.a11y.next group:nextGroupDescription', 'configurator.button.next' ); }); diff --git a/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.ts b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.ts index 2d190fce600..bc22b0ef9de 100644 --- a/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.ts +++ b/feature-libs/product-configurator/rulebased/components/previous-next-buttons/configurator-previous-next-buttons.component.ts @@ -76,6 +76,22 @@ export class ConfiguratorPreviousNextButtonsComponent { ); } + getPreviousGroupDescription( + configuration: Configurator.Configuration + ): Observable { + return this.configuratorGroupsService.getPreviousGroupDescription( + configuration + ); + } + + getNextGroupDescription( + configuration: Configurator.Configuration + ): Observable { + return this.configuratorGroupsService.getNextGroupDescription( + configuration + ); + } + isFirstGroup(owner: CommonConfigurator.Owner): Observable { return this.configuratorGroupsService .getPreviousGroupId(owner) diff --git a/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.html b/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.html index bff0a0866c9..893d7a3ec00 100644 --- a/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.html +++ b/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.html @@ -46,7 +46,7 @@
{{ product.name }} @@ -54,7 +54,7 @@
{{ product.code }} @@ -62,9 +62,7 @@
{{ product.description }} diff --git a/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.spec.ts b/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.spec.ts index b348034f632..a7fcd42d0fe 100644 --- a/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.spec.ts +++ b/feature-libs/product-configurator/rulebased/components/product-title/configurator-product-title.component.spec.ts @@ -704,40 +704,40 @@ describe('ConfigProductTitleComponent', () => { ); }); - it("should contain span element with 'aria-label' attribute for product name that defines an accessible name to label the current element", () => { + it("should contain span element with 'title' attribute for product name that defines an accessible name to label the current element", () => { CommonConfiguratorTestUtilsService.expectElementContainsA11y( expect, htmlElem, 'span', undefined, 2, - 'aria-label', + 'title', 'configurator.a11y.productName', mockProduct.name ); }); - it("should contain span element with 'aria-label' attribute for product code that defines an accessible name to label the current element", () => { + it("should contain span element with 'title' attribute for product code that defines an accessible name to label the current element", () => { CommonConfiguratorTestUtilsService.expectElementContainsA11y( expect, htmlElem, 'span', undefined, 3, - 'aria-label', + 'title', 'configurator.a11y.productCode', mockProduct.code ); }); - it("should contain span element with 'aria-label' attribute for product description that defines an accessible name to label the current element", () => { + it("should contain span element with 'title' attribute for product description that defines an accessible name to label the current element", () => { CommonConfiguratorTestUtilsService.expectElementContainsA11y( expect, htmlElem, 'span', undefined, 4, - 'aria-label', + 'title', 'configurator.a11y.productDescription', mockProduct.description ); diff --git a/feature-libs/product-configurator/rulebased/components/show-more/configurator-show-more.component.html b/feature-libs/product-configurator/rulebased/components/show-more/configurator-show-more.component.html index fa7f9bbd62d..633fe94eb67 100644 --- a/feature-libs/product-configurator/rulebased/components/show-more/configurator-show-more.component.html +++ b/feature-libs/product-configurator/rulebased/components/show-more/configurator-show-more.component.html @@ -1,16 +1,7 @@ - + + + + + + `, }) class MockDynamicSlotComponent { @Input() @@ -53,6 +63,8 @@ class MockUrlPipe implements PipeTransform { transform(): void {} } +let expectedGreeting = `miniLogin.userGreeting name:${mockUserDetails.name}`; + describe('LoginComponent', () => { let component: LoginComponent; let fixture: ComponentFixture; @@ -102,6 +114,12 @@ describe('LoginComponent', () => { expect(user).toEqual(mockUserDetails); }); + it('should have greeting details when token exists', () => { + let greeting; + component.greeting$.subscribe((result) => (greeting = result)); + expect(greeting).toEqual(expectedGreeting); + }); + it('should not get user details when token is lacking', () => { spyOn(authService, 'isUserLoggedIn').and.returnValue(of(false)); @@ -125,7 +143,7 @@ describe('LoginComponent', () => { it('should display greeting message when the user is logged in', () => { expect(fixture.debugElement.nativeElement.innerText).toContain( - 'miniLogin.userGreeting name:First Last' + expectedGreeting ); }); @@ -138,5 +156,22 @@ describe('LoginComponent', () => { 'miniLogin.signInRegister' ); }); + + it('should contain the dynamic slot: HeaderLinks', () => { + spyOn(component, 'onRootNavBtnAdded').and.callThrough(); + component.ngOnInit(); + fixture.detectChanges(); + expectedGreeting = 'Testing;'; + const expectedRootNavBtn = fixture.debugElement.query( + By.css('cx-navigation-ui nav ul li:first-child button') + ); + const mockedMutation = { + target: expectedRootNavBtn.nativeNode, + } as MutationRecord; + expect(expectedRootNavBtn.nativeElement.ariaLabel).toBe(null); + component.onRootNavBtnAdded(mockedMutation, expectedGreeting); + expect(expectedRootNavBtn).not.toBeNull(); + expect(expectedRootNavBtn.nativeElement.ariaLabel).toBe(expectedGreeting); + }); }); }); diff --git a/feature-libs/user/account/components/login/login.component.ts b/feature-libs/user/account/components/login/login.component.ts index f41b530eb97..441b1e93d91 100644 --- a/feature-libs/user/account/components/login/login.component.ts +++ b/feature-libs/user/account/components/login/login.component.ts @@ -5,7 +5,11 @@ */ import { Component, OnInit } from '@angular/core'; -import { AuthService, useFeatureStyles } from '@spartacus/core'; +import { + AuthService, + TranslationService, + useFeatureStyles, +} from '@spartacus/core'; import { User, UserAccountFacade } from '@spartacus/user/account/root'; import { Observable, of } from 'rxjs'; import { switchMap } from 'rxjs/operators'; @@ -16,10 +20,12 @@ import { switchMap } from 'rxjs/operators'; }) export class LoginComponent implements OnInit { user$: Observable; + greeting$: Observable; constructor( private auth: AuthService, - private userAccount: UserAccountFacade + private userAccount: UserAccountFacade, + private translation: TranslationService ) { useFeatureStyles('a11yMyAccountLinkOutline'); } @@ -34,5 +40,16 @@ export class LoginComponent implements OnInit { } }) ); + this.greeting$ = this.user$.pipe( + switchMap((user) => + this.translation.translate(`miniLogin.userGreeting`, { + name: user?.name, + }) + ) + ); + } + + onRootNavBtnAdded($event: MutationRecord, greeting: string) { + ($event.target as HTMLElement).setAttribute('aria-label', greeting); } } diff --git a/feature-libs/user/account/components/login/login.module.ts b/feature-libs/user/account/components/login/login.module.ts index 1627c52ec99..aa6549aeb85 100644 --- a/feature-libs/user/account/components/login/login.module.ts +++ b/feature-libs/user/account/components/login/login.module.ts @@ -13,11 +13,18 @@ import { provideDefaultConfig, UrlModule, } from '@spartacus/core'; -import { PageSlotModule } from '@spartacus/storefront'; +import { DomChangeModule, PageSlotModule } from '@spartacus/storefront'; import { LoginComponent } from './login.component'; @NgModule({ - imports: [CommonModule, RouterModule, UrlModule, PageSlotModule, I18nModule], + imports: [ + CommonModule, + RouterModule, + UrlModule, + PageSlotModule, + I18nModule, + DomChangeModule, + ], providers: [ provideDefaultConfig({ cmsComponents: { diff --git a/feature-libs/user/account/components/otp-login-form/otp-login-form.component.ts b/feature-libs/user/account/components/otp-login-form/otp-login-form.component.ts index 88848cd59e2..4ac25fa05bf 100644 --- a/feature-libs/user/account/components/otp-login-form/otp-login-form.component.ts +++ b/feature-libs/user/account/components/otp-login-form/otp-login-form.component.ts @@ -59,7 +59,7 @@ export class OneTimePasswordLoginFormComponent { @HostBinding('class.user-form') style = true; constructor() { - useFeatureStyles('a11yPasswordVisibilityBtnValueOverflow'); + useFeatureStyles('a11yPasswordVisibliltyBtnValueOverflow'); } onSubmit(): void { diff --git a/feature-libs/user/karma.conf.js b/feature-libs/user/karma.conf.js index f5475ce7ced..8d375c20da3 100644 --- a/feature-libs/user/karma.conf.js +++ b/feature-libs/user/karma.conf.js @@ -32,8 +32,7 @@ module.exports = function (config) { global: { statements: 90, lines: 90, - //TODO CXSPA-8984 change branches to 75 after fix - branches: 70, + branches: 75, functions: 80, }, }, diff --git a/feature-libs/user/package.json b/feature-libs/user/package.json index 9fe2fcda3d8..7453d025158 100644 --- a/feature-libs/user/package.json +++ b/feature-libs/user/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/user", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "User feature library for Spartacus", "keywords": [ "spartacus", @@ -22,7 +22,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/feature-libs/user/profile/components/register/register.component.ts b/feature-libs/user/profile/components/register/register.component.ts index dcf000559c5..47af5f3ea34 100644 --- a/feature-libs/user/profile/components/register/register.component.ts +++ b/feature-libs/user/profile/components/register/register.component.ts @@ -124,7 +124,7 @@ export class RegisterComponent implements OnInit, OnDestroy { protected authConfigService: AuthConfigService, protected registerComponentService: RegisterComponentService ) { - useFeatureStyles('a11yPasswordVisibilityBtnValueOverflow'); + useFeatureStyles('a11yPasswordVisibliltyBtnValueOverflow'); } ngOnInit() { diff --git a/feature-libs/user/profile/components/reset-password/reset-password-component.service.spec.ts b/feature-libs/user/profile/components/reset-password/reset-password-component.service.spec.ts index e6b4edac033..501354a2b96 100644 --- a/feature-libs/user/profile/components/reset-password/reset-password-component.service.spec.ts +++ b/feature-libs/user/profile/components/reset-password/reset-password-component.service.spec.ts @@ -14,6 +14,7 @@ import { RoutingService, } from '@spartacus/core'; import { + CustomFormValidators, FormErrorsModule, PasswordVisibilityToggleModule, } from '@spartacus/storefront'; @@ -39,10 +40,12 @@ class MockUserPasswordFacade implements Partial { class MockRoutingService { go = createSpy().and.stub(); + getRouterState() { return routerState$; } } + class MockGlobalMessageService { add = createSpy().and.stub(); } @@ -82,166 +85,251 @@ describe('ResetPasswordComponentService', () => { ], }).compileComponents(); }); + describe(' - ', () => { + beforeEach(() => { + featureConfigService = TestBed.inject(FeatureConfigService); + spyOn(featureConfigService, 'isEnabled').and.returnValue(true); - beforeEach(() => { - featureConfigService = TestBed.inject(FeatureConfigService); - spyOn(featureConfigService, 'isEnabled').and.returnValue(true); - - service = TestBed.inject(ResetPasswordComponentService); - - userPasswordService = TestBed.inject(UserPasswordFacade); - routingService = TestBed.inject(RoutingService); - globalMessageService = TestBed.inject(GlobalMessageService); + service = TestBed.inject(ResetPasswordComponentService); - password = service.form.controls.password; - passwordConfirm = service.form.controls.passwordConfirm; - }); - - it('should create', () => { - expect(service).toBeTruthy(); - }); + userPasswordService = TestBed.inject(UserPasswordFacade); + routingService = TestBed.inject(RoutingService); + globalMessageService = TestBed.inject(GlobalMessageService); - describe('isUpdating$', () => { - it('should return true', () => { - service['busy$'].next(true); - let result; - service.isUpdating$.subscribe((value) => (result = value)).unsubscribe(); - expect(result).toBeTruthy(); - expect(service.form.disabled).toBeTruthy(); + password = service.form.controls.password; + passwordConfirm = service.form.controls.passwordConfirm; }); - it('should return false', () => { - service['busy$'].next(false); - let result; - service.isUpdating$.subscribe((value) => (result = value)).unsubscribe(); - expect(result).toBeFalsy(); - expect(service.form.disabled).toBeFalsy(); + it('should create', () => { + expect(service).toBeTruthy(); }); - }); - - describe('resetToken$', () => { - it('should return token', () => { - let result; - service.resetToken$.subscribe((value) => (result = value)).unsubscribe(); - expect(result).toEqual(resetToken); - }); - - it('should not return token', () => { - routerState$.next({ - state: { - queryParams: {}, - }, - }); - let result; - service.resetToken$.subscribe((value) => (result = value)).unsubscribe(); - expect(result).toBeFalsy(); - }); - }); - - describe('reset', () => { - describe('success', () => { - beforeEach(() => { - password.setValue('QwePas123!'); - passwordConfirm.setValue('QwePas123!'); - }); - it('should reset password', () => { - spyOn(userPasswordService, 'reset').and.callThrough(); - service.resetPassword(resetToken); - expect(userPasswordService.reset).toHaveBeenCalledWith( - resetToken, - 'QwePas123!' - ); + describe('isUpdating$', () => { + it('should return true', () => { + service['busy$'].next(true); + let result; + service.isUpdating$ + .subscribe((value) => (result = value)) + .unsubscribe(); + expect(result).toBeTruthy(); + expect(service.form.disabled).toBeTruthy(); }); - it('should show message', () => { - service.resetPassword(resetToken); - expect(globalMessageService.add).toHaveBeenCalledWith( - { key: 'forgottenPassword.passwordResetSuccess' }, - GlobalMessageType.MSG_TYPE_CONFIRMATION - ); + it('should return false', () => { + service['busy$'].next(false); + let result; + service.isUpdating$ + .subscribe((value) => (result = value)) + .unsubscribe(); + expect(result).toBeFalsy(); + expect(service.form.disabled).toBeFalsy(); }); + }); - it('should reroute to the login page', () => { - service.resetPassword(resetToken); - expect(routingService.go).toHaveBeenCalledWith({ cxRoute: 'login' }); + describe('resetToken$', () => { + it('should return token', () => { + let result; + service.resetToken$ + .subscribe((value) => (result = value)) + .unsubscribe(); + expect(result).toEqual(resetToken); }); - it('should reset form', () => { - spyOn(service.form, 'reset').and.callThrough(); - service.resetPassword(resetToken); - expect(service.form.reset).toHaveBeenCalled(); + it('should not return token', () => { + routerState$.next({ + state: { + queryParams: {}, + }, + }); + let result; + service.resetToken$ + .subscribe((value) => (result = value)) + .unsubscribe(); + expect(result).toBeFalsy(); }); }); - describe('error', () => { - describe('valid form', () => { + describe('reset', () => { + describe('success', () => { beforeEach(() => { password.setValue('QwePas123!'); passwordConfirm.setValue('QwePas123!'); }); - it('should show error message', () => { - const error = new HttpErrorModel(); - error.details = [{ message: 'error message' }]; - spyOn(userPasswordService, 'reset').and.returnValue( - throwError(() => error) + it('should reset password', () => { + spyOn(userPasswordService, 'reset').and.callThrough(); + service.resetPassword(resetToken); + expect(userPasswordService.reset).toHaveBeenCalledWith( + resetToken, + 'QwePas123!' ); + }); + + it('should show message', () => { service.resetPassword(resetToken); expect(globalMessageService.add).toHaveBeenCalledWith( - { raw: 'error message' }, - GlobalMessageType.MSG_TYPE_ERROR + { key: 'forgottenPassword.passwordResetSuccess' }, + GlobalMessageType.MSG_TYPE_CONFIRMATION ); }); - it('should not show error message', () => { - spyOn(userPasswordService, 'reset').and.returnValue( - throwError(() => null) - ); + it('should reroute to the login page', () => { service.resetPassword(resetToken); - expect(globalMessageService.add).not.toHaveBeenCalled(); + expect(routingService.go).toHaveBeenCalledWith({ cxRoute: 'login' }); }); - it('should not show error message', () => { - spyOn(userPasswordService, 'reset').and.returnValue( - throwError(() => ({})) - ); + it('should reset form', () => { + spyOn(service.form, 'reset').and.callThrough(); service.resetPassword(resetToken); - expect(globalMessageService.add).not.toHaveBeenCalled(); + expect(service.form.reset).toHaveBeenCalled(); }); }); - }); - it('should not reset invalid form', () => { - spyOn(userPasswordService, 'reset').and.returnValue( - throwError(() => ({})) - ); - passwordConfirm.setValue('Diff123!'); - service.resetPassword(resetToken); - expect(userPasswordService.reset).not.toHaveBeenCalled(); - expect(globalMessageService.add).not.toHaveBeenCalled(); - expect(routingService.go).not.toHaveBeenCalled(); + describe('error', () => { + describe('valid form', () => { + beforeEach(() => { + password.setValue('QwePas123!'); + passwordConfirm.setValue('QwePas123!'); + }); + + it('should show error message', () => { + const error = new HttpErrorModel(); + error.details = [{ message: 'error message' }]; + spyOn(userPasswordService, 'reset').and.returnValue( + throwError(() => error) + ); + service.resetPassword(resetToken); + expect(globalMessageService.add).toHaveBeenCalledWith( + { raw: 'error message' }, + GlobalMessageType.MSG_TYPE_ERROR + ); + }); + + it('should not show error message when error is null', () => { + spyOn(userPasswordService, 'reset').and.returnValue( + throwError(() => null) + ); + service.resetPassword(resetToken); + expect(globalMessageService.add).not.toHaveBeenCalled(); + }); + + it('should not display an error message when HttpErrorModel has no details', () => { + spyOn(userPasswordService, 'reset').and.returnValue( + throwError(() => new HttpErrorModel()) + ); + service.resetPassword(resetToken); + expect(globalMessageService.add).not.toHaveBeenCalled(); + }); + }); + }); + + it('should not reset invalid form', () => { + spyOn(userPasswordService, 'reset').and.returnValue( + throwError(() => ({})) + ); + passwordConfirm.setValue('Diff123!'); + service.resetPassword(resetToken); + expect(userPasswordService.reset).not.toHaveBeenCalled(); + expect(globalMessageService.add).not.toHaveBeenCalled(); + expect(routingService.go).not.toHaveBeenCalled(); + }); }); }); - describe('password validators', () => { - it('should have new validators when feature flag isEnabled', () => { - const passwordControl = service.form.get( - 'password' - ) as UntypedFormControl; - const validators = passwordControl.validator - ? passwordControl.validator({} as any) - : []; - - expect(passwordControl).toBeTruthy(); - expect(validators).toEqual({ - required: true, - cxMinOneDigit: true, - cxMinOneUpperCaseCharacter: true, - cxMinOneSpecialCharacter: true, - cxMinEightCharactersLength: true, - cxMaxCharactersLength: true, + let passwordControl: UntypedFormControl; + + describe('when formErrorsDescriptiveMessages are enabled', () => { + beforePasswordValidatorCase([ + 'formErrorsDescriptiveMessages', + 'enableSecurePasswordValidation', + ]); + + it('should use securePasswordValidators', () => { + expect(passwordControl).toBeTruthy(); + expect((service as any).passwordValidators).toEqual( + CustomFormValidators.securePasswordValidators + ); }); }); + + describe('when formErrorsDescriptiveMessages and enableConsecutiveCharactersPasswordRequirement are enabled', () => { + beforePasswordValidatorCase([ + 'formErrorsDescriptiveMessages', + 'enableConsecutiveCharactersPasswordRequirement', + ]); + + it('should use passwordValidators with noConsecutiveCharacters', () => { + expect((service as any).passwordValidators).toEqual([ + ...CustomFormValidators.passwordValidators, + CustomFormValidators.noConsecutiveCharacters, + ]); + }); + }); + + describe('when only formErrorsDescriptiveMessages is enabled', () => { + beforePasswordValidatorCase(['formErrorsDescriptiveMessages']); + + it('should use passwordValidators', () => { + expect(passwordControl).toBeTruthy(); + expect((service as any).passwordValidators).toEqual( + CustomFormValidators.passwordValidators + ); + }); + }); + + describe('when only enableSecurePasswordValidation is enabled', () => { + beforePasswordValidatorCase(['enableSecurePasswordValidation']); + + it('should use securePasswordValidator', () => { + expect(passwordControl).toBeTruthy(); + + expect((service as any).passwordValidators).toEqual([ + CustomFormValidators.securePasswordValidator, + ]); + }); + }); + + describe('when only enableConsecutiveCharactersPasswordRequirement is enabled', () => { + beforePasswordValidatorCase([ + 'enableConsecutiveCharactersPasswordRequirement', + ]); + + it('should use strongPasswordValidator', () => { + expect(passwordControl).toBeTruthy(); + + expect((service as any).passwordValidators).toEqual([ + CustomFormValidators.strongPasswordValidator, + ]); + }); + }); + + describe('when no feature flags are enabled', () => { + beforePasswordValidatorCase([]); + + it('should use passwordValidator', () => { + expect(passwordControl).toBeTruthy(); + expect((service as any).passwordValidators).toEqual([ + CustomFormValidators.passwordValidator, + ]); + }); + }); + + function beforePasswordValidatorCase(featuresEnabled: string[]) { + beforeEach(() => { + featureConfigService = TestBed.inject(FeatureConfigService); + spyOn(featureConfigService, 'isEnabled').and.callFake( + (flag: string) => { + return featuresEnabled.includes(flag); + } + ); + + service = TestBed.inject(ResetPasswordComponentService); + passwordControl = service.form.get('password') as UntypedFormControl; + + userPasswordService = TestBed.inject(UserPasswordFacade); + routingService = TestBed.inject(RoutingService); + globalMessageService = TestBed.inject(GlobalMessageService); + }); + } }); }); diff --git a/feature-libs/user/profile/components/reset-password/reset-password.component.ts b/feature-libs/user/profile/components/reset-password/reset-password.component.ts index 9da2a6548c6..98e6bd93b45 100644 --- a/feature-libs/user/profile/components/reset-password/reset-password.component.ts +++ b/feature-libs/user/profile/components/reset-password/reset-password.component.ts @@ -23,7 +23,7 @@ export class ResetPasswordComponent { token$: Observable = this.service.resetToken$; constructor(protected service: ResetPasswordComponentService) { - useFeatureStyles('a11yPasswordVisibilityBtnValueOverflow'); + useFeatureStyles('a11yPasswordVisibliltyBtnValueOverflow'); } onSubmit(token: string) { diff --git a/feature-libs/user/profile/components/update-email/update-email.component.ts b/feature-libs/user/profile/components/update-email/update-email.component.ts index 3c36f40256e..f31c40f7c66 100644 --- a/feature-libs/user/profile/components/update-email/update-email.component.ts +++ b/feature-libs/user/profile/components/update-email/update-email.component.ts @@ -18,7 +18,7 @@ import { UpdateEmailComponentService } from './update-email-component.service'; }) export class UpdateEmailComponent { constructor(protected service: UpdateEmailComponentService) { - useFeatureStyles('a11yPasswordVisibilityBtnValueOverflow'); + useFeatureStyles('a11yPasswordVisibliltyBtnValueOverflow'); } form: UntypedFormGroup = this.service.form; diff --git a/feature-libs/user/profile/components/update-password/my-account-v2-password.component.ts b/feature-libs/user/profile/components/update-password/my-account-v2-password.component.ts index dab93b19991..34abb9d512d 100644 --- a/feature-libs/user/profile/components/update-password/my-account-v2-password.component.ts +++ b/feature-libs/user/profile/components/update-password/my-account-v2-password.component.ts @@ -27,7 +27,7 @@ export class MyAccountV2PasswordComponent { isUpdating$: Observable = this.service.isUpdating$; constructor() { - useFeatureStyles('a11yPasswordVisibilityBtnValueOverflow'); + useFeatureStyles('a11yPasswordVisibliltyBtnValueOverflow'); } onSubmit(): void { diff --git a/feature-libs/user/profile/components/update-password/update-password.component.ts b/feature-libs/user/profile/components/update-password/update-password.component.ts index b5a636bcd5e..f40f4668cab 100644 --- a/feature-libs/user/profile/components/update-password/update-password.component.ts +++ b/feature-libs/user/profile/components/update-password/update-password.component.ts @@ -27,7 +27,7 @@ export class UpdatePasswordComponent { }); constructor(protected service: UpdatePasswordComponentService) { - useFeatureStyles('a11yPasswordVisibilityBtnValueOverflow'); + useFeatureStyles('a11yPasswordVisibliltyBtnValueOverflow'); } form: UntypedFormGroup = this.service.form; diff --git a/integration-libs/cdc/package.json b/integration-libs/cdc/package.json index 03224d0b185..125a0da5724 100644 --- a/integration-libs/cdc/package.json +++ b/integration-libs/cdc/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/cdc", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Customer Data Cloud Integration library for Spartacus", "keywords": [ "spartacus", @@ -19,7 +19,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/integration-libs/cdp/package.json b/integration-libs/cdp/package.json index 47eb8a92461..980602467ff 100644 --- a/integration-libs/cdp/package.json +++ b/integration-libs/cdp/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/cdp", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Customer Data Platform Integration library for Spartacus", "keywords": [ "spartacus", @@ -19,7 +19,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/integration-libs/cds/package.json b/integration-libs/cds/package.json index b447e122c82..770e4be78b4 100644 --- a/integration-libs/cds/package.json +++ b/integration-libs/cds/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/cds", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Context Driven Service library for Spartacus", "keywords": [ "spartacus", @@ -20,7 +20,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/integration-libs/cpq-quote/package.json b/integration-libs/cpq-quote/package.json index 35e7d216887..e5247649665 100644 --- a/integration-libs/cpq-quote/package.json +++ b/integration-libs/cpq-quote/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/cpq-quote", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "cpq-quote library for Spartacus", "keywords": [ "spartacus", @@ -17,7 +17,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/integration-libs/digital-payments/package.json b/integration-libs/digital-payments/package.json index 6405aa24b1d..1ef1a9d114a 100644 --- a/integration-libs/digital-payments/package.json +++ b/integration-libs/digital-payments/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/digital-payments", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Digital Payments Integration library for Spartacus", "keywords": [ "spartacus", @@ -18,7 +18,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/integration-libs/epd-visualization/package.json b/integration-libs/epd-visualization/package.json index 99eff0f7aea..24aeea6c17e 100644 --- a/integration-libs/epd-visualization/package.json +++ b/integration-libs/epd-visualization/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/epd-visualization", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "SAP Enterprise Product Development Visualization integration library for Spartacus", "keywords": [ "spartacus", @@ -29,7 +29,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", @@ -38,11 +38,11 @@ "@angular/forms": "^18.2.9", "@angular/router": "^18.2.9", "@sapui5/ts-types-esm": "1.120.1", - "@spartacus/cart": "2211.32.0-1", - "@spartacus/core": "2211.32.0-1", - "@spartacus/schematics": "2211.32.0-1", - "@spartacus/storefront": "2211.32.0-1", - "@spartacus/styles": "2211.32.0-1", + "@spartacus/cart": "2211.32.0", + "@spartacus/core": "2211.32.0", + "@spartacus/schematics": "2211.32.0", + "@spartacus/storefront": "2211.32.0", + "@spartacus/styles": "2211.32.0", "bootstrap": "^4.6.2", "rxjs": "^7.8.0" }, diff --git a/integration-libs/omf/package.json b/integration-libs/omf/package.json index f13e4909acd..4e82ad2b19b 100644 --- a/integration-libs/omf/package.json +++ b/integration-libs/omf/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/omf", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "SAP Order Management Foundation Integration", "keywords": [ "spartacus", @@ -17,7 +17,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", 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 53fb1195bd6..398bc988714 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 @@ -8,21 +8,22 @@ import { RouterState, RoutingService, UserIdService, + WindowRef, } from '@spartacus/core'; import { OpfDynamicScriptResourceType, OpfMetadataStoreService, OpfResourceLoaderService, } from '@spartacus/opf/base/root'; -import { OrderFacade } from '@spartacus/order/root'; -import { of, throwError } from 'rxjs'; - import { OPF_PAYMENT_AND_REVIEW_SEMANTIC_ROUTE } from '@spartacus/opf/checkout/root'; +import { getBrowserInfo } from '@spartacus/opf/payment/core'; import { OpfPaymentFacade, OpfPaymentRenderPattern, OpfPaymentSessionData, } from '@spartacus/opf/payment/root'; +import { OrderFacade } from '@spartacus/order/root'; +import { of, throwError } from 'rxjs'; import { OpfCheckoutPaymentWrapperService } from './opf-checkout-payment-wrapper.service'; const mockUrl = 'https://sap.com'; @@ -38,6 +39,7 @@ describe('OpfCheckoutPaymentWrapperService', () => { let globalMessageServiceMock: jasmine.SpyObj; let orderFacadeMock: jasmine.SpyObj; let opfMetadataStoreServiceMock: jasmine.SpyObj; + let windowRefMock: jasmine.SpyObj; beforeEach(() => { opfPaymentFacadeMock = jasmine.createSpyObj('OpfPaymentFacade', [ @@ -69,6 +71,7 @@ describe('OpfCheckoutPaymentWrapperService', () => { 'OpfMetadataStoreService', ['updateOpfMetadata'] ); + windowRefMock = jasmine.createSpyObj('WindowRef', ['nativeWindow']); routingServiceMock.getRouterState.and.returnValue( of({ @@ -96,6 +99,10 @@ describe('OpfCheckoutPaymentWrapperService', () => { provide: OpfMetadataStoreService, useValue: opfMetadataStoreServiceMock, }, + { + provide: WindowRef, + useValue: windowRefMock, + }, ], }); @@ -163,6 +170,7 @@ describe('OpfCheckoutPaymentWrapperService', () => { cartId: mockCartId, resultURL: mockUrl, cancelURL: mockUrl, + browserInfo: getBrowserInfo(windowRefMock.nativeWindow), }, }); @@ -422,7 +430,8 @@ describe('OpfCheckoutPaymentWrapperService', () => { const config = service['getPaymentInitiationConfig']( mockActiveCartId, mockOtpKey, - mockPaymentOptionId + mockPaymentOptionId, + getBrowserInfo(windowRefMock.nativeWindow) ); expect(config).toEqual({ @@ -432,6 +441,7 @@ describe('OpfCheckoutPaymentWrapperService', () => { cartId: mockActiveCartId, resultURL: mockUrl, cancelURL: mockUrl, + browserInfo: getBrowserInfo(windowRefMock.nativeWindow), }, }); }); diff --git a/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.service.ts b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.service.ts index 5526f1890df..ae0523bb780 100644 --- a/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.service.ts +++ b/integration-libs/opf/checkout/components/opf-checkout-payment-wrapper/opf-checkout-payment-wrapper.service.ts @@ -17,6 +17,7 @@ import { HttpResponseStatus, RoutingService, UserIdService, + WindowRef, backOff, isAuthorizationError, } from '@spartacus/core'; @@ -26,7 +27,9 @@ import { OpfResourceLoaderService, } from '@spartacus/opf/base/root'; import { OPF_PAYMENT_AND_REVIEW_SEMANTIC_ROUTE } from '@spartacus/opf/checkout/root'; +import { getBrowserInfo } from '@spartacus/opf/payment/core'; import { + OpfPaymentBrowserInfo, OpfPaymentFacade, OpfPaymentRenderMethodEvent, OpfPaymentRenderPattern, @@ -53,6 +56,7 @@ export class OpfCheckoutPaymentWrapperService { protected orderFacade = inject(OrderFacade); protected opfMetadataStoreService = inject(OpfMetadataStoreService); protected cartAccessCodeFacade = inject(CartAccessCodeFacade); + protected winRef = inject(WindowRef); protected lastPaymentOptionId?: number; @@ -110,7 +114,12 @@ export class OpfCheckoutPaymentWrapperService { this.cartAccessCodeFacade.getCartAccessCode(userId, cartId).pipe( filter((response) => Boolean(response?.accessCode)), map(({ accessCode: otpKey }) => - this.getPaymentInitiationConfig(cartId, otpKey, paymentOptionId) + this.getPaymentInitiationConfig( + cartId, + otpKey, + paymentOptionId, + getBrowserInfo(this.winRef?.nativeWindow) + ) ) ) ), @@ -263,12 +272,14 @@ export class OpfCheckoutPaymentWrapperService { protected getPaymentInitiationConfig( cartId: string, otpKey: string, - paymentOptionId: number + paymentOptionId: number, + browserInfo?: OpfPaymentBrowserInfo ) { return { otpKey, config: { cartId, + browserInfo, configurationId: String(paymentOptionId), resultURL: this.routingService.getFullUrl({ cxRoute: 'paymentVerificationResult', diff --git a/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.component.spec.ts b/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.component.spec.ts index 5c8babc04d8..15de2cd192f 100644 --- a/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.component.spec.ts +++ b/integration-libs/opf/checkout/components/opf-checkout-payments/opf-checkout-payments.component.spec.ts @@ -38,6 +38,12 @@ class MockPaginationComponent { @Output() viewPageEvent = new EventEmitter(); } +@Component({ + template: '', + selector: 'cx-opf-checkout-payment-wrapper', +}) +class MockOpfCheckoutPaymentWrapperComponent {} + const mockActiveConfigurations: OpfActiveConfiguration[] = [ { id: 1, @@ -109,7 +115,11 @@ describe('OpfCheckoutPaymentsComponent', () => { ); await TestBed.configureTestingModule({ imports: [I18nTestingModule, OpfCheckoutTermsAndConditionsAlertModule], - declarations: [OpfCheckoutPaymentsComponent, MockPaginationComponent], + declarations: [ + OpfCheckoutPaymentsComponent, + MockOpfCheckoutPaymentWrapperComponent, + MockPaginationComponent, + ], providers: [ { provide: OpfBaseFacade, diff --git a/integration-libs/opf/package.json b/integration-libs/opf/package.json index 35c1db3f334..94155d31bb6 100644 --- a/integration-libs/opf/package.json +++ b/integration-libs/opf/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/opf", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "SAP Open Payment Framework integration library for Spartacus", "keywords": [ "spartacus", @@ -22,7 +22,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.spec.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.spec.ts index 9323a4e4211..1c567313dfe 100644 --- a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.spec.ts +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.spec.ts @@ -78,6 +78,7 @@ describe('OpfGooglePayService', () => { 'getTransactionDeliveryInfo', 'getTransactionLocationContext', 'getMerchantName', + 'updateCartGuestUserEmail', ] ); mockPaymentFacade = jasmine.createSpyObj('OpfPaymentFacade', [ @@ -630,6 +631,7 @@ describe('OpfGooglePayService', () => { describe('onPaymentAuthorized', () => { it('should handle payment authorization', (done) => { + const mockEmail = 'test@mail.com'; const callbacks = service['handlePaymentCallbacks'](); const mockToken = 'mockToken'; const paymentDataResponse = { @@ -638,6 +640,7 @@ describe('OpfGooglePayService', () => { token: mockToken, }, }, + email: mockEmail, } as google.payments.api.PaymentData; mockPaymentFacade.submitPayment.and.returnValue(of(true)); @@ -647,6 +650,9 @@ describe('OpfGooglePayService', () => { mockQuickBuyTransactionService.setDeliveryAddress.and.returnValue( of('addressId') ); + mockQuickBuyTransactionService.updateCartGuestUserEmail.and.returnValue( + of(true) + ); if (callbacks.onPaymentAuthorized) { ( @@ -657,6 +663,10 @@ describe('OpfGooglePayService', () => { expect(result).toBeDefined(); expect(mockPaymentFacade.submitPayment).toHaveBeenCalled(); + expect( + mockQuickBuyTransactionService.updateCartGuestUserEmail + ).toHaveBeenCalledWith(mockEmail); + expect(Object.values(submitPaymentArgs.callbacks).length).toBe(3); Object.values(submitPaymentArgs.callbacks).forEach((callback) => { expect(typeof callback).toBe('function'); diff --git a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.ts b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.ts index a0cc7a69a70..ac2c206aed2 100644 --- a/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.ts +++ b/integration-libs/opf/quick-buy/components/opf-quick-buy-buttons/google-pay/google-pay.service.ts @@ -74,6 +74,7 @@ export class OpfGooglePayService { shippingAddressParameters: { phoneNumberRequired: false, }, + emailRequired: true, }; protected readonly defaultGooglePayCardParameters: any = { @@ -378,6 +379,13 @@ export class OpfGooglePayService { paymentDataResponse.paymentMethodData.info?.billingAddress ) ), + switchMap(() => { + return paymentDataResponse?.email + ? this.opfQuickBuyTransactionService.updateCartGuestUserEmail( + paymentDataResponse.email + ) + : of(true); + }), switchMap(() => { const encryptedToken = btoa( paymentDataResponse.paymentMethodData.tokenizationData.token diff --git a/integration-libs/opps/package.json b/integration-libs/opps/package.json index 39818e54ea0..a27d62868c4 100644 --- a/integration-libs/opps/package.json +++ b/integration-libs/opps/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/opps", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "OPPS (Omni-Channel Personalization and Promotions Services) library for Spartacus", "keywords": [ "spartacus", @@ -22,7 +22,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/integration-libs/s4-service/package.json b/integration-libs/s4-service/package.json index 78a85325b42..65896023390 100644 --- a/integration-libs/s4-service/package.json +++ b/integration-libs/s4-service/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/s4-service", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "S/4HANA Service Integration Integration library for Spartacus", "keywords": [ "spartacus", @@ -22,7 +22,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/integration-libs/s4om/package.json b/integration-libs/s4om/package.json index e33536cd14f..f49371a5c2e 100644 --- a/integration-libs/s4om/package.json +++ b/integration-libs/s4om/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/s4om", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "S/4HANA Order Management (b2b feature)", "keywords": [ "spartacus", @@ -17,7 +17,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/integration-libs/segment-refs/package.json b/integration-libs/segment-refs/package.json index 623620f69e5..9fffdc9e76b 100644 --- a/integration-libs/segment-refs/package.json +++ b/integration-libs/segment-refs/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/segment-refs", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "segment-refs", "keywords": [ "spartacus", @@ -17,7 +17,7 @@ "test:schematics": "npm --prefix ../../projects/schematics/ run clean && npm run clean:schematics && ../../node_modules/.bin/jest --config ./jest.schematics.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/schematics": "^18.2.9", diff --git a/package-lock.json b/package-lock.json index 4c38b5f45fc..063e79650af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -100,7 +100,7 @@ "http-proxy": "^1.18.1", "http-server": "^14.1.1", "i18n-lint": "^1.1.0", - "jasmine-core": "~5.4.0", + "jasmine-core": "~5.5.0", "jasmine-marbles": "^0.9.2", "jest": "^29.7.0", "jest-circus": "^29.0.0", @@ -131,7 +131,7 @@ "ts-morph": "^23.0.0", "ts-node": "^10.6.0", "typescript": "^5.2.2", - "webpack": "~5.96.0", + "webpack": "~5.97.0", "webpack-cli": "^5.0.0" }, "engines": { @@ -897,6 +897,12 @@ "dev": true, "license": "0BSD" }, + "node_modules/@angular-devkit/build-angular/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, "node_modules/@angular-devkit/build-angular/node_modules/webpack": { "version": "5.94.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", @@ -20475,9 +20481,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -27475,17 +27481,17 @@ } }, "node_modules/webpack": { - "version": "5.96.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.96.1.tgz", - "integrity": "sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==", + "version": "5.97.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", + "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", - "@webassemblyjs/ast": "^1.12.1", - "@webassemblyjs/wasm-edit": "^1.12.1", - "@webassemblyjs/wasm-parser": "^1.12.1", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.14.0", "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", diff --git a/package.json b/package.json index 589539c9421..f1200c27fa3 100644 --- a/package.json +++ b/package.json @@ -216,7 +216,7 @@ "http-proxy": "^1.18.1", "http-server": "^14.1.1", "i18n-lint": "^1.1.0", - "jasmine-core": "~5.4.0", + "jasmine-core": "~5.5.0", "jasmine-marbles": "^0.9.2", "jest": "^29.7.0", "jest-circus": "^29.0.0", @@ -247,7 +247,7 @@ "ts-morph": "^23.0.0", "ts-node": "^10.6.0", "typescript": "^5.2.2", - "webpack": "~5.96.0", + "webpack": "~5.97.0", "webpack-cli": "^5.0.0" } } diff --git a/projects/assets/package.json b/projects/assets/package.json index ec687b0c10e..9a17317c65d 100644 --- a/projects/assets/package.json +++ b/projects/assets/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/assets", - "version": "2211.32.0-1", + "version": "2211.32.0", "homepage": "https://github.com/SAP/spartacus", "repository": "https://github.com/SAP/spartacus/tree/develop/projects/assets", "scripts": { @@ -10,7 +10,7 @@ "generate:translations:ts-2-properties": "ts-node ./generate-translations-ts-2-properties" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "publishConfig": { "access": "public" diff --git a/projects/assets/src/translations/en/common.json b/projects/assets/src/translations/en/common.json index dabea7c8f7c..a96f9e87010 100644 --- a/projects/assets/src/translations/en/common.json +++ b/projects/assets/src/translations/en/common.json @@ -63,7 +63,8 @@ "goTo": "Go to {{location}}", "navigateTo": "Navigate to {{nav}}", "scrollToTop": "Scroll back to the top of the page", - "linkItemInList": "{{title}}. {{position}} of {{listLength}}" + "linkItemInList": "{{title}}. {{position}} of {{listLength}}", + "menuButonTitle": "{{title}} Menu" }, "searchBox": { "placeholder": "Enter product name or SKU", 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/assets/src/translations/en/user.json b/projects/assets/src/translations/en/user.json index 019afc6c907..df01c493616 100644 --- a/projects/assets/src/translations/en/user.json +++ b/projects/assets/src/translations/en/user.json @@ -10,7 +10,8 @@ "title": "This website uses cookies", "description": "We use cookies/browser's storage to personalize the content and improve user experience.", "allowAll": "Allow All", - "viewDetails": "View Details" + "viewDetails": "View Details", + "consentManagement": "Consent Management" } }, "checkoutLogin": { diff --git a/projects/core/package.json b/projects/core/package.json index b00bb4ee19b..ad980d054a0 100644 --- a/projects/core/package.json +++ b/projects/core/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/core", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Spartacus - the core framework", "keywords": [ "spartacus", @@ -12,7 +12,7 @@ "repository": "https://github.com/SAP/spartacus/tree/develop/projects/core", "license": "Apache-2.0", "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular/common": "^18.2.9", diff --git a/projects/core/src/error-handling/http-error-handler/http-error-handler.interceptor.ts b/projects/core/src/error-handling/http-error-handler/http-error-handler.interceptor.ts index 0c0093e85bb..3c331ddddec 100644 --- a/projects/core/src/error-handling/http-error-handler/http-error-handler.interceptor.ts +++ b/projects/core/src/error-handling/http-error-handler/http-error-handler.interceptor.ts @@ -36,7 +36,7 @@ import { * CAUTION: It MUST be provided as the first one in the application to be able to * catch errors from all subsequent interceptors. */ -@Injectable() +@Injectable({ providedIn: 'root' }) export class HttpErrorHandlerInterceptor implements HttpInterceptor { protected errorHandler = inject(ErrorHandler); protected occEndpointsService = inject(OccEndpointsService); diff --git a/projects/core/src/error-handling/http-error-handler/http-error-handler.module.ts b/projects/core/src/error-handling/http-error-handler/http-error-handler.module.ts index 45e89f0be5f..085bcec47b7 100644 --- a/projects/core/src/error-handling/http-error-handler/http-error-handler.module.ts +++ b/projects/core/src/error-handling/http-error-handler/http-error-handler.module.ts @@ -16,7 +16,7 @@ export class HttpErrorHandlerModule { providers: [ { provide: HTTP_INTERCEPTORS, - useClass: HttpErrorHandlerInterceptor, + useExisting: HttpErrorHandlerInterceptor, multi: true, }, ], 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 1a9d8e87b55..9da98edab28 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 @@ -265,6 +265,11 @@ export interface FeatureTogglesInterface { */ a11yCartImportConfirmationMessage?: boolean; + /** + * In `AnonymousConsentDialogComponent` display notifications inside the modal without closing it + */ + a11yAnonymousConsentMessageInDialog?: boolean; + /** * Changes 'order days' check list into a fieldset inside of 'CheckoutScheduleReplenishmentOrderComponent'. */ @@ -286,6 +291,11 @@ export interface FeatureTogglesInterface { */ a11yMobileFocusOnFirstNavigationItem?: boolean; + /** + * `QuickOrderFormComponent` - disable navigation with Tab/Shift+Tab for search results list + */ + a11yQuickOrderSearchListKeyboardNavigation?: boolean; + /** * Corrects heading order inside 'OrderSummaryComponent' template. */ @@ -405,6 +415,12 @@ export interface FeatureTogglesInterface { */ a11yTruncatedTextStoreFinder?: boolean; + /** + * `UnitLevelOrderHistoryComponent` filter input label and table email address + * are not truncated + */ + a11yTruncatedTextUnitLevelOrderHistory?: boolean; + /** * When enabled focus outline on the close button inside `ProductImageZoomDialogComponent` * will be fully visible @@ -520,6 +536,11 @@ export interface FeatureTogglesInterface { */ a11yFacetsDialogFocusHandling?: boolean; + /** + * Resets the focus after navigating to a new page. + */ + a11yResetFocusAfterNavigating?: boolean; + /** * `StorefrontComponent`: Prevents header links from wrapping on smaller screen sizes. * Enables support for increased letter-spacing up to 0.12em for header layout @@ -642,7 +663,7 @@ export interface FeatureTogglesInterface { /** * Stops the inputs value from obstructing the 'PasswordVisibilityToggleComponent'. */ - a11yPasswordVisibilityBtnValueOverflow?: boolean; + a11yPasswordVisibliltyBtnValueOverflow?: boolean; /** * In `ItemCounterComponenet`, Remove button no longer lose focus after activating when count is 2. @@ -684,6 +705,11 @@ export interface FeatureTogglesInterface { */ a11yImproveButtonsInCardComponent?: boolean; + /** + * In `MiniCart component`, improve visible focus contrast on mobile. + */ + a11yMiniCartFocusOnMobile?: boolean; + /** * In `UnitFormComponent`, set 'clearable' as false for select of `ApprovalProcess`. */ @@ -712,7 +738,8 @@ export interface FeatureTogglesInterface { /** * Fixes various instances of the focus ring being cropped in the UI. * The focus ring on interactive elements should have all its sides visible and not include any extra padding. - * Affects styles of: 'CartItemListComponent, CartItemComponent, ListComponent, FutureStockAccordionComponent, QuoteConfirmDialogComponent, MessagingComponent, TabComponent + * Affects styles of: 'CartItemListComponent, CartItemComponent, ListComponent, FutureStockAccordionComponent, + * QuoteConfirmDialogComponent, MessagingComponent, TabComponent, ProductImageZoomViewComponent */ a11yCroppedFocusRing?: boolean; @@ -735,18 +762,59 @@ export interface FeatureTogglesInterface { */ a11ySearchboxAssistiveMessage?: boolean; + /** + * Updates the derivative `consentGiven` state when `consent` is updated. + * + * Components affected: + * - `ConsentManagementFormComponent` + * - `MyAccountV2ConsentManagementFormComponent` + */ + updateConsentGivenInOnChanges?: boolean; + /** * Adds additional styling to help differentiate between focused and selected items in the list. * Affects: ConfiguratorAttributeSingleSelectionImageComponent, ProductImagesComponent */ a11yDifferentiateFocusedAndSelected?: boolean; + /** + * When enabled the input element in `QuickOrderFormComponent' will regain its focus after the dropdown is closed. + */ + a11yQuickOrderSearchBoxRefocusOnClose?: boolean; + + /** + * Adds a visible focus indicator for keyboard navigation in the `SearchBoxComponent` without affecting the visual state for mouse interactions. + * Affects: SearchBoxComponent + */ + a11yKeyboardFocusInSearchBox?: boolean; + /** * Adds horizontal padding to the 'carousel-panel' to fix the issue where the focus only covers three sides of the 'Previous slide' and 'Next slide' buttons within the carousel section. * Affects: CarouselComponent */ a11yAddPaddingToCarouselPanel?: boolean; + /** + * Restores the focus to the card once a option has been selected and the checkout has updated. + * Affects: CheckoutPaymentMethodComponent, CheckoutDeliveryAddressComponent + */ + a11yFocusOnCardAfterSelecting?: boolean; + + /** + * Search dropdowns will display the focus ring correctly when navigating to the options using the down arrow key. + * Affects: SearchBoxComponent, QuickOrderFormComponent + */ + a11ySearchableDropdownFirstElementFocus?: boolean; + + /** + * Hides the 'Consent Management' button from the tab order when the cookies banner is visible. + * Ensures the button is re-enabled and part of the tab order once consent is given and the banner disappears. + * Renames the button from "View Details" to "Consent Management" after consent is given. + * Ensures the button is centered in the `AnonymousConsentOpenDialogComponent` and has clear, four-sided visible focus when navigated via keyboard. + * Affects: AnonymousConsentOpenDialogComponent, AnonymousConsentManagementBannerComponent + */ + a11yHideConsentButtonWhenBannerVisible?: boolean; + /** * In OCC cart requests, it puts parameters of a cart name and cart description * into a request body, instead of query params. @@ -778,6 +846,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(the new Occ endpoint is available since Commerce 2211.28), which avoids security risk. + */ + enableClaimCustomerCouponWithCodeInRequestBody?: boolean; + /** * Enables a validation that prevents new passwords from matching the current password * in the password update form. @@ -805,6 +881,13 @@ export interface FeatureTogglesInterface { */ a11yPdpGridArrangement?: boolean; + /** + * Header. Fixes trapping focus on menu items on mobile when the menu is expanded. + * Sets `tabindex` attribute to `-1` for all visible focusable elements in the header section to exclude them from + * keyboard navigation + */ + a11yHamburgerMenuTrapFocus?: boolean; + /** * When enabled, allows to provide extended formats and media queries for element if used in MediaComponent. * @@ -816,20 +899,13 @@ export interface FeatureTogglesInterface { * ```ts * provideConfig({ * pictureElementFormats: { - * mediaQueries: { - * 'max-width': '767px', - * ... - * }, - * width: 50, - * height: 50, + * mediaQueries: '(max-width: 480px)', * }, * }) * ``` * - * After activating this toggle, new inputs in `MediaComponent` — specifically - * `width`, `height`, and `sizes` — will be passed to the template as HTML attributes. - * - * Toggle activates `@Input() elementType: 'img' | 'picture' = 'img'` in `MediaComponent` + * Toggle activates `@Input() elementType: 'img' | 'picture' = 'img'` + * and `@Input() sizesForImgElement: string` in `MediaComponent` * */ useExtendedMediaComponentConfiguration?: boolean; @@ -852,17 +928,33 @@ export interface FeatureTogglesInterface { */ a11yWrapReviewOrderInSection?: boolean; + /** + * Enables the product carousel to include products based on specified category codes. + * + * - When this feature is enabled, the carousel will fetch and display products + * associated with the `categoryCodes` provided. + * - The `categoryCodes` are configured and managed through SmartEdit + * + */ + enableCarouselCategoryProducts?: boolean; + + /** + * When enabled, enforces stronger password validation rules, + * including requirements for a mix of uppercase letters, lowercase letters, + * special characters, digits, and no consecutive characters, + * as well as enforcing both a minimum and maximum password length. + */ enableSecurePasswordValidation?: boolean; } export const defaultFeatureToggles: Required = { showDeliveryOptionsTranslation: false, - formErrorsDescriptiveMessages: false, + formErrorsDescriptiveMessages: true, showSearchingCustomerByOrderInASM: false, showStyleChangesInASM: false, - shouldHideAddToCartForUnpurchasableProducts: false, - useExtractedBillingAddressComponent: false, - showBillingAddressInDigitalPayments: false, + shouldHideAddToCartForUnpurchasableProducts: true, + useExtractedBillingAddressComponent: true, + showBillingAddressInDigitalPayments: true, showDownloadProposalButton: true, showPromotionsInPDP: true, searchBoxV2: false, @@ -895,10 +987,12 @@ export const defaultFeatureToggles: Required = { a11yOrganizationsBanner: true, a11yOrganizationListHeadingOrder: true, a11yCartImportConfirmationMessage: false, + a11yAnonymousConsentMessageInDialog: false, a11yReplenishmentOrderFieldset: true, a11yListOversizedFocus: true, a11yStoreFinderOverflow: true, a11yMobileFocusOnFirstNavigationItem: false, + a11yQuickOrderSearchListKeyboardNavigation: false, a11yCartSummaryHeadingOrder: true, a11ySearchBoxMobileFocus: true, a11yFacetKeyboardNavigation: true, @@ -913,26 +1007,28 @@ export const defaultFeatureToggles: Required = { cmsGuardsServiceUseGuardsComposer: true, cartQuickOrderRemoveListeningToFailEvent: true, a11yKeyboardAccessibleZoom: false, - a11yOrganizationLinkableCells: false, + a11yOrganizationLinkableCells: true, a11yVisibleFocusOverflows: true, a11yTruncatedTextForResponsiveView: true, a11yTruncatedTextStoreFinder: false, - a11ySemanticPaginationLabel: false, + a11yTruncatedTextUnitLevelOrderHistory: false, + a11ySemanticPaginationLabel: true, a11yPreventCartItemsFormRedundantRecreation: false, - a11yPreventSRFocusOnHiddenElements: false, + a11yPreventSRFocusOnHiddenElements: true, a11yMyAccountLinkOutline: true, a11yCloseProductImageBtnFocus: true, - a11yNotificationPreferenceFieldset: false, - a11yImproveContrast: false, + a11yNotificationPreferenceFieldset: true, + a11yImproveContrast: true, a11yEmptyWishlistHeading: true, - a11yScreenReaderBloatFix: false, + a11yScreenReaderBloatFix: true, a11yUseButtonsForBtnLinks: true, a11yTabComponent: false, a11yCarouselArrowKeysNavigation: false, a11yPickupOptionsTabs: false, a11yNotificationsOnConsentChange: false, - a11yDisabledCouponAndQuickOrderActionButtonsInsteadOfRequiredFields: false, + a11yDisabledCouponAndQuickOrderActionButtonsInsteadOfRequiredFields: true, a11yFacetsDialogFocusHandling: true, + a11yResetFocusAfterNavigating: false, headerLayoutForSmallerViewports: false, a11yStoreFinderAlerts: false, a11yStoreFinderLabel: false, @@ -955,7 +1051,7 @@ export const defaultFeatureToggles: Required = { a11yAddToWishlistFocus: false, a11ySearchBoxFocusOnEscape: false, a11yUpdatingCartNoNarration: false, - a11yPasswordVisibilityBtnValueOverflow: false, + a11yPasswordVisibliltyBtnValueOverflow: false, a11yItemCounterFocus: false, a11yScrollToReviewByShowReview: false, a11yViewHoursButtonIconContrast: false, @@ -963,6 +1059,7 @@ export const defaultFeatureToggles: Required = { a11yCheckoutStepsLandmarks: false, a11yQTY2Quantity: false, a11yImproveButtonsInCardComponent: false, + a11yMiniCartFocusOnMobile: false, a11yWrapReviewOrderInSection: false, a11yApprovalProcessWithNoClearable: false, a11yPostRegisterSuccessMessage: false, @@ -973,8 +1070,14 @@ export const defaultFeatureToggles: Required = { a11yTextSpacingAdjustments: false, a11yTableHeaderReadout: false, a11ySearchboxAssistiveMessage: false, + updateConsentGivenInOnChanges: false, a11yDifferentiateFocusedAndSelected: false, + a11yQuickOrderSearchBoxRefocusOnClose: false, + a11yKeyboardFocusInSearchBox: false, a11yAddPaddingToCarouselPanel: false, + a11yFocusOnCardAfterSelecting: false, + a11ySearchableDropdownFirstElementFocus: false, + a11yHideConsentButtonWhenBannerVisible: false, occCartNameAndDescriptionInHttpRequestBody: false, cmsBottomHeaderSlotUsingFlexStyles: false, useSiteThemeService: false, @@ -982,8 +1085,11 @@ export const defaultFeatureToggles: Required = { enablePasswordsCannotMatchInPasswordUpdateForm: false, allPageMetaResolversEnabledInCsr: false, a11yPdpGridArrangement: false, + a11yHamburgerMenuTrapFocus: false, useExtendedMediaComponentConfiguration: false, - a11yScrollToTopPositioning: false, showRealTimeStockInPDP: false, + a11yScrollToTopPositioning: false, enableSecurePasswordValidation: false, + enableCarouselCategoryProducts: false, + enableClaimCustomerCouponWithCodeInRequestBody: false, }; diff --git a/projects/core/src/model/cms.model.ts b/projects/core/src/model/cms.model.ts index fdeba537f5b..8d71c1dbf8d 100644 --- a/projects/core/src/model/cms.model.ts +++ b/projects/core/src/model/cms.model.ts @@ -135,6 +135,7 @@ export interface CmsBannerCarouselComponent extends CmsComponent { } export interface CmsProductCarouselComponent extends CmsComponent { + categoryCodes?: string; title?: string; productCodes?: string; container?: string; diff --git a/projects/core/src/occ/adapters/product/default-occ-product-config.ts b/projects/core/src/occ/adapters/product/default-occ-product-config.ts index 89bbe5bf9f6..9e066dafc5c 100644 --- a/projects/core/src/occ/adapters/product/default-occ-product-config.ts +++ b/projects/core/src/occ/adapters/product/default-occ-product-config.ts @@ -43,6 +43,10 @@ export const defaultOccProductConfig: OccConfig = { carouselMinimal: 'products/search?fields=products(code,name,price(formattedValue),images(DEFAULT),baseProduct)', }, + productSearchByCategory: { + default: 'categories/${categoryCode}/products?fields=DEFAULT', + code: 'categories/${categoryCode}/products?fields=products(code)', + }, /* eslint-enable */ productSuggestions: 'products/suggestions', }, diff --git a/projects/core/src/occ/adapters/product/occ-product-search.adapter.ts b/projects/core/src/occ/adapters/product/occ-product-search.adapter.ts index cf26166a6b4..53ae8117727 100644 --- a/projects/core/src/occ/adapters/product/occ-product-search.adapter.ts +++ b/projects/core/src/occ/adapters/product/occ-product-search.adapter.ts @@ -24,6 +24,7 @@ import { Occ } from '../../occ-models/occ.models'; import { OccEndpointsService } from '../../services/occ-endpoints.service'; import { OCC_HTTP_TOKEN } from '../../utils'; import { Router } from '@angular/router'; + @Injectable() export class OccProductSearchAdapter implements ProductSearchAdapter { protected router = inject(Router, { @@ -97,6 +98,20 @@ export class OccProductSearchAdapter implements ProductSearchAdapter { ); } + searchByCategory( + category: string, + scope?: string + ): Observable<{ products: Product[] }> { + return this.http + .get(this.getSearchByCategoryEndpoint(category, scope)) + .pipe( + this.converter.pipeable(PRODUCT_SEARCH_PAGE_NORMALIZER), + map((productSearchPage) => ({ + products: productSearchPage.products ?? [], + })) + ); + } + loadSuggestions( term: string, pageSize: number = 3 @@ -111,6 +126,16 @@ export class OccProductSearchAdapter implements ProductSearchAdapter { ); } + protected getSearchByCategoryEndpoint( + categoryCode: string, + scope?: string + ): string { + return this.occEndpoints.buildUrl('productSearchByCategory', { + urlParams: { categoryCode }, + scope, + }); + } + protected getSearchEndpoint( query: string, searchConfig: SearchConfig, 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 813e7a2ae59..2a9fabf34eb 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 8d016457782..d480b056661 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 @@ -241,4 +241,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 870e6142d60..d06ec706bf1 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 72c6f7a29c2..80df7bcd2f2 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 * @@ -272,6 +278,11 @@ export interface OccEndpoints { * @member {string} */ getActiveCostCenters?: string | OccEndpoint; + /** Endpoint to returns categories + * + * @member {string} + */ + productSearchByCategory?: string | OccEndpoint; /** Endpoint to returns Product Availabilities * * @member {string} diff --git a/projects/core/src/product/connectors/search/product-search.adapter.ts b/projects/core/src/product/connectors/search/product-search.adapter.ts index 314102b4300..f360df4977c 100644 --- a/projects/core/src/product/connectors/search/product-search.adapter.ts +++ b/projects/core/src/product/connectors/search/product-search.adapter.ts @@ -25,6 +25,11 @@ export abstract class ProductSearchAdapter { scope?: string ): Observable<{ products: Product[] }>; + abstract searchByCategory( + category: string, + scope?: string + ): Observable<{ products: Product[] }>; + abstract loadSuggestions( term: string, pageSize?: number diff --git a/projects/core/src/product/connectors/search/product-search.connector.spec.ts b/projects/core/src/product/connectors/search/product-search.connector.spec.ts index ac13eb37271..40b405fec32 100644 --- a/projects/core/src/product/connectors/search/product-search.connector.spec.ts +++ b/projects/core/src/product/connectors/search/product-search.connector.spec.ts @@ -16,6 +16,12 @@ class MockProductSearchAdapter implements ProductSearchAdapter { searchByCodes = createSpy('ProductSearchAdapter.searchByCodes').and.callFake( (codes, scope) => of({ products: codes.map((code) => ({ code, scope })) }) ); + + searchByCategory = createSpy( + 'ProductSearchAdapter.searchByCategory' + ).and.callFake((_category, scope) => + of({ products: [{ code: 'product1', scope }] }) + ); } describe('ProductSearchConnector', () => { @@ -73,4 +79,18 @@ describe('ProductSearchConnector', () => { undefined ); }); + + it('searchByCategory should call adapter', () => { + let result; + service.searchByCategory('testCategory', 'testScope').subscribe((res) => { + result = res; + }); + expect(result).toEqual({ + products: [{ code: 'product1', scope: 'testScope' }], + }); + expect(adapter.searchByCategory).toHaveBeenCalledWith( + 'testCategory', + 'testScope' + ); + }); }); diff --git a/projects/core/src/product/connectors/search/product-search.connector.ts b/projects/core/src/product/connectors/search/product-search.connector.ts index ce8b34f2129..40479743f6a 100644 --- a/projects/core/src/product/connectors/search/product-search.connector.ts +++ b/projects/core/src/product/connectors/search/product-search.connector.ts @@ -9,8 +9,8 @@ import { ProductSearchAdapter } from './product-search.adapter'; import { SearchConfig } from '../../model/search-config'; import { Observable } from 'rxjs'; import { - Suggestion, ProductSearchPage, + Suggestion, } from '../../../model/product-search.model'; import { Product } from '../../../model'; @@ -35,6 +35,13 @@ export class ProductSearchConnector { return this.adapter.searchByCodes(codes, scope); } + searchByCategory( + category: string, + scope?: string + ): Observable<{ products: Product[] }> { + return this.adapter.searchByCategory(category, scope); + } + getSuggestions(term: string, pageSize?: number): Observable { return this.adapter.loadSuggestions(term, pageSize); } diff --git a/projects/core/src/product/facade/index.ts b/projects/core/src/product/facade/index.ts index bee0a7310d1..3c73b7e079f 100644 --- a/projects/core/src/product/facade/index.ts +++ b/projects/core/src/product/facade/index.ts @@ -8,6 +8,7 @@ export * from './product-reference.service'; export * from './product-review.service'; export * from './product-search.service'; export * from './product-search-by-code.service'; +export * from './product-search-by-category.service'; export * from './product.service'; export * from './searchbox.service'; export * from './product-availability.service'; diff --git a/projects/core/src/product/facade/product-search-by-category.service.spec.ts b/projects/core/src/product/facade/product-search-by-category.service.spec.ts new file mode 100644 index 00000000000..0644445ca66 --- /dev/null +++ b/projects/core/src/product/facade/product-search-by-category.service.spec.ts @@ -0,0 +1,91 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { ProductSearchByCategoryService } from './product-search-by-category.service'; +import { ProductActions } from '../store'; +import { of } from 'rxjs'; +import { Product, StateUtils } from '@spartacus/core'; + +describe('ProductSearchByCategoryService', () => { + let service: ProductSearchByCategoryService; + let store: MockStore; + const initialState = { + products: { + searchByCategory: {}, + }, + }; + + const categoryCode = 'testCategory'; + const scope = 'testScope'; + const products: Product[] = [ + { code: 'product1', name: 'Test Product 1' }, + { code: 'product2', name: 'Test Product 2' }, + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + ProductSearchByCategoryService, + provideMockStore({ initialState }), + ], + }); + + service = TestBed.inject(ProductSearchByCategoryService); + store = TestBed.inject(MockStore); + + spyOn(store, 'dispatch').and.callThrough(); + }); + + describe('load', () => { + it('should dispatch ProductSearchLoadByCategory action', () => { + service.load({ categoryCode, scope }); + expect(store.dispatch).toHaveBeenCalledWith( + new ProductActions.ProductSearchLoadByCategory({ + categoryCode, + scope, + }) + ); + }); + + it('should use an empty scope when not provided', () => { + service.load({ categoryCode }); + expect(store.dispatch).toHaveBeenCalledWith( + new ProductActions.ProductSearchLoadByCategory({ + categoryCode, + scope: '', + }) + ); + }); + }); + + describe('get', () => { + it('should return products when the state contains them', (done) => { + const mockState = { + loading: false, + success: true, + value: products, + } as StateUtils.LoaderState; + + spyOn(store, 'pipe').and.returnValue(of(mockState)); + + service.get({ categoryCode, scope }).subscribe((result) => { + expect(result).toEqual(products); + done(); + }); + }); + + it('should not trigger load if state is already loading', (done) => { + const mockState = { + loading: true, + success: false, + error: false, + } as StateUtils.LoaderState; + + spyOn(store, 'pipe').and.returnValue(of(mockState)); + + service.get({ categoryCode, scope }).subscribe(() => { + expect(store.dispatch).not.toHaveBeenCalled(); + done(); + }); + }); + }); +}); diff --git a/projects/core/src/product/facade/product-search-by-category.service.ts b/projects/core/src/product/facade/product-search-by-category.service.ts new file mode 100644 index 00000000000..605f318b139 --- /dev/null +++ b/projects/core/src/product/facade/product-search-by-category.service.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { inject, Injectable } from '@angular/core'; +import { select, Store } from '@ngrx/store'; +import { Observable, using } from 'rxjs'; +import { auditTime, map, tap } from 'rxjs/operators'; +import { Product } from '../../model'; +import { StateWithProduct } from '../store/product-state'; +import { ProductActions, ProductSelectors } from '../store'; + +@Injectable({ + providedIn: 'root', +}) +export class ProductSearchByCategoryService { + protected store = inject(Store); + + load({ + categoryCode, + scope, + }: { + categoryCode: string; + scope?: string; + }): void { + this.store.dispatch( + new ProductActions.ProductSearchLoadByCategory({ + categoryCode, + scope: scope ?? '', + }) + ); + } + + get({ + categoryCode, + scope, + }: { + categoryCode: string; + scope?: string; + }): Observable { + const state$ = this.store.pipe( + select( + ProductSelectors.getSelectedProductSearchByCategoryStateFactory({ + categoryCode, + scope: scope ?? '', + }) + ) + ); + + const loading$ = state$.pipe( + auditTime(0), + tap((state) => { + if (!(state.loading || state.success || state.error)) { + this.load({ categoryCode, scope }); + } + }) + ); + + const value$ = state$.pipe(map((state) => state.value)); + + return using( + () => loading$.subscribe(), + () => value$ + ); + } +} diff --git a/projects/core/src/product/facade/product-search.service.ts b/projects/core/src/product/facade/product-search.service.ts index 85473ffa9c9..87185e59c80 100644 --- a/projects/core/src/product/facade/product-search.service.ts +++ b/projects/core/src/product/facade/product-search.service.ts @@ -18,7 +18,6 @@ import { ProductSelectors } from '../store/selectors/index'; }) export class ProductSearchService { constructor(protected store: Store) {} - search(query: string | undefined, searchConfig?: SearchConfig): void { if (query) { this.store.dispatch( diff --git a/projects/core/src/product/model/product-scope.ts b/projects/core/src/product/model/product-scope.ts index ada63a61a3f..34081b55f84 100644 --- a/projects/core/src/product/model/product-scope.ts +++ b/projects/core/src/product/model/product-scope.ts @@ -9,6 +9,7 @@ export const enum ProductScope { DETAILS = 'details', ATTRIBUTES = 'attributes', VARIANTS = 'variants', + CODE = 'code', PRICE = 'price', /** Fetch the default stock information. */ STOCK = 'stock', diff --git a/projects/core/src/product/store/actions/product-group.actions.ts b/projects/core/src/product/store/actions/product-group.actions.ts index e6cc5661364..c58fbd60f59 100644 --- a/projects/core/src/product/store/actions/product-group.actions.ts +++ b/projects/core/src/product/store/actions/product-group.actions.ts @@ -8,4 +8,5 @@ export * from './product-references.action'; export * from './product-reviews.action'; export * from './product-search.action'; export * from './product-search-by-code.action'; +export * from './product-search-by-category.action'; export * from './product.action'; diff --git a/projects/core/src/product/store/actions/product-search-by-category.action.spec.ts b/projects/core/src/product/store/actions/product-search-by-category.action.spec.ts new file mode 100644 index 00000000000..a6c275e2f21 --- /dev/null +++ b/projects/core/src/product/store/actions/product-search-by-category.action.spec.ts @@ -0,0 +1,72 @@ +import { EntityScopedLoaderActions } from '../../../state/utils/scoped-loader/entity-scoped-loader.actions'; +import { PRODUCT_SEARCH_RESULTS_BY_CATEGORY_ENTITY } from '../product-state'; +import * as fromProductSearchByCategory from './product-search-by-category.action'; + +describe('ProductSearchByCategory Actions', () => { + describe('ProductSearchLoadByCategory', () => { + it('should create an action', () => { + const payload = { categoryCode: 'testCategory', scope: 'testScope' }; + const action = + new fromProductSearchByCategory.ProductSearchLoadByCategory(payload); + + expect({ ...action }).toEqual({ + type: fromProductSearchByCategory.PRODUCT_SEARCH_LOAD_BY_CATEGORY, + payload: payload, + meta: EntityScopedLoaderActions.entityScopedLoadMeta( + PRODUCT_SEARCH_RESULTS_BY_CATEGORY_ENTITY, + payload.categoryCode, + payload.scope + ), + }); + }); + }); + + describe('ProductSearchLoadByCategorySuccess', () => { + it('should create an action', () => { + const payload = { + categoryCode: 'testCategory', + scope: 'testScope', + products: [{ code: 'product1' }, { code: 'product2' }], + }; + const action = + new fromProductSearchByCategory.ProductSearchLoadByCategorySuccess( + payload + ); + + expect({ ...action }).toEqual({ + type: fromProductSearchByCategory.PRODUCT_SEARCH_LOAD_BY_CATEGORY_SUCCESS, + payload: payload.products, + meta: EntityScopedLoaderActions.entityScopedSuccessMeta( + PRODUCT_SEARCH_RESULTS_BY_CATEGORY_ENTITY, + payload.categoryCode, + payload.scope + ), + }); + }); + }); + + describe('ProductSearchLoadByCategoryFail', () => { + it('should create an action', () => { + const payload = { + categoryCode: 'testCategory', + scope: 'testScope', + error: 'someError', + }; + const action = + new fromProductSearchByCategory.ProductSearchLoadByCategoryFail( + payload + ); + + expect({ ...action }).toEqual({ + type: fromProductSearchByCategory.PRODUCT_SEARCH_LOAD_BY_CATEGORY_FAIL, + meta: EntityScopedLoaderActions.entityScopedFailMeta( + PRODUCT_SEARCH_RESULTS_BY_CATEGORY_ENTITY, + payload.categoryCode, + payload.scope, + payload.error + ), + error: payload.error, + }); + }); + }); +}); diff --git a/projects/core/src/product/store/actions/product-search-by-category.action.ts b/projects/core/src/product/store/actions/product-search-by-category.action.ts new file mode 100644 index 00000000000..01c2369f2a9 --- /dev/null +++ b/projects/core/src/product/store/actions/product-search-by-category.action.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EntityScopedLoaderActions } from '../../../state/utils/scoped-loader/entity-scoped-loader.actions'; + +import { PRODUCT_SEARCH_RESULTS_BY_CATEGORY_ENTITY } from '../product-state'; + +import { ErrorAction } from '../../../error-handling'; +import { Product } from '../../../model/product.model'; +export const PRODUCT_SEARCH_LOAD_BY_CATEGORY = + '[Product] Product Search Load By Category'; +export const PRODUCT_SEARCH_LOAD_BY_CATEGORY_SUCCESS = + '[Product] Product Search Load By Category Success'; +export const PRODUCT_SEARCH_LOAD_BY_CATEGORY_FAIL = + '[Product] Product Search Load By Category Fail'; + +export class ProductSearchLoadByCategory extends EntityScopedLoaderActions.EntityScopedLoadAction { + readonly type = PRODUCT_SEARCH_LOAD_BY_CATEGORY; + constructor( + public payload: { + categoryCode: string; + scope: string; + } + ) { + super( + PRODUCT_SEARCH_RESULTS_BY_CATEGORY_ENTITY, + payload.categoryCode, + payload.scope + ); + } +} + +export class ProductSearchLoadByCategorySuccess extends EntityScopedLoaderActions.EntityScopedSuccessAction { + readonly type = PRODUCT_SEARCH_LOAD_BY_CATEGORY_SUCCESS; + constructor(payload: { + products: Product[]; + categoryCode: string; + scope: string; + }) { + super( + PRODUCT_SEARCH_RESULTS_BY_CATEGORY_ENTITY, + payload.categoryCode, + payload.scope, + payload.products + ); + } +} + +export class ProductSearchLoadByCategoryFail + extends EntityScopedLoaderActions.EntityScopedFailAction + implements ErrorAction +{ + readonly type = PRODUCT_SEARCH_LOAD_BY_CATEGORY_FAIL; + constructor(payload: { categoryCode: string; scope: string; error: any }) { + super( + PRODUCT_SEARCH_RESULTS_BY_CATEGORY_ENTITY, + payload.categoryCode, + payload.scope, + payload.error + ); + } +} + +// action types +export type ProductSearchByCategoryAction = + | ProductSearchLoadByCategory + | ProductSearchLoadByCategorySuccess + | ProductSearchLoadByCategoryFail; diff --git a/projects/core/src/product/store/effects/index.ts b/projects/core/src/product/store/effects/index.ts index b27d7f18b3e..4ea62901091 100644 --- a/projects/core/src/product/store/effects/index.ts +++ b/projects/core/src/product/store/effects/index.ts @@ -9,10 +9,12 @@ import { ProductReviewsEffects } from './product-reviews.effect'; import { ProductsSearchEffects } from './product-search.effect'; import { ProductEffects } from './product.effect'; import { ProductSearchByCodeEffects } from './product-search-by-code.effect'; +import { ProductSearchByCategoryEffects } from './product-search-by-category.effect'; export const effects: any[] = [ ProductsSearchEffects, ProductSearchByCodeEffects, + ProductSearchByCategoryEffects, ProductEffects, ProductReviewsEffects, ProductReferencesEffects, @@ -22,4 +24,5 @@ export * from './product-references.effect'; export * from './product-reviews.effect'; export * from './product-search.effect'; export * from './product-search-by-code.effect'; +export * from './product-search-by-category.effect'; export * from './product.effect'; diff --git a/projects/core/src/product/store/effects/product-search-by-category.effect.spec.ts b/projects/core/src/product/store/effects/product-search-by-category.effect.spec.ts new file mode 100644 index 00000000000..0aab9f2e022 --- /dev/null +++ b/projects/core/src/product/store/effects/product-search-by-category.effect.spec.ts @@ -0,0 +1,120 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Observable } from 'rxjs'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { ProductSearchConnector } from '../../connectors/search/product-search.connector'; +import { LoggerService } from '../../../logger/logger.service'; +import { ProductActions } from '../actions'; +import { ProductSearchByCategoryEffects } from './product-search-by-category.effect'; +import { tryNormalizeHttpError } from '@spartacus/core'; + +describe('ProductSearchByCategoryEffects', () => { + let actions$: Observable; + let effects: ProductSearchByCategoryEffects; + let productSearchConnector: jasmine.SpyObj; + let logger: jasmine.SpyObj; + + beforeEach(() => { + productSearchConnector = jasmine.createSpyObj('ProductSearchConnector', [ + 'searchByCategory', + ]); + logger = jasmine.createSpyObj('LoggerService', ['error']); + + TestBed.configureTestingModule({ + providers: [ + ProductSearchByCategoryEffects, + provideMockActions(() => actions$), + { provide: ProductSearchConnector, useValue: productSearchConnector }, + { provide: LoggerService, useValue: logger }, + ], + }); + + effects = TestBed.inject(ProductSearchByCategoryEffects); + }); + + it('should load products by category codes successfully', () => { + const action = new ProductActions.ProductSearchLoadByCategory({ + categoryCode: 'brand_1', + scope: 'list', + }); + const completion = new ProductActions.ProductSearchLoadByCategorySuccess({ + categoryCode: 'brand_1', + scope: 'list', + products: [{ code: '123' }], + }); + + actions$ = hot('-a', { a: action }); + const response = cold('-a|', { a: { products: [{ code: '123' }] } }); + productSearchConnector.searchByCategory.and.returnValue(response); + + const expected = cold('---b', { b: completion }); + + expect( + effects.searchByCategory$({ scheduler: getTestScheduler() }) + ).toBeObservable(expected); + }); + + it('should handle multiple categories successfully', () => { + const action1 = new ProductActions.ProductSearchLoadByCategory({ + categoryCode: 'brand_1', + scope: 'list', + }); + const action2 = new ProductActions.ProductSearchLoadByCategory({ + categoryCode: 'brand_2', + scope: 'list', + }); + const completion1 = new ProductActions.ProductSearchLoadByCategorySuccess({ + categoryCode: 'brand_1', + scope: 'list', + products: [{ code: '123' }, { code: '456' }], + }); + const completion2 = new ProductActions.ProductSearchLoadByCategorySuccess({ + categoryCode: 'brand_2', + scope: 'list', + products: [{ code: '789' }, { code: '101' }], + }); + + actions$ = hot('-a-b', { a: action1, b: action2 }); + + const response1 = cold('-a|', { + a: { products: [{ code: '123' }, { code: '456' }] }, + }); + const response2 = cold('-a|', { + a: { products: [{ code: '789' }, { code: '101' }] }, + }); + + productSearchConnector.searchByCategory.and.returnValues( + response1, + response2 + ); + + const expected = cold('---c-d', { c: completion1, d: completion2 }); + + expect( + effects.searchByCategory$({ scheduler: getTestScheduler() }) + ).toBeObservable(expected); + }); + + it('should handle errors when loading products by category codes', () => { + const action = new ProductActions.ProductSearchLoadByCategory({ + categoryCode: 'brand_1', + scope: 'list', + }); + const error = tryNormalizeHttpError('Error loading products', logger); + const completion = new ProductActions.ProductSearchLoadByCategoryFail({ + categoryCode: 'brand_1', + scope: 'list', + error, + }); + + actions$ = hot('-a-', { a: action }); + const response = cold('-#|', {}, error); + productSearchConnector.searchByCategory.and.returnValue(response); + + const expected = cold('--b', { b: completion }); + + expect( + effects.searchByCategory$({ scheduler: getTestScheduler() }) + ).toBeObservable(expected); + }); +}); diff --git a/projects/core/src/product/store/effects/product-search-by-category.effect.ts b/projects/core/src/product/store/effects/product-search-by-category.effect.ts new file mode 100644 index 00000000000..5d4561c89dd --- /dev/null +++ b/projects/core/src/product/store/effects/product-search-by-category.effect.ts @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { inject, Injectable } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { Action } from '@ngrx/store'; +import { forkJoin, Observable } from 'rxjs'; +import { catchError, groupBy, map, mergeMap } from 'rxjs/operators'; +import { LoggerService } from '../../../logger/logger.service'; +import { SiteContextActions } from '../../../site-context/store/actions/index'; +import { bufferDebounceTime } from '../../../util/rxjs/buffer-debounce-time'; +import { withdrawOn } from '../../../util/rxjs/withdraw-on'; +import { ProductSearchConnector } from '../../connectors/search/product-search.connector'; +import { ProductActions } from '../actions/index'; +import { tryNormalizeHttpError } from '../../../util/try-normalize-http-error'; + +@Injectable() +export class ProductSearchByCategoryEffects { + protected logger = inject(LoggerService); + private actions$ = inject(Actions); + private productSearchConnector = inject(ProductSearchConnector); + + private contextChange$: Observable = this.actions$.pipe( + ofType( + SiteContextActions.CURRENCY_CHANGE, + SiteContextActions.LANGUAGE_CHANGE + ) + ); + + searchByCategory$ = createEffect( + () => + ({ scheduler, debounce = 0 } = {}): Observable< + | ProductActions.ProductSearchLoadByCategorySuccess + | ProductActions.ProductSearchLoadByCategoryFail + > => + this.actions$.pipe( + ofType(ProductActions.PRODUCT_SEARCH_LOAD_BY_CATEGORY), + + groupBy( + (action: ProductActions.ProductSearchLoadByCategory) => + action.payload.scope + ), + mergeMap((group) => { + const scope = group.key; + + return group.pipe( + map( + (action: ProductActions.ProductSearchLoadByCategory) => + action.payload + ), + + bufferDebounceTime(debounce, scheduler), + + mergeMap( + (payloads: { categoryCode: string; scope: string }[]) => { + const categoryCodes = payloads.map( + (payload) => payload.categoryCode + ); + + return forkJoin( + categoryCodes.map((categoryCode) => + this.productSearchConnector.searchByCategory( + categoryCode, + scope + ) + ) + ).pipe( + mergeMap((searchResults, _index) => { + return categoryCodes.map((categoryCode, idx) => { + const categoryProducts = + searchResults[idx]?.products ?? []; + return new ProductActions.ProductSearchLoadByCategorySuccess( + { + categoryCode, + scope, + products: categoryProducts, + } + ); + }); + }), + catchError( + ( + error + ): ProductActions.ProductSearchLoadByCategoryFail[] => { + return payloads.map( + (payload) => + new ProductActions.ProductSearchLoadByCategoryFail({ + ...payload, + error: tryNormalizeHttpError(error, this.logger), + }) + ); + } + ) + ); + } + ) + ); + }), + withdrawOn(this.contextChange$) + ) + ); +} diff --git a/projects/core/src/product/store/product-state.ts b/projects/core/src/product/store/product-state.ts index 9b76b269864..de1dfb6c5dd 100644 --- a/projects/core/src/product/store/product-state.ts +++ b/projects/core/src/product/store/product-state.ts @@ -16,6 +16,9 @@ export const PRODUCT_DETAIL_ENTITY = '[Product] Detail Entity'; export const PRODUCT_SEARCH_RESULTS_BY_CODES_ENTITY = '[Product] Search Results By Codes Entity'; +export const PRODUCT_SEARCH_RESULTS_BY_CATEGORY_ENTITY = + '[Product] Search Results By Category Entity'; + export interface StateWithProduct { [PRODUCT_FEATURE]: ProductsState; } @@ -24,6 +27,7 @@ export interface ProductsState { details: EntityScopedLoaderState; search: ProductsSearchState; searchByCode: EntityScopedLoaderState; + searchByCategory: EntityScopedLoaderState; reviews: ProductReviewsState; references: ProductReferencesState; } diff --git a/projects/core/src/product/store/reducers/index.ts b/projects/core/src/product/store/reducers/index.ts index a1b9b5c2138..e8ffd632c54 100644 --- a/projects/core/src/product/store/reducers/index.ts +++ b/projects/core/src/product/store/reducers/index.ts @@ -13,6 +13,7 @@ import { ProductsState, PRODUCT_DETAIL_ENTITY, PRODUCT_SEARCH_RESULTS_BY_CODES_ENTITY, + PRODUCT_SEARCH_RESULTS_BY_CATEGORY_ENTITY, } from '../product-state'; import * as fromProductReferences from './product-references.reducer'; import * as fromProductReviews from './product-reviews.reducer'; @@ -24,6 +25,9 @@ export function getReducers(): ActionReducerMap { searchByCode: entityScopedLoaderReducer( PRODUCT_SEARCH_RESULTS_BY_CODES_ENTITY ), + searchByCategory: entityScopedLoaderReducer( + PRODUCT_SEARCH_RESULTS_BY_CATEGORY_ENTITY + ), details: entityScopedLoaderReducer(PRODUCT_DETAIL_ENTITY), reviews: fromProductReviews.reducer, references: fromProductReferences.reducer, diff --git a/projects/core/src/product/store/selectors/product-group.selectors.ts b/projects/core/src/product/store/selectors/product-group.selectors.ts index 0f87832fa73..da3c462c5ff 100644 --- a/projects/core/src/product/store/selectors/product-group.selectors.ts +++ b/projects/core/src/product/store/selectors/product-group.selectors.ts @@ -9,4 +9,5 @@ export * from './product-references.selectors'; export * from './product-reviews.selectors'; export * from './product-search.selectors'; export * from './product-search-by-code.selectors'; +export * from './product-search-by-category.selectors'; export * from './product.selectors'; diff --git a/projects/core/src/product/store/selectors/product-search-by-category.selectors.spec.ts b/projects/core/src/product/store/selectors/product-search-by-category.selectors.spec.ts new file mode 100644 index 00000000000..33152207941 --- /dev/null +++ b/projects/core/src/product/store/selectors/product-search-by-category.selectors.spec.ts @@ -0,0 +1,102 @@ +import { TestBed } from '@angular/core/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { StateUtils } from '../../../state/utils'; +import { Product } from '../../../model/product.model'; +import { PRODUCT_FEATURE, StateWithProduct } from '../product-state'; +import * as fromReducers from '../reducers'; +import * as ProductActions from '../actions/product-search-by-category.action'; +import { getSelectedProductSearchByCategoryStateFactory } from './product-search-by-category.selectors'; + +describe('getSelectedProductSearchByCategoryStateFactory', () => { + let store: Store; + + const categoryCode = 'testCategory'; + const scope = 'testScope'; + const products: Product[] = [ + { code: 'product1', name: 'Test Product 1' }, + { code: 'product2', name: 'Test Product 2' }, + ]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + StoreModule.forFeature(PRODUCT_FEATURE, fromReducers.getReducers()), + ], + }); + + store = TestBed.inject(Store); + spyOn(store, 'dispatch').and.callThrough(); + }); + + it('should return the initial loader state when no category is found', () => { + let result: StateUtils.LoaderState; + store + .select( + getSelectedProductSearchByCategoryStateFactory({ categoryCode, scope }) + ) + .subscribe((value) => (result = value)); + + expect(result).toEqual(StateUtils.initialLoaderState); + }); + + it('should return the correct product state when products are found', () => { + let result: StateUtils.LoaderState; + store + .select( + getSelectedProductSearchByCategoryStateFactory({ categoryCode, scope }) + ) + .subscribe((value) => (result = value)); + + store.dispatch( + new ProductActions.ProductSearchLoadByCategorySuccess({ + categoryCode, + scope, + products, + }) + ); + + expect(result.value).toEqual(products); + }); + + it('should return a loading state when loading is triggered', () => { + let result: StateUtils.LoaderState; + store + .select( + getSelectedProductSearchByCategoryStateFactory({ categoryCode, scope }) + ) + .subscribe((value) => (result = value)); + + store.dispatch( + new ProductActions.ProductSearchLoadByCategory({ + categoryCode, + scope, + }) + ); + + expect(result.loading).toBeTruthy(); + expect(result.value).toBeUndefined(); + }); + + it('should return an error state when the loading fails', () => { + let result: StateUtils.LoaderState; + const error = { message: 'Error occurred' }; + + store + .select( + getSelectedProductSearchByCategoryStateFactory({ categoryCode, scope }) + ) + .subscribe((value) => (result = value)); + + store.dispatch( + new ProductActions.ProductSearchLoadByCategoryFail({ + categoryCode, + scope, + error, + }) + ); + + expect(result.error).toBeTruthy(); + expect(result.success).toBeFalsy(); + }); +}); diff --git a/projects/core/src/product/store/selectors/product-search-by-category.selectors.ts b/projects/core/src/product/store/selectors/product-search-by-category.selectors.ts new file mode 100644 index 00000000000..75b3dcc76ad --- /dev/null +++ b/projects/core/src/product/store/selectors/product-search-by-category.selectors.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSelector, MemoizedSelector } from '@ngrx/store'; +import { Product } from '../../../model/product.model'; +import { StateUtils } from '../../../state/utils/index'; +import { EntityScopedLoaderState } from '../../../state/utils/scoped-loader/scoped-loader.state'; +import { ProductsState, StateWithProduct } from '../product-state'; +import { getProductsState } from './feature.selector'; + +export const getProductSearchByCategoryState: MemoizedSelector< + StateWithProduct, + EntityScopedLoaderState +> = createSelector( + getProductsState, + (state: ProductsState) => state.searchByCategory +); + +export const getSelectedProductSearchByCategoryStateFactory = (payload: { + categoryCode: string; + scope: string; +}): MemoizedSelector> => { + return createSelector( + getProductSearchByCategoryState, + (productSearchByCategoryResults) => + ( + StateUtils.entityLoaderStateSelector( + productSearchByCategoryResults, + payload.categoryCode + ) as any + )[payload.scope] || StateUtils.initialLoaderState + ); +}; diff --git a/projects/core/src/state/config/state-config.ts b/projects/core/src/state/config/state-config.ts index 63667b067ff..830a233c1d0 100644 --- a/projects/core/src/state/config/state-config.ts +++ b/projects/core/src/state/config/state-config.ts @@ -28,7 +28,7 @@ export abstract class StateConfig { /** * A set of state keys that should be transferred from server. */ - [key: string]: StateTransferType; + [key: string]: StateTransferType | undefined; }; }; }; diff --git a/projects/core/src/state/reducers/transfer-state.reducer.ts b/projects/core/src/state/reducers/transfer-state.reducer.ts index c61d80ff100..c31fe548cef 100644 --- a/projects/core/src/state/reducers/transfer-state.reducer.ts +++ b/projects/core/src/state/reducers/transfer-state.reducer.ts @@ -41,7 +41,7 @@ export function getTransferStateReducer( export function getServerTransferStateReducer( transferState: TransferState, - keys: { [key: string]: StateTransferType } + keys: { [key: string]: StateTransferType | undefined } ) { const transferStateKeys = filterKeysByType( keys, @@ -63,7 +63,7 @@ export function getServerTransferStateReducer( export function getBrowserTransferStateReducer( transferState: TransferState, - keys: { [key: string]: StateTransferType }, + keys: { [key: string]: StateTransferType | undefined }, isLoggedIn: boolean ) { const transferStateKeys = filterKeysByType( diff --git a/projects/core/src/state/utils/get-state-slice.ts b/projects/core/src/state/utils/get-state-slice.ts index 1c0dab8dc59..32604b6a5fe 100644 --- a/projects/core/src/state/utils/get-state-slice.ts +++ b/projects/core/src/state/utils/get-state-slice.ts @@ -102,7 +102,7 @@ export function getExclusionKeys(key: string, excludeKeys: string[]): string[] { } export function filterKeysByType( - keys: { [key: string]: StorageSyncType | StateTransferType }, + keys: { [key: string]: StorageSyncType | StateTransferType | undefined }, type: StorageSyncType | StateTransferType ): string[] { if (!keys) { 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 9923117908f..9dd21376486 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 97d874c7077..82f856fb384 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/schematics/package.json b/projects/schematics/package.json index 4a8feebc1d6..421692ab82c 100644 --- a/projects/schematics/package.json +++ b/projects/schematics/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/schematics", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Spartacus schematics", "keywords": [ "spartacus", @@ -21,7 +21,7 @@ "@angular/ssr": "^18.2.9", "semver": "^7.5.2", "ts-morph": "^23.0.0", - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular-devkit/core": "^18.2.9", diff --git a/projects/schematics/src/dependencies.json b/projects/schematics/src/dependencies.json index e06f9f8c3d6..21c472a7bb3 100644 --- a/projects/schematics/src/dependencies.json +++ b/projects/schematics/src/dependencies.json @@ -24,7 +24,7 @@ "typescript": "^5.2.2" }, "ssr-tests": { - "@spartacus/core": "2211.32.0-1", + "@spartacus/core": "2211.32.0", "http-proxy": "^1.18.1", "jest-circus": "^29.0.0", "jest-environment-node": "^29.0.0" @@ -46,7 +46,7 @@ "rxjs": "^7.8.0" }, "@spartacus/styles": { - "@fontsource/open-sans": "^4.5.14", + "@fontsource/open-sans": "^5.1.0", "@fortawesome/fontawesome-free": "6.5.1", "@ng-select/ng-select": "^13.9.1", "bootstrap": "^4.6.2" @@ -400,11 +400,11 @@ "@angular/forms": "^18.2.9", "@angular/router": "^18.2.9", "@sapui5/ts-types-esm": "1.120.1", - "@spartacus/cart": "2211.32.0-1", - "@spartacus/core": "2211.32.0-1", - "@spartacus/schematics": "2211.32.0-1", - "@spartacus/storefront": "2211.32.0-1", - "@spartacus/styles": "2211.32.0-1", + "@spartacus/cart": "2211.32.0", + "@spartacus/core": "2211.32.0", + "@spartacus/schematics": "2211.32.0", + "@spartacus/storefront": "2211.32.0", + "@spartacus/styles": "2211.32.0", "bootstrap": "^4.6.2", "rxjs": "^7.8.0" }, diff --git a/projects/ssr-tests/package.json b/projects/ssr-tests/package.json index 186f6742f89..a1d3c1edd03 100644 --- a/projects/ssr-tests/package.json +++ b/projects/ssr-tests/package.json @@ -1,6 +1,6 @@ { "name": "ssr-tests", - "version": "2211.32.0-1", + "version": "2211.32.0", "private": true, "description": "Spartacus SSR Tests", "keywords": [ @@ -14,10 +14,10 @@ "test": "../../node_modules/.bin/jest --config ./jest.config.js" }, "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { - "@spartacus/core": "2211.32.0-1", + "@spartacus/core": "2211.32.0", "http-proxy": "^1.18.1", "jest-circus": "^29.0.0", "jest-environment-node": "^29.0.0" diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/skip-focus-header-items-mobile.e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/skip-focus-header-items-mobile.e2e.cy.ts new file mode 100644 index 00000000000..1bf9bd0a4a9 --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/accessibility/skip-focus-header-items-mobile.e2e.cy.ts @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { formats } from '../../sample-data/viewports'; + +const mobileHeaderVisibleElements = [ + 'cx-banner a', + 'button.search', + 'cx-mini-cart a', +]; + +describe('Skipping elements from tab navigation in header', () => { + beforeEach(() => { + cy.viewport(formats.mobile.width, formats.mobile.height); + cy.visit('/'); + // close the cookie banner + cy.get('button').contains('Allow All').click(); + }); + + it('toggles elements tabindex', () => { + // when hamburger menu is closed + mobileHeaderVisibleElements.forEach((selector) => { + cy.get(selector).should('not.have.attr', 'tabIndex', '-1'); + }); + + // open hamburger menu + cy.get('button.cx-hamburger').click(); + + // elements are hidden from tabbing + mobileHeaderVisibleElements.forEach((selector) => { + cy.get(selector).should('have.attr', 'tabIndex', '-1'); + }); + + // close hamburger menu + cy.get('button.cx-hamburger').click(); + + // elements are available for tabbing again + mobileHeaderVisibleElements.forEach((selector) => { + cy.get(selector).should('not.have.attr', 'tabIndex', '-1'); + }); + }); + + it('persists tabbing sequence', () => { + // menu is closed + cy.get('button.cx-hamburger').focus(); + cy.pressTab(); + cy.focused().should('have.attr', 'aria-label', 'SAP Commerce'); + cy.pressTab(true); + + // open menu + cy.focused().click(); + cy.get('button.cx-hamburger').focus(); + cy.pressTab(); + cy.focused().should('have.text', 'Sign In / Register'); + + // return to button and close the menu + cy.pressTab(true); + cy.focused().click(); + + // tab through header + cy.pressTab(); + cy.focused().should('have.attr', 'aria-label', 'SAP Commerce'); + cy.pressTab(); + cy.focused().should('have.attr', 'title', 'Search'); + cy.pressTab(); + cy.focused().should( + 'have.attr', + 'aria-label', + '0 items currently in your cart' + ); + }); + + it('restores header item tabbing after navigation', () => { + cy.get('button.cx-hamburger').click(); + cy.pressTab(true); + cy.focused().click(); + + // elements are available for tabbing again + mobileHeaderVisibleElements.forEach((selector) => { + cy.get(selector).should('not.have.attr', 'tabIndex', '-1'); + }); + + // tab through header in reverse order + cy.get('cx-mini-cart a').focus(); + cy.pressTab(true); + cy.focused().should('have.attr', 'title', 'Search'); + cy.pressTab(true); + cy.focused().should('have.attr', 'aria-label', 'SAP Commerce'); + cy.pressTab(true); + cy.focused().should('have.class', 'cx-hamburger'); + }); +}); 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 b918d62194b..00f3b991672 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?*' //TODO check '/users/*/customercoupons/claim?*' instead when enable 'enableClaimCustomerCouponWithCodeInRequestBody' with the new Occ endpoint is available since Commerce 2211.28 ); cy.get('.cx-asm-customer-360-promotion-listing-row') .contains(customer_coupon.name) diff --git a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/asm/asm.emulation.core-e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/asm/asm.emulation.core-e2e.cy.ts index 1cd94194a4b..f93adb0dda0 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/asm/asm.emulation.core-e2e.cy.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/asm/asm.emulation.core-e2e.cy.ts @@ -61,9 +61,17 @@ context('Assisted Service Module', () => { cy.get('.cx-page-title').then((el) => { const orderNumber = el.text().match(/\d+/)[0]; cy.log('--> End session'); + // const homepage = waitForPage('homepage', 'getHomePage'); cy.get('cx-customer-emulation') .findByText(/End Session/i) .click(); + // Make sure homepage is visible + cy.wait(`@getHomePage`).its('response.statusCode').should('eq', 200); + cy.get('cx-global-message div').should( + 'contain', + 'You have successfully signed out.' + ); + cy.wait(1000); asm.startCustomerEmulationWithOrderID(orderNumber, customer); }); }); 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 1a6ea661457..8ba6eb7a173 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?*' //TODO check '/users/*/customercoupons/claim?*' instead when enable 'enableClaimCustomerCouponWithCodeInRequestBody' with the new Occ endpoint is available since Commerce 2211.28 ); cy.get('.cx-asm-customer-360-promotion-listing-row') .contains('Buy over $1000 get 20% off on cart') 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 31a0b38dada..04bb6e91a60 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/e2e/regression/user_access/navigation-login-a11y.e2e.cy.ts b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/user_access/navigation-login-a11y.e2e.cy.ts new file mode 100644 index 00000000000..3ba06a4027e --- /dev/null +++ b/projects/storefrontapp-e2e-cypress/cypress/e2e/regression/user_access/navigation-login-a11y.e2e.cy.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as login from '../../../helpers/login'; +import { viewportContext } from '../../../helpers/viewport-context'; + +function assertNavigationButtonsAttributes(buttonsSelector: string) { + cy.get(buttonsSelector).each(($btn) => { + const btnAriaControl = $btn.attr('aria-controls'); + cy.wrap($btn) + .should('have.attr', 'title', `${btnAriaControl} Menu`) + .should('have.attr', 'aria-label', btnAriaControl); + }); +} + +describe('Navigation Login', () => { + let user; + viewportContext(['desktop'], () => { + before(() => { + cy.visit('/login'); + user = login.registerUserFromLoginPage(); + }); + + it('should login and logout successfully and have correct Navigation Menu buttons values', () => { + login.loginUser(); + + const tokenRevocationRequestAlias = + login.listenForTokenRevocationRequest(); + + cy.get('cx-login button') + .as('myAccountBtn') + .invoke('attr', 'ariaLabel') + .contains(`Hi, ${user.firstName} ${user.lastName}`); + + const mainCategoryMenuBrandsRootBtnSelector = + 'cx-category-navigation button[aria-controls]'; + assertNavigationButtonsAttributes(mainCategoryMenuBrandsRootBtnSelector); + + login.signOutUser(); + cy.wait(tokenRevocationRequestAlias); + cy.get('@myAccountBtn').should('not.exist'); + assertNavigationButtonsAttributes(mainCategoryMenuBrandsRootBtnSelector); + }); + }); +}); diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/anonymous-consents.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/anonymous-consents.ts index 4ac7ec08215..7e1fd5cc09a 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/anonymous-consents.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/anonymous-consents.ts @@ -133,7 +133,7 @@ export function checkDialogClosed() { } export function closeAnonymousConsentsDialog() { - cy.get(`${ANONYMOUS_DIALOG} button.close`).click({ force: true }); + cy.get(`${ANONYMOUS_DIALOG} button.close`).first().click({ force: true }); } export function toggleAnonymousConsent(position) { diff --git a/projects/storefrontapp-e2e-cypress/cypress/helpers/asm.ts b/projects/storefrontapp-e2e-cypress/cypress/helpers/asm.ts index b8c32c367df..433ea470c1b 100644 --- a/projects/storefrontapp-e2e-cypress/cypress/helpers/asm.ts +++ b/projects/storefrontapp-e2e-cypress/cypress/helpers/asm.ts @@ -531,6 +531,7 @@ export function startCustomerEmulationWithOrderID( cy.get('cx-customer-selection form').within(() => { cy.get('[formcontrolname="searchOrder"]') .should('not.be.disabled') + .focus() .type(order); cy.get('[formcontrolname="searchOrder"]').should('have.value', `${order}`); }); 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 2aa2e10210f..10c879ba601 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,6 +116,7 @@ export function claimCoupon(couponCode: string) { 'getClaimedCouponPage' ); + //TODO when 'enableClaimCustomerCouponWithCodeInRequestBody' is true, call the mothod of 'waitForClaimCouponWithCodeInBody' instead once ClaimCustomerCouponWithCodeInBody works in the backend(available since Commerce 2211.28) const claimCoupon = waitForClaimCoupon(couponCode); const getCoupons = waitForGetCoupons(); @@ -115,6 +132,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 +209,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 +267,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 +289,24 @@ export function testClaimCustomerCoupon() { }); }); } + +export function testClaimCustomerCouponWithCodeInBody() { + describe('Claim customer coupon with code in requestBody', () => { + //TODO uncomment when enable 'enableClaimCustomerCouponWithCodeInRequestBody' to make ClaimCustomerCouponWithCodeInBody work in the backend, the new Occ endpoint is available since Commerce 2211.28. + it.skip('should claim customer coupon successfully with code in requestBody', () => { + verifClaimCouponSuccessWithCodeInBody(validCouponCode); + cy.saveLocalStorage(); + }); + + //TODO uncomment when enable 'enableClaimCustomerCouponWithCodeInRequestBody' to make ClaimCustomerCouponWithCodeInBody work in the backend, the new Occ endpoint is available since Commerce 2211.28. + it.skip('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 583a9d33c97..583673b4356 100644 --- a/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts +++ b/projects/storefrontapp/src/app/spartacus/spartacus-features.module.ts @@ -327,10 +327,12 @@ if (environment.cpq) { a11yOrganizationsBanner: true, a11yOrganizationListHeadingOrder: true, a11yCartImportConfirmationMessage: true, + a11yAnonymousConsentMessageInDialog: true, a11yReplenishmentOrderFieldset: true, a11yListOversizedFocus: true, a11yStoreFinderOverflow: true, a11yMobileFocusOnFirstNavigationItem: true, + a11yQuickOrderSearchListKeyboardNavigation: false, a11yCartSummaryHeadingOrder: true, a11ySearchBoxMobileFocus: true, a11yFacetKeyboardNavigation: true, @@ -350,6 +352,7 @@ if (environment.cpq) { a11yVisibleFocusOverflows: true, a11yTruncatedTextForResponsiveView: true, a11yTruncatedTextStoreFinder: true, + a11yTruncatedTextUnitLevelOrderHistory: true, a11ySemanticPaginationLabel: true, a11yPreventCartItemsFormRedundantRecreation: true, a11yMyAccountLinkOutline: true, @@ -366,10 +369,11 @@ if (environment.cpq) { a11yDisabledCouponAndQuickOrderActionButtonsInsteadOfRequiredFields: true, a11yFacetsDialogFocusHandling: true, + a11yResetFocusAfterNavigating: true, headerLayoutForSmallerViewports: true, a11yStoreFinderAlerts: true, - a11yStoreFinderLabel: true, a11yFormErrorMuteIcon: true, + a11yStoreFinderLabel: true, a11yCxMessageFocus: true, occCartNameAndDescriptionInHttpRequestBody: true, a11yLinkBtnsToTertiaryBtns: true, @@ -389,7 +393,7 @@ if (environment.cpq) { a11yAddToWishlistFocus: true, a11ySearchBoxFocusOnEscape: true, a11yUpdatingCartNoNarration: true, - a11yPasswordVisibilityBtnValueOverflow: true, + a11yPasswordVisibliltyBtnValueOverflow: true, a11yItemCounterFocus: true, a11yScrollToReviewByShowReview: true, a11yViewHoursButtonIconContrast: true, @@ -397,6 +401,7 @@ if (environment.cpq) { a11yCheckoutStepsLandmarks: true, a11yQTY2Quantity: true, a11yImproveButtonsInCardComponent: true, + a11yMiniCartFocusOnMobile: true, a11yApprovalProcessWithNoClearable: true, a11yPostRegisterSuccessMessage: true, a11yDeleteButton2First: true, @@ -406,19 +411,28 @@ if (environment.cpq) { a11yTextSpacingAdjustments: true, a11yTableHeaderReadout: true, a11ySearchboxAssistiveMessage: true, + updateConsentGivenInOnChanges: true, a11yDifferentiateFocusedAndSelected: true, + a11yQuickOrderSearchBoxRefocusOnClose: true, + a11yKeyboardFocusInSearchBox: true, a11yAddPaddingToCarouselPanel: true, + a11yFocusOnCardAfterSelecting: true, + a11ySearchableDropdownFirstElementFocus: true, + a11yHideConsentButtonWhenBannerVisible: true, cmsBottomHeaderSlotUsingFlexStyles: true, - useSiteThemeService: false, + useSiteThemeService: true, enableConsecutiveCharactersPasswordRequirement: true, enablePasswordsCannotMatchInPasswordUpdateForm: true, allPageMetaResolversEnabledInCsr: true, a11yPdpGridArrangement: true, + a11yHamburgerMenuTrapFocus: true, useExtendedMediaComponentConfiguration: true, - a11yScrollToTopPositioning: true, showRealTimeStockInPDP: false, + a11yScrollToTopPositioning: false, a11yWrapReviewOrderInSection: true, + enableCarouselCategoryProducts: true, enableSecurePasswordValidation: true, + enableClaimCustomerCouponWithCodeInRequestBody: false, }; return appFeatureToggles; }), diff --git a/projects/storefrontlib/cms-components/anonymous-consent-management/anonymous-consent-management.module.ts b/projects/storefrontlib/cms-components/anonymous-consent-management/anonymous-consent-management.module.ts index a3b0328671f..a25360aa618 100644 --- a/projects/storefrontlib/cms-components/anonymous-consent-management/anonymous-consent-management.module.ts +++ b/projects/storefrontlib/cms-components/anonymous-consent-management/anonymous-consent-management.module.ts @@ -9,6 +9,7 @@ import { NgModule } from '@angular/core'; import { CmsConfig, DeferLoadingStrategy, + FeaturesConfigModule, I18nModule, provideDefaultConfig, } from '@spartacus/core'; @@ -18,7 +19,12 @@ import { defaultAnonymousConsentLayoutConfig } from './default-anonymous-consent import { AnonymousConsentOpenDialogComponent } from './open-dialog/anonymous-consent-open-dialog.component'; @NgModule({ - imports: [CommonModule, I18nModule, KeyboardFocusModule], + imports: [ + CommonModule, + I18nModule, + KeyboardFocusModule, + FeaturesConfigModule, + ], providers: [ provideDefaultConfig(defaultAnonymousConsentLayoutConfig), provideDefaultConfig({ diff --git a/projects/storefrontlib/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.html b/projects/storefrontlib/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.html index 76a16d0b45c..969c9b37e89 100644 --- a/projects/storefrontlib/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.html +++ b/projects/storefrontlib/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.html @@ -4,7 +4,7 @@ class="anonymous-consent-banner" >
-
+
{{ 'anonymousConsents.banner.title' | cxTranslate }} @@ -23,6 +23,25 @@
+
+
+
+ {{ 'anonymousConsents.banner.title' | cxTranslate }} +
+
+ {{ 'anonymousConsents.banner.description' | cxTranslate }} +
+
+ +
+ + +
+
diff --git a/projects/storefrontlib/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.ts b/projects/storefrontlib/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.ts index e3267fb3ed3..e95e03bf9a8 100644 --- a/projects/storefrontlib/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.ts +++ b/projects/storefrontlib/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.ts @@ -5,7 +5,7 @@ */ import { Component, OnDestroy, ViewContainerRef } from '@angular/core'; -import { AnonymousConsentsService } from '@spartacus/core'; +import { AnonymousConsentsService, useFeatureStyles } from '@spartacus/core'; import { Observable, Subscription } from 'rxjs'; import { tap } from 'rxjs/operators'; import { LAUNCH_CALLER } from '../../../layout/launch-dialog/config/launch-config'; @@ -25,7 +25,9 @@ export class AnonymousConsentManagementBannerComponent implements OnDestroy { protected anonymousConsentsService: AnonymousConsentsService, protected vcr: ViewContainerRef, protected launchDialogService: LaunchDialogService - ) {} + ) { + useFeatureStyles('a11yScrollToTopPositioning'); + } viewDetails(): void { this.hideBanner(); diff --git a/projects/storefrontlib/cms-components/anonymous-consent-management/open-dialog/anonymous-consent-open-dialog.component.html b/projects/storefrontlib/cms-components/anonymous-consent-management/open-dialog/anonymous-consent-open-dialog.component.html index e1e481b3440..0de6c607987 100644 --- a/projects/storefrontlib/cms-components/anonymous-consent-management/open-dialog/anonymous-consent-open-dialog.component.html +++ b/projects/storefrontlib/cms-components/anonymous-consent-management/open-dialog/anonymous-consent-open-dialog.component.html @@ -1,3 +1,16 @@ - + + + + + + + diff --git a/projects/storefrontlib/cms-components/anonymous-consent-management/open-dialog/anonymous-consent-open-dialog.component.spec.ts b/projects/storefrontlib/cms-components/anonymous-consent-management/open-dialog/anonymous-consent-open-dialog.component.spec.ts index c484a559268..58aada72e6b 100644 --- a/projects/storefrontlib/cms-components/anonymous-consent-management/open-dialog/anonymous-consent-open-dialog.component.spec.ts +++ b/projects/storefrontlib/cms-components/anonymous-consent-management/open-dialog/anonymous-consent-open-dialog.component.spec.ts @@ -1,10 +1,27 @@ import { ElementRef, ViewContainerRef } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { I18nTestingModule } from '@spartacus/core'; -import { LaunchDialogService, LAUNCH_CALLER } from '@spartacus/storefront'; -import { EMPTY } from 'rxjs'; +import { + AnonymousConsentsService, + ConsentTemplate, + I18nTestingModule, +} from '@spartacus/core'; +import { LAUNCH_CALLER, LaunchDialogService } from '@spartacus/storefront'; +import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive'; +import { EMPTY, Observable, of } from 'rxjs'; import { AnonymousConsentOpenDialogComponent } from './anonymous-consent-open-dialog.component'; +class MockAnonymousConsentsService { + isBannerVisible(): Observable { + return EMPTY; + } + giveAllConsents(): Observable { + return EMPTY; + } + getTemplatesUpdated(): Observable { + return EMPTY; + } + toggleBannerDismissed(_dismissed: boolean): void {} +} class MockLaunchDialogService implements Partial { openDialog( _caller: LAUNCH_CALLER, @@ -23,8 +40,12 @@ describe('AnonymousConsentOpenDialogComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [I18nTestingModule], - declarations: [AnonymousConsentOpenDialogComponent], + declarations: [AnonymousConsentOpenDialogComponent, MockFeatureDirective], providers: [ + { + provide: AnonymousConsentsService, + useClass: MockAnonymousConsentsService, + }, { provide: LaunchDialogService, useClass: MockLaunchDialogService, @@ -45,15 +66,35 @@ describe('AnonymousConsentOpenDialogComponent', () => { }); describe('openDialog', () => { - it('should call modalService.open', () => { - spyOn(launchDialogService, 'openDialog'); - component.openDialog(); - - expect(launchDialogService.openDialog).toHaveBeenCalledWith( - LAUNCH_CALLER.ANONYMOUS_CONSENT, - component.openElement, - component['vcr'] - ); + it('should not show the button if the banner is visible', () => { + component.bannerVisible$ = of(true); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + const button = + fixture.debugElement.nativeElement.querySelector('button'); + expect(button).toBeNull(); + }); }); + + it('should show the button and open the dialog if the banner is not visible', waitForAsync(() => { + component.bannerVisible$ = of(false); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + const button = + fixture.debugElement.nativeElement.querySelector('button'); + expect(button).not.toBeNull(); + + spyOn(launchDialogService, 'openDialog'); + button.click(); + + expect(launchDialogService.openDialog).toHaveBeenCalledWith( + LAUNCH_CALLER.ANONYMOUS_CONSENT, + component.openElement, + component['vcr'] + ); + }); + })); }); }); diff --git a/projects/storefrontlib/cms-components/anonymous-consent-management/open-dialog/anonymous-consent-open-dialog.component.ts b/projects/storefrontlib/cms-components/anonymous-consent-management/open-dialog/anonymous-consent-open-dialog.component.ts index b4e78f6991a..b407739a5fc 100644 --- a/projects/storefrontlib/cms-components/anonymous-consent-management/open-dialog/anonymous-consent-open-dialog.component.ts +++ b/projects/storefrontlib/cms-components/anonymous-consent-management/open-dialog/anonymous-consent-open-dialog.component.ts @@ -10,9 +10,11 @@ import { ViewChild, ViewContainerRef, } from '@angular/core'; -import { LaunchDialogService } from '../../../layout/launch-dialog/services/launch-dialog.service'; -import { LAUNCH_CALLER } from '../../../layout/launch-dialog/config/launch-config'; +import { AnonymousConsentsService, useFeatureStyles } from '@spartacus/core'; +import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; +import { LAUNCH_CALLER } from '../../../layout/launch-dialog/config/launch-config'; +import { LaunchDialogService } from '../../../layout/launch-dialog/services/launch-dialog.service'; @Component({ selector: 'cx-anonymous-consent-open-dialog', @@ -20,11 +22,16 @@ import { take } from 'rxjs/operators'; }) export class AnonymousConsentOpenDialogComponent { @ViewChild('open') openElement: ElementRef; + bannerVisible$: Observable = + this.anonymousConsentsService.isBannerVisible(); constructor( protected vcr: ViewContainerRef, + protected anonymousConsentsService: AnonymousConsentsService, protected launchDialogService: LaunchDialogService - ) {} + ) { + useFeatureStyles('a11yHideConsentButtonWhenBannerVisible'); + } openDialog(): void { const dialog = this.launchDialogService.openDialog( diff --git a/projects/storefrontlib/cms-components/myaccount/consent-management/components/consent-form/consent-management-form.component.spec.ts b/projects/storefrontlib/cms-components/myaccount/consent-management/components/consent-form/consent-management-form.component.spec.ts index 977a181318f..a981a4e62b9 100644 --- a/projects/storefrontlib/cms-components/myaccount/consent-management/components/consent-form/consent-management-form.component.spec.ts +++ b/projects/storefrontlib/cms-components/myaccount/consent-management/components/consent-form/consent-management-form.component.spec.ts @@ -4,11 +4,18 @@ import { By } from '@angular/platform-browser'; import { ANONYMOUS_CONSENT_STATUS, ConsentTemplate, + FeatureConfigService, I18nTestingModule, } from '@spartacus/core'; import { ConsentManagementFormComponent } from './consent-management-form.component'; import { MockFeatureDirective } from 'projects/storefrontlib/shared/test/mock-feature-directive'; +class MockFeatureConfigService { + isEnabled(): boolean { + return true; + } +} + describe('ConsentManagementFormComponent', () => { let component: ConsentManagementFormComponent; let fixture: ComponentFixture; @@ -18,6 +25,9 @@ describe('ConsentManagementFormComponent', () => { TestBed.configureTestingModule({ imports: [I18nTestingModule], declarations: [ConsentManagementFormComponent, MockFeatureDirective], + providers: [ + { provide: FeatureConfigService, useClass: MockFeatureConfigService }, + ], }).compileComponents(); })); @@ -120,7 +130,6 @@ describe('ConsentManagementFormComponent', () => { component.consentTemplate = mockConsentTemplate; component.consentGiven = true; - component.ngOnInit(); fixture.detectChanges(); const checkbox = el.query(By.css('input')).nativeElement as HTMLElement; diff --git a/projects/storefrontlib/cms-components/myaccount/consent-management/components/consent-form/consent-management-form.component.ts b/projects/storefrontlib/cms-components/myaccount/consent-management/components/consent-form/consent-management-form.component.ts index 0c48aab9691..3a641b778d4 100644 --- a/projects/storefrontlib/cms-components/myaccount/consent-management/components/consent-form/consent-management-form.component.ts +++ b/projects/storefrontlib/cms-components/myaccount/consent-management/components/consent-form/consent-management-form.component.ts @@ -4,18 +4,28 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { + Component, + EventEmitter, + inject, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges, +} from '@angular/core'; import { AnonymousConsent, ANONYMOUS_CONSENT_STATUS, ConsentTemplate, + FeatureConfigService, } from '@spartacus/core'; @Component({ selector: 'cx-consent-management-form', templateUrl: './consent-management-form.component.html', }) -export class ConsentManagementFormComponent implements OnInit { +export class ConsentManagementFormComponent implements OnInit, OnChanges { consentGiven = false; @Input() @@ -35,23 +45,24 @@ export class ConsentManagementFormComponent implements OnInit { template: ConsentTemplate; }>(); + private featureConfigService = inject(FeatureConfigService, { + optional: true, + }); + constructor() { // Intentional empty constructor } ngOnInit(): void { - if (this.consent) { - this.consentGiven = Boolean( - this.consent.consentState === ANONYMOUS_CONSENT_STATUS.GIVEN - ); - } else { - if (this.consentTemplate && this.consentTemplate.currentConsent) { - if (this.consentTemplate.currentConsent.consentWithdrawnDate) { - this.consentGiven = false; - } else if (this.consentTemplate.currentConsent.consentGivenDate) { - this.consentGiven = true; - } - } + this.updateConsentGiven(); + } + + ngOnChanges(changes: SimpleChanges): void { + if ( + this.featureConfigService?.isEnabled('updateConsentGivenInOnChanges') && + (changes.consent || changes.consentTemplate) + ) { + this.updateConsentGiven(); } } @@ -67,4 +78,20 @@ export class ConsentManagementFormComponent implements OnInit { isRequired(templateId: string | undefined): boolean { return templateId ? this.requiredConsents.includes(templateId) : false; } + + protected updateConsentGiven(): void { + if (this.consent) { + this.consentGiven = Boolean( + this.consent.consentState === ANONYMOUS_CONSENT_STATUS.GIVEN + ); + } else { + if (this.consentTemplate && this.consentTemplate.currentConsent) { + if (this.consentTemplate.currentConsent.consentWithdrawnDate) { + this.consentGiven = false; + } else if (this.consentTemplate.currentConsent.consentGivenDate) { + this.consentGiven = true; + } + } + } + } } diff --git a/projects/storefrontlib/cms-components/myaccount/my-account-v2/my-account-v2-consent-management/components/consent-form/my-account-v2-consent-management-form.component.spec.ts b/projects/storefrontlib/cms-components/myaccount/my-account-v2/my-account-v2-consent-management/components/consent-form/my-account-v2-consent-management-form.component.spec.ts index 89db9b79c7e..2f80f227892 100644 --- a/projects/storefrontlib/cms-components/myaccount/my-account-v2/my-account-v2-consent-management/components/consent-form/my-account-v2-consent-management-form.component.spec.ts +++ b/projects/storefrontlib/cms-components/myaccount/my-account-v2/my-account-v2-consent-management/components/consent-form/my-account-v2-consent-management-form.component.spec.ts @@ -4,10 +4,17 @@ import { By } from '@angular/platform-browser'; import { ANONYMOUS_CONSENT_STATUS, ConsentTemplate, + FeatureConfigService, I18nTestingModule, } from '@spartacus/core'; import { MyAccountV2ConsentManagementFormComponent } from './my-account-v2-consent-management-form.component'; +class MockFeatureConfigService { + isEnabled(): boolean { + return true; + } +} + describe('MyAccountV2ConsentManagementFormComponent', () => { let component: MyAccountV2ConsentManagementFormComponent; let fixture: ComponentFixture; @@ -17,6 +24,12 @@ describe('MyAccountV2ConsentManagementFormComponent', () => { TestBed.configureTestingModule({ imports: [I18nTestingModule], declarations: [MyAccountV2ConsentManagementFormComponent], + providers: [ + { + provide: FeatureConfigService, + useClass: MockFeatureConfigService, + }, + ], }).compileComponents(); })); 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..06b512fa678 --- /dev/null +++ b/projects/storefrontlib/cms-components/myaccount/my-coupons/claim-dialog/claim-dialog.component.ts @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: 2025 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 54ebc3b1e42..27fd40a1745 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 f27097e47b5..a7dccda60b2 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 254c72ab32d..97bdc497f94 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'; @@ -14,8 +15,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'; @@ -142,10 +144,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', @@ -182,6 +191,7 @@ describe('MyCouponsComponent', () => { provide: MyCouponsComponentService, useValue: myCouponsComponentService, }, + { provide: LaunchDialogService, useClass: MockLaunchDialogService }, { provide: FeaturesConfig, useValue: { @@ -196,6 +206,7 @@ describe('MyCouponsComponent', () => { fixture = TestBed.createComponent(MyCouponsComponent); component = fixture.componentInstance; el = fixture.debugElement; + launchDialogService = TestBed.inject(LaunchDialogService); customerCouponService.getCustomerCoupons.and.returnValue( of(emptyCouponResult) @@ -317,4 +328,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 8b2fda253c6..4971f3ad48c 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 7224e1f2c41..80f58e967d5 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/cms-components/navigation/navigation/navigation-ui.component.html b/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.html index 89ea959fa34..f2b0f989db2 100644 --- a/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.html +++ b/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.html @@ -90,6 +90,9 @@ [attr.aria-haspopup]="true" [attr.aria-expanded]="false" [attr.aria-label]="node.title" + [attr.title]=" + 'navigation.menuButonTitle' | cxTranslate: { title: node.title } + " (click)="toggleOpen($any($event))" (mouseenter)="onMouseEnter($event)" (keydown.space)="toggleOpen($any($event))" @@ -108,7 +111,10 @@ [attr.aria-haspopup]="true" [attr.aria-expanded]="false" [attr.aria-controls]="node.title" - [attr.aria-describedby]="'greeting'" + [attr.aria-label]="node.title" + [attr.title]=" + 'navigation.menuButonTitle' | cxTranslate: { title: node.title } + " (click)="toggleOpen($any($event))" (mouseenter)="onMouseEnter($event)" (keydown.space)="onSpace($any($event))" diff --git a/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.spec.ts b/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.spec.ts index a4cfe925b56..d450979a444 100644 --- a/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.spec.ts +++ b/projects/storefrontlib/cms-components/navigation/navigation/navigation-ui.component.spec.ts @@ -462,4 +462,29 @@ describe('Navigation UI Component', () => { expect(mockHeader.focus).toHaveBeenCalled(); })); }); + + describe('trigger buttions ariaLabel/title', () => { + it('should have the ariaLabel and title set', () => { + const rootNode = mockNode.children?.[0]; + const childNode = rootNode?.children?.[0]; + const rootTitle = rootNode?.title; + const childTitle = childNode?.title; + fixture.detectChanges(); + const nestedTriggerButton = fixture.debugElement.query( + By.css(`button[aria-label="${childTitle}"]`) + ).nativeElement; + const rootTriggerButton = fixture.debugElement.query( + By.css(`button[aria-label="${rootTitle}"]`) + ).nativeElement; + + expect(nestedTriggerButton).toBeDefined(); + expect(rootTriggerButton).toBeDefined(); + expect(rootTriggerButton.getAttribute('title')).toEqual( + `navigation.menuButonTitle title:${rootTitle}` + ); + expect(nestedTriggerButton.getAttribute('title')).toEqual( + `navigation.menuButonTitle title:${childTitle}` + ); + }); + }); }); diff --git a/projects/storefrontlib/cms-components/navigation/scroll-to-top/scroll-to-top.component.html b/projects/storefrontlib/cms-components/navigation/scroll-to-top/scroll-to-top.component.html index 8dfb220c8a3..5da35d2699b 100644 --- a/projects/storefrontlib/cms-components/navigation/scroll-to-top/scroll-to-top.component.html +++ b/projects/storefrontlib/cms-components/navigation/scroll-to-top/scroll-to-top.component.html @@ -3,7 +3,6 @@ [attr.aria-label]="'navigation.scrollToTop' | cxTranslate" [title]="'navigation.scrollToTop' | cxTranslate" class="cx-scroll-to-top-btn" - [class.elevated-position]="elevatedPosition$ | async" (click)="scrollToTop($event)" (focusout)="onFocusOut()" (keydown.Tab)="onTab($event)" diff --git a/projects/storefrontlib/cms-components/navigation/scroll-to-top/scroll-to-top.component.spec.ts b/projects/storefrontlib/cms-components/navigation/scroll-to-top/scroll-to-top.component.spec.ts index 492243b83bf..1c8cb9ca0ab 100644 --- a/projects/storefrontlib/cms-components/navigation/scroll-to-top/scroll-to-top.component.spec.ts +++ b/projects/storefrontlib/cms-components/navigation/scroll-to-top/scroll-to-top.component.spec.ts @@ -2,7 +2,6 @@ import { DebugElement } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { - AnonymousConsentsService, CmsScrollToTopComponent, FeatureConfigService, I18nTestingModule, @@ -29,12 +28,6 @@ class MockFeatureConfigService { } } -class MockAnonymousConsentsService { - isBannerVisible() { - return of(false); - } -} - describe('ScrollToTopComponent', () => { let component: ScrollToTopComponent; let fixture: ComponentFixture; @@ -55,10 +48,6 @@ describe('ScrollToTopComponent', () => { provide: FeatureConfigService, useClass: MockFeatureConfigService, }, - { - provide: AnonymousConsentsService, - useClass: MockAnonymousConsentsService, - }, ], }).compileComponents(); diff --git a/projects/storefrontlib/cms-components/navigation/scroll-to-top/scroll-to-top.component.ts b/projects/storefrontlib/cms-components/navigation/scroll-to-top/scroll-to-top.component.ts index a37b6022463..3ea9b4f1970 100644 --- a/projects/storefrontlib/cms-components/navigation/scroll-to-top/scroll-to-top.component.ts +++ b/projects/storefrontlib/cms-components/navigation/scroll-to-top/scroll-to-top.component.ts @@ -20,10 +20,8 @@ import { FeatureConfigService, ScrollBehavior, WindowRef, - AnonymousConsentsService, - useFeatureStyles, } from '@spartacus/core'; -import { take, Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; import { CmsComponentData } from '../../../cms-structure/page/model/cms-component-data'; import { SelectFocusUtility } from '../../../layout/a11y/index'; import { ICON_TYPE } from '../../misc/icon/icon.model'; @@ -39,7 +37,6 @@ export class ScrollToTopComponent implements OnInit { @HostBinding('class.display') display: boolean | undefined; - protected elevatedPosition$: Observable | undefined; protected window: Window | undefined = this.winRef.nativeWindow; protected scrollBehavior: ScrollBehavior = ScrollBehavior.SMOOTH; protected displayThreshold: number = (this.window?.innerHeight ?? 400) / 2; @@ -52,26 +49,15 @@ export class ScrollToTopComponent implements OnInit { @Optional() protected featureConfigService = inject(FeatureConfigService, { optional: true, }); - @Optional() protected anonymousConsentsService = inject( - AnonymousConsentsService, - { - optional: true, - } - ); constructor( protected winRef: WindowRef, protected componentData: CmsComponentData, protected selectFocusUtility: SelectFocusUtility - ) { - useFeatureStyles('a11yScrollToTopPositioning'); - } + ) {} ngOnInit(): void { this.setConfig(); - if (this.featureConfigService?.isEnabled('a11yScrollToTopPositioning')) { - this.elevatedPosition$ = this.anonymousConsentsService?.isBannerVisible(); - } } @HostListener('window:scroll', ['$event']) diff --git a/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.ts b/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.ts index 54473f0fcee..5afcf32a15d 100644 --- a/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.ts +++ b/projects/storefrontlib/cms-components/navigation/search-box/search-box.component.ts @@ -24,8 +24,8 @@ import { FeatureConfigService, PageType, RoutingService, - WindowRef, useFeatureStyles, + WindowRef, } from '@spartacus/core'; import { Observable, of, Subscription } from 'rxjs'; import { filter, map, switchMap, tap } from 'rxjs/operators'; @@ -156,6 +156,7 @@ export class SearchBoxComponent implements OnInit, OnDestroy { protected routingService: RoutingService ) { useFeatureStyles('a11ySearchboxLabel'); + useFeatureStyles('a11yKeyboardFocusInSearchBox'); } /** @@ -480,6 +481,15 @@ export class SearchBoxComponent implements OnInit, OnDestroy { ]; // Focus on first index moving to last if (results.length) { + if ( + this.featureConfigService?.isEnabled( + 'a11ySearchableDropdownFirstElementFocus' + ) + ) { + this.winRef.document + .querySelector('header') + ?.classList.remove('mouse-focus'); + } if (focusedIndex >= results.length - 1) { results[0].focus(); } else { diff --git a/projects/storefrontlib/cms-components/navigation/search-box/search-box.module.ts b/projects/storefrontlib/cms-components/navigation/search-box/search-box.module.ts index 8d7516e167a..fbddd34755e 100644 --- a/projects/storefrontlib/cms-components/navigation/search-box/search-box.module.ts +++ b/projects/storefrontlib/cms-components/navigation/search-box/search-box.module.ts @@ -15,11 +15,12 @@ import { provideDefaultConfig, } from '@spartacus/core'; import { OutletModule } from '../../../cms-structure'; +import { KeyboardFocusModule } from '../../../layout/a11y/keyboard-focus/keyboard-focus.module'; +import { CarouselModule } from '../../../shared'; import { MediaModule } from '../../../shared/components/media/media.module'; import { IconModule } from '../../misc/icon/icon.module'; import { HighlightPipe } from './highlight.pipe'; import { SearchBoxComponent } from './search-box.component'; -import { CarouselModule } from '../../../shared'; @NgModule({ imports: [ @@ -32,6 +33,7 @@ import { CarouselModule } from '../../../shared'; OutletModule, FeaturesConfigModule, CarouselModule, + KeyboardFocusModule, ], providers: [ provideDefaultConfig({ diff --git a/projects/storefrontlib/cms-components/product/carousel/product-carousel/product-carousel.component.spec.ts b/projects/storefrontlib/cms-components/product/carousel/product-carousel/product-carousel.component.spec.ts index 6f6f8720d0c..2bd51297100 100644 --- a/projects/storefrontlib/cms-components/product/carousel/product-carousel/product-carousel.component.spec.ts +++ b/projects/storefrontlib/cms-components/product/carousel/product-carousel/product-carousel.component.spec.ts @@ -111,6 +111,14 @@ const mockProductsFromSearchByCodes: Record> = { }, }, }; +const mockProductsFromSearchByCategory: Record< + string, + Record +> = { + electronics: { + code: [{ code: 'prod3' }, { code: 'prod4' }, { code: 'prod5' }], + }, +}; const mockComponentData: CmsProductCarouselComponent = { uid: '001', @@ -122,6 +130,7 @@ const mockComponentData: CmsProductCarouselComponent = { title: 'Mock Title', name: 'Mock Product Carousel', container: 'false', + categoryCodes: 'electronics ', }; const mockComponentWithAddCartData: CmsProductCarouselComponent = { ...mockComponentData, @@ -134,11 +143,13 @@ const MockCmsProductCarouselComponent = >{ const MockCmsProductCarouselComponentAddToCart = >{ data$: of(mockComponentWithAddCartData), }; + class MockProductService implements Partial { get(productCode: string): Observable { return of(mockProducts[productCode]); } } + class MockFeatureConfigService { isEnabled(feature: string): boolean { return feature === 'useProductCarouselBatchApi'; @@ -153,11 +164,22 @@ class MockProductSearchByCodeService } } +class MockProductSearchByCategoryService + implements Partial +{ + get({ categoryCode, scope }: { categoryCode: string; scope?: string }) { + const products = + mockProductsFromSearchByCategory[categoryCode]?.[scope ?? ''] || []; + return of(products); + } +} + describe('ProductCarouselComponent', () => { let component: ProductCarouselComponent; let fixture: ComponentFixture; let featureConfigService: MockFeatureConfigService; let productSearchByCodeService: MockProductSearchByCodeService; + let productSearchByCategoryService: MockProductSearchByCategoryService; const testBedDefaults = { imports: [I18nTestingModule], @@ -185,6 +207,10 @@ describe('ProductCarouselComponent', () => { provide: ProductSearchByCodeService, useClass: MockProductSearchByCodeService, }, + { + provide: ProductSearchByCategoryService, + useClass: MockProductSearchByCategoryService, + }, ], }; @@ -201,6 +227,9 @@ describe('ProductCarouselComponent', () => { productSearchByCodeService = TestBed.inject( ProductSearchByCodeService ) as MockProductSearchByCodeService; + productSearchByCategoryService = TestBed.inject( + ProductSearchByCategoryService + ) as MockProductSearchByCategoryService; fixture.detectChanges(); }); @@ -236,7 +265,9 @@ describe('ProductCarouselComponent', () => { })); it('FeatureToggleEnable: Should use batch API with carouselMinimal scope when componentMappingExist is false', (done) => { - spyOn(featureConfigService, 'isEnabled').and.returnValue(true); + spyOn(featureConfigService, 'isEnabled').and.callFake( + (val: string) => val === 'useProductCarouselBatchApi' + ); spyOn(productSearchByCodeService, 'get').and.callThrough(); component.items$.subscribe((items) => { @@ -303,7 +334,9 @@ describe('ProductCarouselComponent', () => { }); it('FeatureToggleEnable: Should use batch API with carousel scope when componentMappingExist is true', (done) => { - spyOn(featureConfigService, 'isEnabled').and.returnValue(true); + spyOn(featureConfigService, 'isEnabled').and.callFake( + (val: string) => val === 'useProductCarouselBatchApi' + ); spyOn(productSearchByCodeService, 'get').and.callThrough(); component.items$.subscribe((items) => { @@ -323,4 +356,40 @@ describe('ProductCarouselComponent', () => { }); }); }); + describe('Carousel with category products', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule(testBedDefaults); + + TestBed.overrideProvider(CmsComponentData, { + useValue: MockCmsProductCarouselComponentAddToCart, + }); + TestBed.compileComponents(); + fixture = TestBed.createComponent(ProductCarouselComponent); + component = fixture.componentInstance; + featureConfigService = TestBed.inject( + FeatureConfigService + ) as MockFeatureConfigService; + productSearchByCategoryService = TestBed.inject( + ProductSearchByCategoryService + ) as MockProductSearchByCategoryService; + fixture.detectChanges(); + }); + + it('should retrieve products by category', (done) => { + spyOn(featureConfigService, 'isEnabled').and.callFake( + (val: string) => val !== 'useProductCarouselBatchApi' + ); + + spyOn(productSearchByCategoryService, 'get').and.callThrough(); + + component.items$.subscribe((items) => { + expect(items?.length).toBe(5); + + expect(productSearchByCategoryService.get).toHaveBeenCalledTimes(2); + + done(); + }); + }); + }); }); diff --git a/projects/storefrontlib/cms-components/product/carousel/product-carousel/product-carousel.component.ts b/projects/storefrontlib/cms-components/product/carousel/product-carousel/product-carousel.component.ts index 336d73eeab2..f6d0c89b012 100644 --- a/projects/storefrontlib/cms-components/product/carousel/product-carousel/product-carousel.component.ts +++ b/projects/storefrontlib/cms-components/product/carousel/product-carousel/product-carousel.component.ts @@ -10,10 +10,11 @@ import { FeatureConfigService, Product, ProductScope, - ProductService, + ProductSearchByCategoryService, ProductSearchByCodeService, + ProductService, } from '@spartacus/core'; -import { Observable } from 'rxjs'; +import { Observable, of, switchMap, zip } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { CmsComponentData } from '../../../../cms-structure/page/model/cms-component-data'; @@ -28,6 +29,8 @@ export class ProductCarouselComponent { protected productSearchByCodeService: ProductSearchByCodeService = inject( ProductSearchByCodeService ); + protected productSearchByCategoryService: ProductSearchByCategoryService = + inject(ProductSearchByCategoryService); protected readonly PRODUCT_SCOPE = [ProductScope.LIST, ProductScope.STOCK]; protected readonly PRODUCT_SCOPE_ITEM = [ProductScope.LIST_ITEM]; @@ -52,6 +55,7 @@ export class ProductCarouselComponent { */ items$: Observable[]> = this.componentData$.pipe( + switchMap((data) => this.handleCategoryCodes(data)), map((data) => { const componentMappingExist = !!data.composition?.inner?.length; const codes = data.productCodes?.trim().split(' ') ?? []; @@ -78,4 +82,36 @@ export class ProductCarouselComponent { protected componentData: CmsComponentData, protected productService: ProductService ) {} + handleCategoryCodes(data: model): Observable { + const categoryCodes = data?.categoryCodes?.split(' '); + + // Try to add category codes to the carousel product codes + if ( + categoryCodes && + this.featureConfigService.isEnabled('enableCarouselCategoryProducts') + ) { + return zip( + categoryCodes.map((categoryCode) => + this.productSearchByCategoryService.get({ + categoryCode, + scope: ProductScope.CODE, + }) + ) + ).pipe( + map((results) => { + const codes = results + .flat() + .map((product) => product?.code) + .join(' '); + + return { + ...data, + productCodes: data.productCodes + ' ' + codes, + }; + }) + ); + } + + return of(data); + } } diff --git a/projects/storefrontlib/layout/a11y/index.ts b/projects/storefrontlib/layout/a11y/index.ts index eaadc2b9e15..acfd3342c0e 100644 --- a/projects/storefrontlib/layout/a11y/index.ts +++ b/projects/storefrontlib/layout/a11y/index.ts @@ -7,3 +7,4 @@ export * from './keyboard-focus/index'; export * from './skip-link/index'; export * from './btn-like-link'; +export * from './on-dom-change'; diff --git a/projects/storefrontlib/layout/a11y/keyboard-focus/index.ts b/projects/storefrontlib/layout/a11y/keyboard-focus/index.ts index 05f6ca2881d..f5d1074cde7 100644 --- a/projects/storefrontlib/layout/a11y/keyboard-focus/index.ts +++ b/projects/storefrontlib/layout/a11y/keyboard-focus/index.ts @@ -8,6 +8,7 @@ // NOT exposing all it to the public API. export * from './focus.directive'; export { FocusConfig, TrapFocus, TrapFocusType } from './keyboard-focus.model'; +export * from './skip-focus.directive'; export * from './keyboard-focus.module'; export * from './focus-testing.module'; export * from './services/index'; diff --git a/projects/storefrontlib/layout/a11y/keyboard-focus/keyboard-focus.module.ts b/projects/storefrontlib/layout/a11y/keyboard-focus/keyboard-focus.module.ts index 737d1e712a6..4d04dc9444c 100644 --- a/projects/storefrontlib/layout/a11y/keyboard-focus/keyboard-focus.module.ts +++ b/projects/storefrontlib/layout/a11y/keyboard-focus/keyboard-focus.module.ts @@ -7,6 +7,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FocusDirective } from './focus.directive'; +import { SkipFocusDirective } from './skip-focus.directive'; const directives = [ // PersistFocusDirective, @@ -18,6 +19,7 @@ const directives = [ // TrapFocusDirective, // TabFocusDirective, FocusDirective, + SkipFocusDirective, ]; @NgModule({ diff --git a/projects/storefrontlib/layout/a11y/keyboard-focus/services/select-focus.util.spec.ts b/projects/storefrontlib/layout/a11y/keyboard-focus/services/select-focus.util.spec.ts index 504e5fe350d..6b020003ac5 100644 --- a/projects/storefrontlib/layout/a11y/keyboard-focus/services/select-focus.util.spec.ts +++ b/projects/storefrontlib/layout/a11y/keyboard-focus/services/select-focus.util.spec.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { SelectFocusUtility } from './select-focus.util'; diff --git a/projects/storefrontlib/layout/a11y/keyboard-focus/skip-focus.directive.ts b/projects/storefrontlib/layout/a11y/keyboard-focus/skip-focus.directive.ts new file mode 100644 index 00000000000..7716751ddd0 --- /dev/null +++ b/projects/storefrontlib/layout/a11y/keyboard-focus/skip-focus.directive.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Directive, + ElementRef, + inject, + Input, + OnChanges, + Renderer2, +} from '@angular/core'; +import { WindowRef } from '@spartacus/core'; + +export interface SkipFocusConfig { + isEnabled: boolean; + /** + * elements selectors that should not be skipped + * for ex `activeElementSelectors: ['button.cx-hamburger']` + */ + activeElementSelectors?: string[]; +} +/** + * Directive that removes all visible and focusable elements + * in a host container from `tab` or `shift-tab` navigation + * except elements in `activeElementSelectors` config. + */ +@Directive({ + selector: '[cxSkipFocus]', +}) +export class SkipFocusDirective implements OnChanges { + @Input('cxSkipFocus') config: SkipFocusConfig = { isEnabled: false }; + + protected elementRef = inject(ElementRef); + protected winRef = inject(WindowRef); + protected renderer = inject(Renderer2); + + public ngOnChanges(): void { + this.excludeFromFocus( + this.config.isEnabled, + this.config.activeElementSelectors + ); + } + + protected excludeFromFocus( + isEnabled: boolean, + skipSelectors: string[] = [] + ): void { + if (!this.winRef.isBrowser()) { + return; + } + const tabindex = isEnabled ? '-1' : '0'; + const focusableElementsSelector = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"]'; + const focusableElements = this.elementRef.nativeElement.querySelectorAll( + focusableElementsSelector + ); + Array.from(focusableElements || []).forEach((el) => { + const element = el as HTMLElement; + const shouldSkip = skipSelectors.some((selector) => { + return element.matches(selector); + }); + if (!shouldSkip && this.isElementVisible(element)) { + this.renderer.setAttribute(element, 'tabindex', tabindex); + } + }); + } + + protected isElementVisible(element: HTMLElement): boolean { + const style = this.winRef.nativeWindow?.getComputedStyle(element); + return ( + style?.visibility !== 'hidden' && + style?.display !== 'none' && + element.offsetWidth > 0 && + element.offsetHeight > 0 + ); + } +} diff --git a/projects/storefrontlib/layout/a11y/on-dom-change/dom-change.directive.spec.ts b/projects/storefrontlib/layout/a11y/on-dom-change/dom-change.directive.spec.ts new file mode 100644 index 00000000000..907f079c1db --- /dev/null +++ b/projects/storefrontlib/layout/a11y/on-dom-change/dom-change.directive.spec.ts @@ -0,0 +1,101 @@ +import { Component, ElementRef } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { DomChangeDirective } from './dom-change.directive'; + +@Component({ + template: ` +
+
+
+ `, +}) +class TestHostComponent {} + +describe('DomChangeDirective', () => { + // let component: TestHostComponent; + let fixture: ComponentFixture; + let testElement: ElementRef; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [TestHostComponent, DomChangeDirective], + imports: [BrowserAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + + testElement = fixture.debugElement.query(By.directive(DomChangeDirective)); + })); + + it('should emit when a child element is added', (done) => { + const directive = fixture.debugElement + .query(By.directive(DomChangeDirective)) + .injector.get(DomChangeDirective); + const newElement = document.createElement('div'); + + directive.cxDomChange.subscribe((mutation: MutationRecord) => { + expect(mutation.type).toBe('childList'); + done(); + }); + + // Set DOM + testElement.nativeElement.appendChild(newElement); + fixture.detectChanges(); + }); + + it('should emit when a child element is removed', (done) => { + const directive = fixture.debugElement + .query(By.directive(DomChangeDirective)) + .injector.get(DomChangeDirective); + const childElement = + testElement.nativeElement.querySelector('.targetElement'); + + directive.cxDomChange.subscribe((mutation: MutationRecord) => { + expect(mutation.type).toBe('childList'); + done(); + }); + + // Set DOM + testElement.nativeElement.removeChild(childElement); + fixture.detectChanges(); + }); + + it('should filter mutations based on the target selector', (done) => { + const directive = fixture.debugElement + .query(By.directive(DomChangeDirective)) + .injector.get(DomChangeDirective); + directive.cxDomChangeTargetSelector = '.targetElement'; + + directive.cxDomChange.subscribe((mutation: MutationRecord) => { + expect(mutation.target).toHaveClass('targetElement'); + done(); + }); + + // Set DOM + const targetElement = + testElement.nativeElement.querySelector('.targetElement'); + targetElement.appendChild(document.createTextNode('Test Text')); + fixture.detectChanges(); + }); + + it('should not emit when mutations do not match target selector', () => { + let called = false; + const directive = fixture.debugElement + .query(By.directive(DomChangeDirective)) + .injector.get(DomChangeDirective); + directive.cxDomChangeTargetSelector = '.non-matching-selector'; + + directive.cxDomChange.subscribe(() => { + called = true; + }); + + // Set DOM + const newElement = document.createElement('div'); + testElement.nativeElement.appendChild(newElement); + fixture.detectChanges(); + + expect(called).toBe(false); + }); +}); diff --git a/projects/storefrontlib/layout/a11y/on-dom-change/dom-change.directive.ts b/projects/storefrontlib/layout/a11y/on-dom-change/dom-change.directive.ts new file mode 100644 index 00000000000..d0313b44180 --- /dev/null +++ b/projects/storefrontlib/layout/a11y/on-dom-change/dom-change.directive.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Directive, + ElementRef, + EventEmitter, + inject, + Input, + OnDestroy, + Output, +} from '@angular/core'; + +/** + * Custom Directive used to enable clients to track (react to) Host's Element DOM changes. + * + * Whenever the mutation (Host's element DOM subthree change) is detected it will trigger the + * _cxDomChange_ Output allowing clients to react to the Host's element DOM changes. + * + * Optional _cxDomChangeTargetSelector_ Input enables clients to be more specifixc when reacting to + * the Host's element DOM changes by allowing them to specify css selector (relative to Host Element) + * that narrows down the number of _cxDomChange_ Output executions to be triggered only when + * _cxDomChangeTargetSelector_ Input relevant elements changes are detected. + */ +@Directive({ + selector: '[cxDomChange]', +}) +export class DomChangeDirective implements OnDestroy { + protected changes: MutationObserver; + + /** + * Optional Css Selector Input filtering DOM mutations to those targeting only elements described via provided selector + */ + @Input() cxDomChangeTargetSelector?: string; + + @Output() + public cxDomChange = new EventEmitter(); + + protected elementRef: ElementRef; + + constructor() { + this.elementRef = inject(ElementRef); + this.changes = new MutationObserver((mutations: MutationRecord[]) => { + const affectedMutations = this.cxDomChangeTargetSelector + ? mutations.filter( + (mutation) => + mutation.target === + this.elementRef.nativeElement.querySelector( + this.cxDomChangeTargetSelector + ) + ) + : mutations; + affectedMutations.forEach((mutation) => this.cxDomChange.emit(mutation)); + }); + + this.changes.observe(this.elementRef.nativeElement, { + subtree: true, + childList: true, + }); + } + + ngOnDestroy(): void { + this.changes.disconnect(); + } +} diff --git a/projects/storefrontlib/layout/a11y/on-dom-change/dom-change.module.ts b/projects/storefrontlib/layout/a11y/on-dom-change/dom-change.module.ts new file mode 100644 index 00000000000..eeec3602c74 --- /dev/null +++ b/projects/storefrontlib/layout/a11y/on-dom-change/dom-change.module.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { DomChangeDirective } from './dom-change.directive'; + +@NgModule({ + imports: [CommonModule], + declarations: [DomChangeDirective], + exports: [DomChangeDirective], +}) +export class DomChangeModule {} diff --git a/projects/storefrontlib/layout/a11y/on-dom-change/index.ts b/projects/storefrontlib/layout/a11y/on-dom-change/index.ts new file mode 100644 index 00000000000..df08d06e4a6 --- /dev/null +++ b/projects/storefrontlib/layout/a11y/on-dom-change/index.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP Spartacus team + * SPDX-FileCopyrightText: 2025 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './dom-change.directive'; +export * from './dom-change.module'; +export { DomChangeModule } from './dom-change.module'; diff --git a/projects/storefrontlib/layout/a11y/skip-link/service/skip-link.service.ts b/projects/storefrontlib/layout/a11y/skip-link/service/skip-link.service.ts index 88a553dff51..635438e32f6 100644 --- a/projects/storefrontlib/layout/a11y/skip-link/service/skip-link.service.ts +++ b/projects/storefrontlib/layout/a11y/skip-link/service/skip-link.service.ts @@ -53,7 +53,16 @@ export class SkipLinkService { } } - scrollToTarget(skipLink: SkipLink): void { + scrollToTarget(scrollTo: string | SkipLink): void { + const skipLink = + typeof scrollTo === 'string' + ? this.findSkipLinkByKey(scrollTo) + : scrollTo; + + if (!skipLink) { + return; + } + const target = skipLink.target instanceof HTMLElement ? skipLink.target @@ -77,6 +86,10 @@ export class SkipLinkService { } } + protected findSkipLinkByKey(key: string): SkipLink | undefined { + return this.skipLinks$.value.find((skipLink) => skipLink.key === key); + } + protected getSkipLinkIndexInArray(key: string): number { let index: number = this.config.skipLinks?.findIndex((skipLink) => skipLink.key === key) ?? 0; diff --git a/projects/storefrontlib/layout/launch-dialog/config/launch-config.ts b/projects/storefrontlib/layout/launch-dialog/config/launch-config.ts index 1854c193c35..5f9fc9e9852 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/storefrontlib/layout/main/storefront.component.html b/projects/storefrontlib/layout/main/storefront.component.html index f3146127e0e..a06324c0755 100644 --- a/projects/storefrontlib/layout/main/storefront.component.html +++ b/projects/storefrontlib/layout/main/storefront.component.html @@ -3,12 +3,16 @@
- + +
{ @Input() cxOutlet: string; } +class MockSkipLinkService implements Partial { + getSkipLinks() { + return of([ + { + key: 'cx-main', + target: document.createElement('div'), + i18nKey: 'skipLink.main', + }, + ]); + } + scrollToTarget(): void {} +} + describe('StorefrontComponent', () => { let component: StorefrontComponent; let fixture: ComponentFixture; let el: DebugElement; let routingService: RoutingService; + let skipLinkService: SkipLinkService; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -88,6 +104,16 @@ describe('StorefrontComponent', () => { provide: HamburgerMenuService, useClass: MockHamburgerMenuService, }, + { + provide: SkipLinkService, + useClass: MockSkipLinkService, + }, + { + provide: FeatureConfigService, + useValue: { + isEnabled: () => true, + }, + }, ], }).compileComponents(); })); @@ -97,6 +123,7 @@ describe('StorefrontComponent', () => { component = fixture.componentInstance; el = fixture.debugElement; routingService = TestBed.inject(RoutingService); + skipLinkService = TestBed.inject(SkipLinkService); }); it('should create', () => { @@ -145,4 +172,63 @@ describe('StorefrontComponent', () => { component.collapseMenuIfClickOutside(mockEvent); expect(component.collapseMenu).not.toHaveBeenCalled(); }); + + describe('onNavigation', () => { + it('should set navigation flags correctly when navigation starts', () => { + component['onNavigation'](true); + expect(component.startNavigating).toBe(true); + expect(component.stopNavigating).toBe(false); + }); + + it('should set navigation flags correctly when navigation ends', () => { + component['onNavigation'](false); + expect(component.startNavigating).toBe(false); + expect(component.stopNavigating).toBe(true); + }); + + it('should call skipLinkService.scrollToTarget when navigation ends and document has active element', () => { + spyOn(skipLinkService, 'scrollToTarget'); + spyOn(component['featureConfigService'], 'isEnabled').and.returnValue( + true + ); + + const mockDocument = { + activeElement: document.createElement('button'), + body: document.createElement('body'), + }; + component['document'] = mockDocument as any; + + component['onNavigation'](false); + + expect(skipLinkService.scrollToTarget).toHaveBeenCalledWith('cx-main'); + }); + + it('should not call skipLinkService.scrollToTarget when navigation ends and focus is on body', () => { + spyOn(skipLinkService, 'scrollToTarget'); + spyOn(component['featureConfigService'], 'isEnabled').and.returnValue( + true + ); + const body = document.createElement('body'); + const mockDocument = { + activeElement: body, + body, + }; + component['document'] = mockDocument as any; + + component['onNavigation'](false); + + expect(skipLinkService.scrollToTarget).not.toHaveBeenCalled(); + }); + + it('should not call skipLinkService.scrollToTarget when feature is disabled', () => { + spyOn(skipLinkService, 'scrollToTarget'); + spyOn(component['featureConfigService'], 'isEnabled').and.returnValue( + false + ); + + component['onNavigation'](false); + + expect(skipLinkService.scrollToTarget).not.toHaveBeenCalled(); + }); + }); }); diff --git a/projects/storefrontlib/layout/main/storefront.component.ts b/projects/storefrontlib/layout/main/storefront.component.ts index 2b7cbf8d402..7e4051315ea 100644 --- a/projects/storefrontlib/layout/main/storefront.component.ts +++ b/projects/storefrontlib/layout/main/storefront.component.ts @@ -6,13 +6,15 @@ import { Component, + DestroyRef, ElementRef, HostBinding, HostListener, + inject, OnDestroy, OnInit, + Optional, ViewChild, - inject, } from '@angular/core'; import { FeatureConfigService, @@ -22,11 +24,15 @@ import { import { Observable, Subscription, tap } from 'rxjs'; import { FocusConfig, + SkipFocusConfig, KeyboardFocusService, } from '../a11y/keyboard-focus/index'; -import { SkipLinkComponent } from '../a11y/skip-link/index'; +import { SkipLinkComponent, SkipLinkService } from '../a11y/skip-link/index'; import { HamburgerMenuService } from '../header/hamburger-menu/hamburger-menu.service'; import { StorefrontOutlets } from './storefront-outlets.model'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { distinctUntilChanged } from 'rxjs/operators'; +import { DOCUMENT } from '@angular/common'; @Component({ selector: 'cx-storefront', @@ -34,11 +40,23 @@ import { StorefrontOutlets } from './storefront-outlets.model'; }) export class StorefrontComponent implements OnInit, OnDestroy { navigateSubscription: Subscription; + focusConfig: FocusConfig = { disableMouseFocus: true, trap: false }; + skipFocusConfig: SkipFocusConfig = { + isEnabled: false, + activeElementSelectors: ['button.cx-hamburger'], + }; isExpanded$: Observable = this.hamburgerMenuService.isExpanded; readonly StorefrontOutlets = StorefrontOutlets; private featureConfigService = inject(FeatureConfigService); + protected destroyRef = inject(DestroyRef); + @Optional() protected document = inject(DOCUMENT, { + optional: true, + }); + @Optional() protected skipLinkService = inject(SkipLinkService, { + optional: true, + }); @HostBinding('class.start-navigating') startNavigating: boolean; @HostBinding('class.stop-navigating') stopNavigating: boolean; @@ -83,15 +101,13 @@ export class StorefrontComponent implements OnInit, OnDestroy { useFeatureStyles('cmsBottomHeaderSlotUsingFlexStyles'); useFeatureStyles('headerLayoutForSmallerViewports'); useFeatureStyles('a11yPdpGridArrangement'); + useFeatureStyles('a11yKeyboardFocusInSearchBox'); } ngOnInit(): void { this.navigateSubscription = this.routingService .isNavigating() - .subscribe((val) => { - this.startNavigating = val === true; - this.stopNavigating = val === false; - }); + .subscribe((val) => this.onNavigation(val)); if ( this.featureConfigService.isEnabled( 'a11yMobileFocusOnFirstNavigationItem' @@ -105,6 +121,10 @@ export class StorefrontComponent implements OnInit, OnDestroy { }) ); } + + if (this.featureConfigService.isEnabled('a11yHamburgerMenuTrapFocus')) { + this.trapFocusOnMenuIfExpanded(); + } } collapseMenuIfClickOutside(event: any): void { @@ -121,6 +141,18 @@ export class StorefrontComponent implements OnInit, OnDestroy { this.hamburgerMenuService.toggle(true); } + protected trapFocusOnMenuIfExpanded(): void { + this.isExpanded$ + .pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) + .subscribe((isExpanded) => { + this.focusConfig = { ...this.focusConfig, trap: isExpanded }; + this.skipFocusConfig = { + ...this.skipFocusConfig, + isEnabled: isExpanded, + }; + }); + } + protected focusOnFirstNavigationItem() { const closestNavigationUi = this.elementRef.nativeElement.querySelector( 'header cx-navigation-ui' @@ -138,4 +170,18 @@ export class StorefrontComponent implements OnInit, OnDestroy { this.navigateSubscription.unsubscribe(); } } + + protected onNavigation(isNavigating: boolean): void { + this.startNavigating = isNavigating === true; + this.stopNavigating = isNavigating === false; + + // After clicking a link the focus should move to the first available item in the main content area. + if ( + this.featureConfigService.isEnabled('a11yResetFocusAfterNavigating') && + this.stopNavigating && + this.document?.activeElement !== this.document?.body + ) { + this.skipLinkService?.scrollToTarget('cx-main'); + } + } } diff --git a/projects/storefrontlib/package.json b/projects/storefrontlib/package.json index 23452257d85..436004f72eb 100644 --- a/projects/storefrontlib/package.json +++ b/projects/storefrontlib/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/storefront", - "version": "2211.32.0-1", + "version": "2211.32.0", "keywords": [ "spartacus", "storefront", @@ -10,7 +10,7 @@ "repository": "https://github.com/SAP/spartacus/tree/develop/projects/storefrontlib", "license": "Apache-2.0", "dependencies": { - "tslib": "^2.6.2" + "tslib": "^2.8.1" }, "peerDependencies": { "@angular/common": "^18.2.9", diff --git a/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consent-dialog.component.html b/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consent-dialog.component.html index 600a6a98465..7147bf922b9 100644 --- a/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consent-dialog.component.html +++ b/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consent-dialog.component.html @@ -38,6 +38,19 @@

class="cx-dialog-separator col-sm-12 d-xs-block d-sm-block d-md-none" >

+ + +
+ +
+
diff --git a/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consent-dialog.component.spec.ts b/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consent-dialog.component.spec.ts index ccacc98c671..b66b4af24d5 100644 --- a/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consent-dialog.component.spec.ts +++ b/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consent-dialog.component.spec.ts @@ -374,6 +374,14 @@ describe('AnonymousConsentsDialogComponent', () => { }); }); + describe('closeMessage', () => { + it('should reset message$ subject', () => { + spyOn(component.message$, 'next').and.stub(); + component.closeMessage(); + expect(component.message$.next).toHaveBeenCalledWith(null); + }); + }); + describe('ngOnDestroy', () => { it('should call unsubscribe', () => { spyOn(component['subscriptions'], 'unsubscribe').and.stub(); diff --git a/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consent-dialog.component.ts b/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consent-dialog.component.ts index 0343921e4c7..54a5680fec1 100644 --- a/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consent-dialog.component.ts +++ b/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consent-dialog.component.ts @@ -24,7 +24,7 @@ import { GlobalMessageType, useFeatureStyles, } from '@spartacus/core'; -import { combineLatest, Observable, Subscription } from 'rxjs'; +import { combineLatest, Observable, Subject, Subscription } from 'rxjs'; import { distinctUntilChanged, take, tap } from 'rxjs/operators'; import { ICON_TYPE } from '../../../cms-components/misc/icon/index'; import { FocusConfig } from '../../../layout/a11y/keyboard-focus/index'; @@ -59,6 +59,8 @@ export class AnonymousConsentDialogComponent implements OnInit, OnDestroy { @Optional() globalMessageService = inject(GlobalMessageService, { optional: true, }); + globalMessageType = GlobalMessageType; + message$ = new Subject<{ type: GlobalMessageType; key: string } | null>(); @HostListener('click', ['$event']) handleClick(event: UIEvent): void { @@ -83,6 +85,7 @@ export class AnonymousConsentDialogComponent implements OnInit, OnDestroy { } useFeatureStyles('a11yUseButtonsForBtnLinks'); useFeatureStyles('a11yExpandedFocusIndicator'); + useFeatureStyles('a11yAnonymousConsentMessageInDialog'); } ngOnInit(): void { @@ -121,7 +124,13 @@ export class AnonymousConsentDialogComponent implements OnInit, OnDestroy { ) .subscribe(() => this.onConsentWithdrawnSuccess()) ); - this.close('rejectAll'); + if ( + !this.featureConfigService.isEnabled( + 'a11yAnonymousConsentMessageInDialog' + ) + ) { + this.close('rejectAll'); + } } allowAll(): void { @@ -151,7 +160,13 @@ export class AnonymousConsentDialogComponent implements OnInit, OnDestroy { ) .subscribe(() => this.onConsentGivenSuccess()) ); - this.close('allowAll'); + if ( + !this.featureConfigService.isEnabled( + 'a11yAnonymousConsentMessageInDialog' + ) + ) { + this.close('allowAll'); + } } private isRequiredConsent(template: ConsentTemplate): boolean { @@ -194,6 +209,13 @@ export class AnonymousConsentDialogComponent implements OnInit, OnDestroy { protected onConsentGivenSuccess(): void { if ( + this.featureConfigService.isEnabled('a11yAnonymousConsentMessageInDialog') + ) { + this.message$.next({ + type: GlobalMessageType.MSG_TYPE_CONFIRMATION, + key: 'consentManagementForm.message.success.given', + }); + } else if ( this.featureConfigService.isEnabled('a11yNotificationsOnConsentChange') ) { this.globalMessageService?.add( @@ -205,6 +227,13 @@ export class AnonymousConsentDialogComponent implements OnInit, OnDestroy { protected onConsentWithdrawnSuccess(): void { if ( + this.featureConfigService.isEnabled('a11yAnonymousConsentMessageInDialog') + ) { + this.message$.next({ + type: GlobalMessageType.MSG_TYPE_CONFIRMATION, + key: 'consentManagementForm.message.success.withdrawn', + }); + } else if ( this.featureConfigService.isEnabled('a11yNotificationsOnConsentChange') ) { this.globalMessageService?.add( @@ -214,6 +243,10 @@ export class AnonymousConsentDialogComponent implements OnInit, OnDestroy { } } + closeMessage(): void { + this.message$.next(null); + } + ngOnDestroy(): void { this.subscriptions.unsubscribe(); } diff --git a/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consents-dialog.module.ts b/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consents-dialog.module.ts index 3d9bd409473..879917f4cf1 100644 --- a/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consents-dialog.module.ts +++ b/projects/storefrontlib/shared/components/anonymous-consents-dialog/anonymous-consents-dialog.module.ts @@ -9,6 +9,7 @@ import { NgModule } from '@angular/core'; import { FeaturesConfigModule, I18nModule } from '@spartacus/core'; import { IconModule } from '../../../cms-components/misc/icon/icon.module'; import { ConsentManagementModule } from '../../../cms-components/myaccount/consent-management/consent-management.module'; +import { MessageComponentModule } from '../../../cms-components/misc/message'; import { KeyboardFocusModule } from '../../../layout/a11y/keyboard-focus/index'; import { SpinnerModule } from '../spinner/spinner.module'; import { AnonymousConsentDialogComponent } from './anonymous-consent-dialog.component'; @@ -22,6 +23,7 @@ import { AnonymousConsentDialogComponent } from './anonymous-consent-dialog.comp ConsentManagementModule, KeyboardFocusModule, FeaturesConfigModule, + MessageComponentModule, ], declarations: [AnonymousConsentDialogComponent], exports: [AnonymousConsentDialogComponent], diff --git a/projects/storefrontlib/shared/components/carousel/carousel.component.html b/projects/storefrontlib/shared/components/carousel/carousel.component.html index 74fcf0e9779..23759f3e7e7 100644 --- a/projects/storefrontlib/shared/components/carousel/carousel.component.html +++ b/projects/storefrontlib/shared/components/carousel/carousel.component.html @@ -96,11 +96,7 @@

{{ title }}

-
+
@@ -125,7 +121,6 @@

{{ title }}

numberOfSlides: size, } " - role="tab" > diff --git a/projects/storefrontstyles/package.json b/projects/storefrontstyles/package.json index 557cb323172..7100771ff52 100644 --- a/projects/storefrontstyles/package.json +++ b/projects/storefrontstyles/package.json @@ -1,6 +1,6 @@ { "name": "@spartacus/styles", - "version": "2211.32.0-1", + "version": "2211.32.0", "description": "Style library containing global styles", "keywords": [ "spartacus", @@ -18,7 +18,7 @@ }, "devDependencies": {}, "peerDependencies": { - "@fontsource/open-sans": "^4.5.14", + "@fontsource/open-sans": "^5.1.0", "@fortawesome/fontawesome-free": "6.5.1", "@ng-select/ng-select": "^13.9.1", "bootstrap": "^4.6.2" diff --git a/projects/storefrontstyles/scss/components/content/navigation/_scroll-to-top.scss b/projects/storefrontstyles/scss/components/content/navigation/_scroll-to-top.scss index 7828e563f4d..deeb4cdda3b 100644 --- a/projects/storefrontstyles/scss/components/content/navigation/_scroll-to-top.scss +++ b/projects/storefrontstyles/scss/components/content/navigation/_scroll-to-top.scss @@ -12,12 +12,6 @@ animation: popup 1s 1; } - @include forFeature('a11yScrollToTopPositioning') { - &:has(.elevated-position) { - bottom: 180px; - } - } - button { height: inherit; width: inherit; diff --git a/projects/storefrontstyles/scss/components/content/tab/_tab.scss b/projects/storefrontstyles/scss/components/content/tab/_tab.scss index f6cad2b2917..64a176b4774 100644 --- a/projects/storefrontstyles/scss/components/content/tab/_tab.scss +++ b/projects/storefrontstyles/scss/components/content/tab/_tab.scss @@ -104,6 +104,7 @@ background-color: var(--cx-tab-btn-bg-color); border: var(--cx-tab-btn-border); border-radius: var(--cx-tab-btn-border-radius); + color: inherit; } } @@ -119,6 +120,7 @@ text-align: start; height: 63px; position: relative; + color: inherit; &:before { margin: 0px 15px; diff --git a/projects/storefrontstyles/scss/components/layout/_storefront.scss b/projects/storefrontstyles/scss/components/layout/_storefront.scss index 8f41db5f972..62c12f72713 100644 --- a/projects/storefrontstyles/scss/components/layout/_storefront.scss +++ b/projects/storefrontstyles/scss/components/layout/_storefront.scss @@ -35,6 +35,12 @@ box-shadow: 0 0 0 0; } } + + @include forFeature('a11yKeyboardFocusInSearchBox') { + :focus-within { + --cx-visual-focus-width: 0; + } + } } main { 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; + } + } + } +} diff --git a/projects/storefrontstyles/scss/components/myaccount/anonymous-consent/_anonymous-consent-dialog.scss b/projects/storefrontstyles/scss/components/myaccount/anonymous-consent/_anonymous-consent-dialog.scss index 7f070c10926..487e6b82399 100644 --- a/projects/storefrontstyles/scss/components/myaccount/anonymous-consent/_anonymous-consent-dialog.scss +++ b/projects/storefrontstyles/scss/components/myaccount/anonymous-consent/_anonymous-consent-dialog.scss @@ -40,6 +40,16 @@ } } + @include forFeature('a11yAnonymousConsentMessageInDialog') { + .cx-dialog-message { + padding: 1.5rem 1.75rem 0; + + .cx-message { + margin: 0; + } + } + } + .cx-action-link { margin: 0 0.35rem; diff --git a/projects/storefrontstyles/scss/components/myaccount/anonymous-consent/_anonymous-consent-management-banner.scss b/projects/storefrontstyles/scss/components/myaccount/anonymous-consent/_anonymous-consent-management-banner.scss index 52dc1cca21c..747c614fdd1 100644 --- a/projects/storefrontstyles/scss/components/myaccount/anonymous-consent/_anonymous-consent-management-banner.scss +++ b/projects/storefrontstyles/scss/components/myaccount/anonymous-consent/_anonymous-consent-management-banner.scss @@ -50,4 +50,10 @@ background-color: var(--cx-color-background); } } + + @include forFeature('a11yScrollToTopPositioning') { + &:has(.anonymous-consent-banner) ~ cx-scroll-to-top { + bottom: 180px; + } + } } diff --git a/projects/storefrontstyles/scss/components/myaccount/anonymous-consent/_anonymous-consent-open-dialog.scss b/projects/storefrontstyles/scss/components/myaccount/anonymous-consent/_anonymous-consent-open-dialog.scss index 536a752051f..c7bdec2fa9a 100644 --- a/projects/storefrontstyles/scss/components/myaccount/anonymous-consent/_anonymous-consent-open-dialog.scss +++ b/projects/storefrontstyles/scss/components/myaccount/anonymous-consent/_anonymous-consent-open-dialog.scss @@ -3,10 +3,17 @@ justify-content: center; margin: 0 3vw 3vw 3vw; + @include forFeature('a11yHideConsentButtonWhenBannerVisible') { + margin: 0; + } + @include media-breakpoint-down(sm) { justify-content: flex-start; } .btn-link { + @include forFeature('a11yHideConsentButtonWhenBannerVisible') { + margin: 1.5vw 3vw; + } padding: 0; color: var(--cx-color-inverse); font-size: 0.875rem; diff --git a/projects/storefrontstyles/scss/components/product/_star-rating.scss b/projects/storefrontstyles/scss/components/product/_star-rating.scss index 28638f69f02..8db7d2975be 100644 --- a/projects/storefrontstyles/scss/components/product/_star-rating.scss +++ b/projects/storefrontstyles/scss/components/product/_star-rating.scss @@ -24,7 +24,7 @@ var(--cx-color-primary) 0%, var(--cx-color-primary) calc((var(--star-fill, 0) - #{$i} + 1) * 100%), - var(--cx-color-medium) calc((var(--star-fill, 0) - #{$i} + 1) * 100%) + var(--cx-color-dark) calc((var(--star-fill, 0) - #{$i} + 1) * 100%) ); // somehow we cannot move the text related clip and fill color outside this loop. // most likely they cannot come before the definition of the background. diff --git a/projects/storefrontstyles/scss/components/product/list/_facet-list.scss b/projects/storefrontstyles/scss/components/product/list/_facet-list.scss index e84ab9ed833..ba518fe646e 100644 --- a/projects/storefrontstyles/scss/components/product/list/_facet-list.scss +++ b/projects/storefrontstyles/scss/components/product/list/_facet-list.scss @@ -163,4 +163,10 @@ body.modal-open { height: 95%; } } + + @include cx-highContrastTheme-dark { + .inner cx-tab .tab-btn { + color: var(--cx-color-text); + } + } } diff --git a/projects/storefrontstyles/scss/components/product/list/_facet.scss b/projects/storefrontstyles/scss/components/product/list/_facet.scss index 477df36bc48..20a0a9b6810 100644 --- a/projects/storefrontstyles/scss/components/product/list/_facet.scss +++ b/projects/storefrontstyles/scss/components/product/list/_facet.scss @@ -181,6 +181,11 @@ $intialExpandedFacetsLarge: 3 !default; background-color: var(--cx-color-primary); border-color: var(--cx-color-primary); color: var(--cx-color-inverse); + + @include cx-highContrastTheme-dark { + background-color: transparent; + border-color: var(--cx-color-dark); + } } } diff --git a/projects/storefrontstyles/scss/components/product/search/_searchbox.scss b/projects/storefrontstyles/scss/components/product/search/_searchbox.scss index 9e8d96fe39a..68b30665492 100644 --- a/projects/storefrontstyles/scss/components/product/search/_searchbox.scss +++ b/projects/storefrontstyles/scss/components/product/search/_searchbox.scss @@ -279,6 +279,12 @@ padding-bottom: 6px; padding-inline-start: 10px; + @include forFeature('a11yKeyboardFocusInSearchBox') { + &:focus-within { + @include visible-focus(); + } + } + @include media-breakpoint-up(md) { border: 1px solid var(--cx-color-medium); width: 27vw; @@ -295,6 +301,13 @@ background-color: var(--cx-color-inverse); z-index: 20; padding-top: 25px; + + @include forFeature('a11yKeyboardFocusInSearchBox') { + &:focus-within { + padding: 27px 10px 8px; + outline: 0; + } + } } } @@ -310,6 +323,12 @@ height: 48px; border: 1px solid var(--cx-color-medium); border-radius: 4px; + + @include forFeature('a11yKeyboardFocusInSearchBox') { + &:focus { + @include visible-focus(); + } + } } flex-basis: 100%; diff --git a/projects/storefrontstyles/scss/cxbase/blocks/forms.scss b/projects/storefrontstyles/scss/cxbase/blocks/forms.scss index e74ee9f3d7c..d8d0512a6ae 100644 --- a/projects/storefrontstyles/scss/cxbase/blocks/forms.scss +++ b/projects/storefrontstyles/scss/cxbase/blocks/forms.scss @@ -510,8 +510,10 @@ form { } input[cxpasswordvisibilityswitch] { - @include forFeature('a11yPasswordVisibilityBtnValueOverflow') { - $password-visibility-btn-width: 30px; - padding-inline-end: calc($password-visibility-btn-width + $input-padding-x); + @include forFeature('a11yPasswordVisibliltyBtnValueOverflow') { + $password-visibbility-btn-width: 30px; + padding-inline-end: calc( + $password-visibbility-btn-width + $input-padding-x + ); } } diff --git a/tools/config/const.ts b/tools/config/const.ts index 0dd3e174e20..fb6204788a3 100644 --- a/tools/config/const.ts +++ b/tools/config/const.ts @@ -10,4 +10,4 @@ export const SPARTACUS_SCOPE = '@spartacus'; export const SAP_SCOPE = 'sap'; export const SAPUI5_TYPES = '@sapui5/ts-types-esm'; export const SPARTACUS_SCHEMATICS = `${SPARTACUS_SCOPE}/schematics`; -export const PUBLISHING_VERSION = '2211.32.0-1'; +export const PUBLISHING_VERSION = '2211.32.0';