diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md index a6139451dc..b7a109ac99 100644 --- a/docs/concepts/configuration.md +++ b/docs/concepts/configuration.md @@ -159,6 +159,7 @@ Of course, the ICM server must supply appropriate REST resources to leverage fun | productNotifications | Product notifications feature for price and in stock notifications | | rating | Display product ratings | | recently | Display recently viewed products (additional configuration via `dataRetention` configuration options) | +| saveLanguageSelection | Save the user's language selection and restore it after PWA load | | storeLocator | Display physical stores and their addresses | | **B2B Features** | | | businessCustomerRegistration | Create business customers on registration | diff --git a/docs/guides/cookie-consent.md b/docs/guides/cookie-consent.md index 1405129e81..d5e237fb85 100644 --- a/docs/guides/cookie-consent.md +++ b/docs/guides/cookie-consent.md @@ -33,7 +33,7 @@ cookieConsentOptions: { description: 'cookie.consent.option.tracking.description', }, }, - allowedCookies: ['cookieConsent', 'apiToken'], + allowedCookies: ['apiToken', 'cookieConsent', 'preferredLocale'], }, ``` @@ -42,7 +42,7 @@ The `options` array configures the presented options in the _Cookie Preferences_ - The option `id` is the value that will be stored in the user's `cookieConsent` settings. - The `name` makes up the checkbox label name, usually given as localization key. - The `description` contains the additional option description, usually given as localization key. -- With the `required` flag, an option can be marked as not deselectable. +- With the `required` flag, an option can be marked as not non-selectable. In this way, the user can be informed that necessary cookies are always set without explicit consent of the user. The following screenshot is the rendered representation of the default cookie consent options configuration: @@ -100,10 +100,11 @@ This route can be linked to from anywhere within the application. ## PWA Required Cookies -| Name | Expiration | Provider | Description | Category | -| ------------- | ---------- | ------------- | ----------------------------------------------------------------- | ----------- | -| apiToken | 1 year | Intershop PWA | The API token used by the Intershop Commerce Management REST API. | First Party | -| cookieConsent | 1 year | Intershop PWA | Saves the user's cookie consent settings. | First Party | +| Name | Expiration | Provider | Description | Category | +| --------------- | ---------- | ------------- | ----------------------------------------------------------------- | ----------- | +| apiToken | 1 year | Intershop PWA | The API token used by the Intershop Commerce Management REST API. | First Party | +| cookieConsent | 1 year | Intershop PWA | Saves the user's cookie consent settings. | First Party | +| preferredLocale | 1 year | Intershop PWA | Saves the user's language selection. | First Party | ## Disabling the Integrated Cookie Consent Handling diff --git a/docs/guides/migrations.md b/docs/guides/migrations.md index 137120d1c5..dfa368997e 100644 --- a/docs/guides/migrations.md +++ b/docs/guides/migrations.md @@ -20,6 +20,9 @@ Be aware that some browsers no longer accept cookies with `SameSite=None` withou Before by default no `SameSite` was set so browsers treated it as `SameSite=Lax`, this needs to be set explicitly now if it is really intended. For migrating check the calls of the `cookies.service` `put` method whether they need to be adapted. +The user's language selection is saved as a cookie (`preferredLocale`) now and restored after the PWA is loaded. +This functionality can be enabled/disabled with the feature toggle `saveLanguageSelection`. + ## 4.0 to 4.1 The Intershop PWA now uses Node.js 18.16.0 LTS with the corresponding npm version 9.5.1 to resolve an issue with Azure Docker deployments (see #1416). diff --git a/e2e/cypress/e2e/specs/system/change-language-authentication.b2c.e2e-spec.ts b/e2e/cypress/e2e/specs/system/change-language-authentication.b2c.e2e-spec.ts index 06f949df04..6d3393a0c1 100644 --- a/e2e/cypress/e2e/specs/system/change-language-authentication.b2c.e2e-spec.ts +++ b/e2e/cypress/e2e/specs/system/change-language-authentication.b2c.e2e-spec.ts @@ -19,7 +19,10 @@ const _ = { describe('Language Changing User', () => { describe('when logged in', () => { - before(() => LoginPage.navigateTo()); + before(() => { + cy.clearCookie('preferredLocale'); + LoginPage.navigateTo(); + }); it('should log in', () => { createUserViaREST(_.user); @@ -47,6 +50,7 @@ describe('Language Changing User', () => { describe('when accessing protected content without cookie', () => { before(() => { cy.clearCookie('apiToken'); + cy.clearCookie('preferredLocale'); MyAccountPage.navigateTo(); }); diff --git a/src/app/core/pipes/make-href.pipe.spec.ts b/src/app/core/pipes/make-href.pipe.spec.ts index c2f98e55ec..782c098bbd 100644 --- a/src/app/core/pipes/make-href.pipe.spec.ts +++ b/src/app/core/pipes/make-href.pipe.spec.ts @@ -17,38 +17,42 @@ describe('Make Href Pipe', () => { providers: [{ provide: MultiSiteService, useFactory: () => instance(multiSiteService) }, MakeHrefPipe], }); makeHrefPipe = TestBed.inject(MakeHrefPipe); - when(multiSiteService.getLangUpdatedUrl(anything(), anything(), anything())).thenCall( - (url: string, _: LocationStrategy) => of(url) + + when(multiSiteService.getLangUpdatedUrl(anything(), anything())).thenCall((url: string, _: LocationStrategy) => + of(url) + ); + when(multiSiteService.appendUrlParams(anything(), anything(), anything())).thenCall( + (url: string, _, __: string) => url ); }); it('should be created', () => { expect(makeHrefPipe).toBeTruthy(); }); - // workaround for https://github.com/DefinitelyTyped/DefinitelyTyped/issues/34617 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - it.each([ - [undefined, undefined, 'undefined'], - ['/test', undefined, '/test'], - ['/test', {}, '/test'], - ['/test', { foo: 'bar' }, '/test;foo=bar'], - ['/test', { foo: 'bar', marco: 'polo' }, '/test;foo=bar;marco=polo'], - ['/test?query=q', undefined, '/test?query=q'], - ['/test?query=q', {}, '/test?query=q'], - ['/test?query=q', { foo: 'bar' }, '/test;foo=bar?query=q'], - ['/test?query=q', { foo: 'bar', marco: 'polo' }, '/test;foo=bar;marco=polo?query=q'], - ])(`should transform "%s" with %j to "%s"`, (url, params, expected, done: jest.DoneCallback) => { - makeHrefPipe.transform({ path: () => url, getBaseHref: () => '/' } as LocationStrategy, params).subscribe(res => { - expect(res).toEqual(expected); - done(); - }); + + it('should call appendUrlParams from the multiSiteService if no parameter exists', done => { + makeHrefPipe + .transform({ path: () => '/de/test', getBaseHref: () => '/de' } as LocationStrategy, undefined) + .subscribe(() => { + verify(multiSiteService.appendUrlParams(anything(), anything(), anything())).once(); + done(); + }); }); - it('should call the multiSiteService if lang parameter exists', done => { + it('should call getLangUpdatedUrl from the multiSiteService if lang parameter exists', done => { makeHrefPipe .transform({ path: () => '/de/test', getBaseHref: () => '/de' } as LocationStrategy, { lang: 'en_US' }) .subscribe(() => { - verify(multiSiteService.getLangUpdatedUrl(anything(), anything(), anything())).once(); + verify(multiSiteService.getLangUpdatedUrl(anything(), anything())).once(); + done(); + }); + }); + + it('should call appendUrlParams from the multiSiteService if other parameter exists', done => { + makeHrefPipe + .transform({ path: () => '/de/test', getBaseHref: () => '/de' } as LocationStrategy, { foo: 'bar' }) + .subscribe(() => { + verify(multiSiteService.appendUrlParams(anything(), anything(), anything())).once(); done(); }); }); diff --git a/src/app/core/pipes/make-href.pipe.ts b/src/app/core/pipes/make-href.pipe.ts index 689892d36e..cc6d967583 100644 --- a/src/app/core/pipes/make-href.pipe.ts +++ b/src/app/core/pipes/make-href.pipe.ts @@ -21,29 +21,19 @@ export class MakeHrefPipe implements PipeTransform { if (urlParams) { if (urlParams.lang) { - return this.multiSiteService.getLangUpdatedUrl(urlParams.lang, newUrl, location.getBaseHref()).pipe( + return this.multiSiteService.getLangUpdatedUrl(urlParams.lang, newUrl).pipe( map(modifiedUrl => { const modifiedUrlParams = modifiedUrl === newUrl ? urlParams : omit(urlParams, 'lang'); - return appendUrlParams(modifiedUrl, modifiedUrlParams, split?.[1]); + return this.multiSiteService.appendUrlParams(modifiedUrl, modifiedUrlParams, split?.[1]); }) ); } else { - return of(newUrl).pipe(map(url => appendUrlParams(url, urlParams, split?.[1]))); + return of(newUrl).pipe(map(url => this.multiSiteService.appendUrlParams(url, urlParams, split?.[1]))); } } else { - return of(appendUrlParams(newUrl, undefined, split?.[1])); + return of(this.multiSiteService.appendUrlParams(newUrl, undefined, split?.[1])); } }) ); } } - -function appendUrlParams(url: string, urlParams: Record, queryParams: string | undefined): string { - return `${url}${ - urlParams - ? Object.keys(urlParams) - .map(k => `;${k}=${urlParams[k]}`) - .join('') - : '' - }${queryParams ? `?${queryParams}` : ''}`; -} diff --git a/src/app/core/store/core/server-config/server-config.effects.spec.ts b/src/app/core/store/core/server-config/server-config.effects.spec.ts index ff2dd4ba99..75bf2bbac5 100644 --- a/src/app/core/store/core/server-config/server-config.effects.spec.ts +++ b/src/app/core/store/core/server-config/server-config.effects.spec.ts @@ -1,18 +1,22 @@ -import { TestBed } from '@angular/core/testing'; +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; import { Action } from '@ngrx/store'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; import { cold, hot } from 'jasmine-marbles'; -import { Observable, of, throwError } from 'rxjs'; -import { instance, mock, when } from 'ts-mockito'; +import { Observable, noop, of, throwError } from 'rxjs'; +import { anything, instance, mock, when } from 'ts-mockito'; -import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; +import { FeatureToggleService } from 'ish-core/feature-toggle.module'; +import { ServerConfig } from 'ish-core/models/server-config/server-config.model'; import { ConfigurationService } from 'ish-core/services/configuration/configuration.service'; +import { getAvailableLocales, getCurrentLocale } from 'ish-core/store/core/configuration/configuration.selectors'; import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; import { serverConfigError } from 'ish-core/store/core/error'; +import { CookiesService } from 'ish-core/utils/cookies/cookies.service'; import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; import { StoreWithSnapshots, provideStoreSnapshots } from 'ish-core/utils/dev/ngrx-testing'; import { routerTestNavigationAction } from 'ish-core/utils/dev/routing'; +import { MultiSiteService } from 'ish-core/utils/multi-site/multi-site.service'; import { loadServerConfig, loadServerConfigFail, loadServerConfigSuccess } from './server-config.actions'; import { ServerConfigEffects } from './server-config.effects'; @@ -23,15 +27,20 @@ describe('Server Config Effects', () => { beforeEach(() => { const configurationServiceMock = mock(ConfigurationService); + const cookiesServiceMock = mock(CookiesService); + const multiSiteServiceMock = mock(MultiSiteService); + when(configurationServiceMock.getServerConfiguration()).thenReturn(of({})); + when(cookiesServiceMock.get(anything())).thenReturn('de_DE'); + when(multiSiteServiceMock.getLangUpdatedUrl(anything(), anything())).thenReturn(of('/home;lang=de_DE')); + when(multiSiteServiceMock.appendUrlParams(anything(), anything(), anything())).thenReturn('/home;lang=de_DE'); TestBed.configureTestingModule({ - imports: [ - CoreStoreModule.forTesting(['serverConfig'], [ServerConfigEffects]), - FeatureToggleModule.forTesting('extraConfiguration'), - ], + imports: [CoreStoreModule.forTesting(['serverConfig', 'configuration'], [ServerConfigEffects])], providers: [ { provide: ConfigurationService, useFactory: () => instance(configurationServiceMock) }, + { provide: CookiesService, useFactory: () => instance(cookiesServiceMock) }, + { provide: MultiSiteService, useFactory: () => instance(multiSiteServiceMock) }, provideStoreSnapshots(), ], }); @@ -44,6 +53,10 @@ describe('Server Config Effects', () => { }); it('should trigger the loading of config data on the first page', () => { + Object.defineProperty(window, 'location', { + value: { assign: jest.fn() }, + writable: true, + }); store$.dispatch(routerTestNavigationAction({ routerState: { url: '/any' } })); expect(store$.actionsArray()).toMatchInlineSnapshot(` @@ -60,13 +73,22 @@ describe('Server Config Effects', () => { let effects: ServerConfigEffects; let store$: MockStore; let configurationServiceMock: ConfigurationService; + let cookiesServiceMock: CookiesService; + let multiSiteServiceMock: MultiSiteService; + let featureToggleServiceMock: FeatureToggleService; beforeEach(() => { configurationServiceMock = mock(ConfigurationService); + cookiesServiceMock = mock(CookiesService); + multiSiteServiceMock = mock(MultiSiteService); + featureToggleServiceMock = mock(FeatureToggleService); TestBed.configureTestingModule({ providers: [ { provide: ConfigurationService, useFactory: () => instance(configurationServiceMock) }, + { provide: CookiesService, useFactory: () => instance(cookiesServiceMock) }, + { provide: FeatureToggleService, useFactory: () => instance(featureToggleServiceMock) }, + { provide: MultiSiteService, useFactory: () => instance(multiSiteServiceMock) }, provideMockActions(() => actions$), provideMockStore(), ServerConfigEffects, @@ -104,7 +126,7 @@ describe('Server Config Effects', () => { when(configurationServiceMock.getServerConfiguration()).thenReturn(of({})); }); - it('should map to action of type ApplyConfiguration', () => { + it('should map to action of type LoadServerConfigSuccess', () => { const action = loadServerConfig(); const completion = loadServerConfigSuccess({ config: {} }); @@ -135,5 +157,63 @@ describe('Server Config Effects', () => { expect(effects.mapToServerConfigError$).toBeObservable(expected$); }); + + describe('restore language after loadServerConfigSuccess$', () => { + const action = loadServerConfigSuccess({ config: {} as ServerConfig }); + beforeEach(() => { + when(multiSiteServiceMock.getLangUpdatedUrl(anything(), anything())).thenReturn(of('/home;lang=de_DE')); + when(multiSiteServiceMock.appendUrlParams(anything(), anything(), anything())).thenReturn('/home;lang=de_DE'); + when(featureToggleServiceMock.enabled(anything())).thenReturn(true); + store$.overrideSelector(getAvailableLocales, ['en_US', 'de_DE', 'fr_FR']); + store$.overrideSelector(getCurrentLocale, 'en_US'); + + // mock location.assign() with jest.fn() + Object.defineProperty(window, 'location', { + value: { assign: jest.fn() }, + writable: true, + }); + }); + + it("should reload the current page if the user's locale cookie differs from the current locale", fakeAsync(() => { + when(cookiesServiceMock.get(anything())).thenReturn('de_DE'); + + actions$ = of(action); + effects.switchToPreferredLanguage$.subscribe({ next: noop, error: fail, complete: noop }); + + tick(500); + expect(window.location.assign).toHaveBeenCalled(); + })); + + it("should not reload the current page if the user's locale cookie is equal to the current locale", fakeAsync(() => { + when(cookiesServiceMock.get(anything())).thenReturn('en_US'); + + actions$ = of(action); + effects.switchToPreferredLanguage$.subscribe({ next: noop, error: fail, complete: noop }); + + tick(500); + expect(window.location.assign).not.toHaveBeenCalled(); + })); + + it('should not reload the current page if the feature toggle `saveLanguageSelection` is off', fakeAsync(() => { + when(cookiesServiceMock.get(anything())).thenReturn('de_DE'); + when(featureToggleServiceMock.enabled(anything())).thenReturn(false); + + actions$ = of(action); + effects.switchToPreferredLanguage$.subscribe({ next: noop, error: fail, complete: noop }); + + tick(500); + expect(window.location.assign).not.toHaveBeenCalled(); + })); + + it("should not reload the current page if the user's cookie locale is not available", fakeAsync(() => { + when(cookiesServiceMock.get(anything())).thenReturn('it_IT'); + + actions$ = of(action); + effects.switchToPreferredLanguage$.subscribe({ next: noop, error: fail, complete: noop }); + + tick(500); + expect(window.location.assign).not.toHaveBeenCalled(); + })); + }); }); }); diff --git a/src/app/core/store/core/server-config/server-config.effects.ts b/src/app/core/store/core/server-config/server-config.effects.ts index b92098d743..99fd14278d 100644 --- a/src/app/core/store/core/server-config/server-config.effects.ts +++ b/src/app/core/store/core/server-config/server-config.effects.ts @@ -3,15 +3,17 @@ import { Actions, createEffect, ofType } from '@ngrx/effects'; import { routerNavigationAction } from '@ngrx/router-store'; import { Store, select } from '@ngrx/store'; import { EMPTY, identity } from 'rxjs'; -import { concatMap, first, map, switchMap, takeWhile } from 'rxjs/operators'; +import { concatMap, filter, first, map, switchMap, take, takeWhile, withLatestFrom } from 'rxjs/operators'; import { FeatureToggleService } from 'ish-core/feature-toggle.module'; import { ServerConfig } from 'ish-core/models/server-config/server-config.model'; import { ConfigurationService } from 'ish-core/services/configuration/configuration.service'; -import { applyConfiguration } from 'ish-core/store/core/configuration'; +import { applyConfiguration, getAvailableLocales, getCurrentLocale } from 'ish-core/store/core/configuration'; import { ConfigurationState } from 'ish-core/store/core/configuration/configuration.reducer'; import { serverConfigError } from 'ish-core/store/core/error'; import { personalizationStatusDetermined } from 'ish-core/store/customer/user'; +import { CookiesService } from 'ish-core/utils/cookies/cookies.service'; +import { MultiSiteService } from 'ish-core/utils/multi-site/multi-site.service'; import { delayUntil, mapErrorToAction, mapToPayloadProperty, whenFalsy, whenTruthy } from 'ish-core/utils/operators'; import { @@ -29,7 +31,9 @@ export class ServerConfigEffects { private actions$: Actions, private store: Store, private configService: ConfigurationService, - private featureToggleService: FeatureToggleService + private featureToggleService: FeatureToggleService, + private cookiesService: CookiesService, + private multiSiteService: MultiSiteService ) {} /** @@ -57,6 +61,45 @@ export class ServerConfigEffects { ) ); + switchToPreferredLanguage$ = + !SSR && + createEffect( + () => + this.actions$.pipe( + ofType(loadServerConfigSuccess), + take(1), + takeWhile(() => this.featureToggleService.enabled('saveLanguageSelection')), + withLatestFrom(this.store.pipe(select(getAvailableLocales)), this.store.pipe(select(getCurrentLocale))), + map(([, availableLocales, currentLocale]) => ({ + cookieLocale: this.cookiesService.get('preferredLocale'), + availableLocales, + currentLocale, + })), + filter( + ({ cookieLocale, availableLocales, currentLocale }) => + cookieLocale && availableLocales?.includes(cookieLocale) && cookieLocale !== currentLocale + ), + concatMap(({ cookieLocale }) => { + const splittedUrl = location.pathname?.split('?'); + const newUrl = splittedUrl?.[0]; + + return this.multiSiteService.getLangUpdatedUrl(cookieLocale, newUrl).pipe( + whenTruthy(), + take(1), + map(modifiedUrl => { + const modifiedUrlParams = modifiedUrl === newUrl ? { lang: cookieLocale } : {}; + return this.multiSiteService.appendUrlParams(modifiedUrl, modifiedUrlParams, splittedUrl?.[1]); + }), + concatMap(url => { + location.assign(url); + return EMPTY; + }) + ); + }) + ), + { dispatch: false } + ); + loadExtraServerConfig$ = createEffect(() => this.actions$.pipe( ofType(loadServerConfig), diff --git a/src/app/core/utils/multi-site/multi-site.service.spec.ts b/src/app/core/utils/multi-site/multi-site.service.spec.ts index 09e2ee48a6..1cebde40e9 100644 --- a/src/app/core/utils/multi-site/multi-site.service.spec.ts +++ b/src/app/core/utils/multi-site/multi-site.service.spec.ts @@ -1,3 +1,4 @@ +import { APP_BASE_HREF } from '@angular/common'; import { TestBed } from '@angular/core/testing'; import { provideMockStore } from '@ngrx/store/testing'; @@ -16,7 +17,10 @@ describe('Multi Site Service', () => { beforeEach(() => { TestBed.configureTestingModule({ - providers: [provideMockStore({ selectors: [{ selector: getMultiSiteLocaleMap, value: multiSiteLocaleMap }] })], + providers: [ + { provide: APP_BASE_HREF, useValue: '/de' }, + provideMockStore({ selectors: [{ selector: getMultiSiteLocaleMap, value: multiSiteLocaleMap }] }), + ], }); multiSiteService = TestBed.inject(MultiSiteService); }); @@ -25,21 +29,36 @@ describe('Multi Site Service', () => { expect(multiSiteService).toBeTruthy(); }); it('should return url unchanged if no language baseHref exists', done => { - multiSiteService.getLangUpdatedUrl('de_DE', '/testpath', '/').subscribe(url => { + multiSiteService.getLangUpdatedUrl('de_DE', '/testpath').subscribe(url => { expect(url).toMatchInlineSnapshot(`"/testpath"`); done(); }); }); it('should return with new url if language baseHref exists and language is valid', done => { - multiSiteService.getLangUpdatedUrl('en_US', '/de/testpath', '/de').subscribe(url => { + multiSiteService.getLangUpdatedUrl('en_US', '/de/testpath').subscribe(url => { expect(url).toMatchInlineSnapshot(`"/en/testpath"`); done(); }); }); it('should return url unchanged if language baseHref exists but language is invalid', done => { - multiSiteService.getLangUpdatedUrl('xy_XY', '/de/testpath', '/de').subscribe(url => { + multiSiteService.getLangUpdatedUrl('xy_XY', '/de/testpath').subscribe(url => { expect(url).toMatchInlineSnapshot(`"/de/testpath"`); done(); }); }); + + it.each([ + [undefined, undefined, 'undefined'], + ['/test', undefined, '/test'], + ['/test', {}, '/test'], + ['/test', { foo: 'bar' }, '/test;foo=bar'], + ['/test', { foo: 'bar', marco: 'polo' }, '/test;foo=bar;marco=polo'], + ['/test?query=q', undefined, '/test?query=q'], + ['/test?query=q', {}, '/test?query=q'], + ['/test?query=q', { foo: 'bar' }, '/test;foo=bar?query=q'], + ['/test?query=q', { foo: 'bar', marco: 'polo' }, '/test;foo=bar;marco=polo?query=q'], + ])(`should transform "%s" with %j to "%s"`, (url, params, expected) => { + const splittedUrl = url?.split('?'); + expect(multiSiteService.appendUrlParams(splittedUrl?.[0], params, splittedUrl?.[1])).toBe(expected); + }); }); diff --git a/src/app/core/utils/multi-site/multi-site.service.ts b/src/app/core/utils/multi-site/multi-site.service.ts index 73fe67177d..1f6ccff7fe 100644 --- a/src/app/core/utils/multi-site/multi-site.service.ts +++ b/src/app/core/utils/multi-site/multi-site.service.ts @@ -1,4 +1,5 @@ -import { Injectable } from '@angular/core'; +import { APP_BASE_HREF } from '@angular/common'; +import { Inject, Injectable } from '@angular/core'; import { Store, select } from '@ngrx/store'; import { Observable } from 'rxjs'; import { map, take } from 'rxjs/operators'; @@ -9,7 +10,7 @@ export type MultiSiteLocaleMap = Record | undefined; @Injectable({ providedIn: 'root' }) export class MultiSiteService { - constructor(private store: Store) {} + constructor(private store: Store, @Inject(APP_BASE_HREF) private baseHref: string) {} /** * returns the current url, modified to fit the locale parameter if the environment parameter "multiSiteLocaleMap" is set @@ -19,7 +20,7 @@ export class MultiSiteService { * @param baseHref the current baseHref which needs to be replaced * @returns the modified url */ - getLangUpdatedUrl(locale: string, url: string, baseHref: string): Observable { + getLangUpdatedUrl(locale: string, url: string): Observable { return this.store.pipe( select(getMultiSiteLocaleMap), take(1), @@ -33,15 +34,25 @@ export class MultiSiteService { */ if ( multiSiteLocaleMap && - Object.values(multiSiteLocaleMap).includes(baseHref) && + Object.values(multiSiteLocaleMap).includes(this.baseHref) && localeMapHasLangString(locale, multiSiteLocaleMap) ) { - newUrl = newUrl.replace(baseHref, multiSiteLocaleMap[locale]); + newUrl = newUrl.replace(this.baseHref, multiSiteLocaleMap[locale]); } return newUrl; }) ); } + + appendUrlParams(url: string, urlParams: Record, queryParams: string | undefined): string { + return `${url}${ + urlParams + ? Object.keys(urlParams) + .map(k => `;${k}=${urlParams[k]}`) + .join('') + : '' + }${queryParams ? `?${queryParams}` : ''}`; + } } function localeMapHasLangString( diff --git a/src/app/shell/header/language-switch/language-switch.component.html b/src/app/shell/header/language-switch/language-switch.component.html index d2c99eea73..eadcaafe65 100644 --- a/src/app/shell/header/language-switch/language-switch.component.html +++ b/src/app/shell/header/language-switch/language-switch.component.html @@ -25,7 +25,7 @@
  • - + {{ 'locale.' + l + '.long' | translate }}
  • diff --git a/src/app/shell/header/language-switch/language-switch.component.spec.ts b/src/app/shell/header/language-switch/language-switch.component.spec.ts index ccc7ae3cab..282d066ef1 100644 --- a/src/app/shell/header/language-switch/language-switch.component.spec.ts +++ b/src/app/shell/header/language-switch/language-switch.component.spec.ts @@ -8,7 +8,9 @@ import { of } from 'rxjs'; import { instance, mock, when } from 'ts-mockito'; import { AppFacade } from 'ish-core/facades/app.facade'; +import { FeatureToggleService } from 'ish-core/feature-toggle.module'; import { MakeHrefPipe } from 'ish-core/pipes/make-href.pipe'; +import { CookiesService } from 'ish-core/utils/cookies/cookies.service'; import { LanguageSwitchComponent } from './language-switch.component'; @@ -18,6 +20,8 @@ describe('Language Switch Component', () => { let element: HTMLElement; let appFacade: AppFacade; const locales = ['en_US', 'de_DE', 'fr_FR']; + const cookiesServiceMock = mock(CookiesService); + const featureToggleServiceMock = mock(FeatureToggleService); beforeEach(async () => { appFacade = mock(AppFacade); @@ -29,7 +33,11 @@ describe('Language Switch Component', () => { MockPipe(MakeHrefPipe, (_, urlParams) => of(urlParams.lang)), ], imports: [NgbDropdownModule, TranslateModule.forRoot()], - providers: [{ provide: AppFacade, useFactory: () => instance(appFacade) }], + providers: [ + { provide: AppFacade, useFactory: () => instance(appFacade) }, + { provide: CookiesService, useValue: instance(cookiesServiceMock) }, + { provide: FeatureToggleService, useValue: instance(featureToggleServiceMock) }, + ], }).compileComponents(); }); diff --git a/src/app/shell/header/language-switch/language-switch.component.ts b/src/app/shell/header/language-switch/language-switch.component.ts index 654782f655..96e984980d 100644 --- a/src/app/shell/header/language-switch/language-switch.component.ts +++ b/src/app/shell/header/language-switch/language-switch.component.ts @@ -3,6 +3,8 @@ import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core import { Observable } from 'rxjs'; import { AppFacade } from 'ish-core/facades/app.facade'; +import { FeatureToggleService } from 'ish-core/feature-toggle.module'; +import { CookiesService } from 'ish-core/utils/cookies/cookies.service'; @Component({ selector: 'ish-language-switch', @@ -19,10 +21,25 @@ export class LanguageSwitchComponent implements OnInit { locale$: Observable; availableLocales$: Observable; - constructor(private appFacade: AppFacade, public location: LocationStrategy) {} + constructor( + private appFacade: AppFacade, + private cookiesService: CookiesService, + private featureToggleService: FeatureToggleService, + public location: LocationStrategy + ) {} ngOnInit() { this.locale$ = this.appFacade.currentLocale$; this.availableLocales$ = this.appFacade.availableLocales$; } + + setLocaleCookie(locale: string) { + if (this.featureToggleService.enabled('saveLanguageSelection')) { + this.cookiesService.put('preferredLocale', locale, { + expires: new Date(new Date().setFullYear(new Date().getFullYear() + 1)), + path: '/', + }); + return true; + } + } } diff --git a/src/environments/environment.model.ts b/src/environments/environment.model.ts index 559c67bec3..1cb85fd21b 100644 --- a/src/environments/environment.model.ts +++ b/src/environments/environment.model.ts @@ -25,12 +25,13 @@ export interface Environment { /* FEATURE TOGGLES */ features: ( | 'compare' + | 'contactUs' + | 'extraConfiguration' + | 'productNotifications' | 'rating' | 'recently' - | 'productNotifications' + | 'saveLanguageSelection' | 'storeLocator' - | 'contactUs' - | 'extraConfiguration' /* B2B features */ | 'businessCustomerRegistration' | 'costCenters' @@ -149,7 +150,15 @@ export const ENVIRONMENT_DEFAULTS: Omit = { hybridApplication: '-', /* FEATURE TOGGLES */ - features: ['compare', 'contactUs', 'productNotifications', 'rating', 'recently', 'storeLocator'], + features: [ + 'compare', + 'contactUs', + 'productNotifications', + 'rating', + 'recently', + 'saveLanguageSelection', + 'storeLocator', + ], /* PROGRESSIVE WEB APP CONFIGURATIONS */ smallBreakpointWidth: 576, @@ -186,7 +195,7 @@ export const ENVIRONMENT_DEFAULTS: Omit = { description: 'cookie.consent.option.tracking.description', }, }, - allowedCookies: ['cookieConsent', 'apiToken'], + allowedCookies: ['apiToken', 'cookieConsent', 'preferredLocale'], }, cookieConsentVersion: 1,