diff --git a/core-libs/assets/src/translations/en/user.json b/core-libs/assets/src/translations/en/user.json index b430c32e8a6..663f2fe4abf 100644 --- a/core-libs/assets/src/translations/en/user.json +++ b/core-libs/assets/src/translations/en/user.json @@ -10,6 +10,7 @@ "title": "This website uses cookies", "description": "We use cookies/browser's storage to personalize the content and improve user experience.", "allowAll": "Allow All", + "rejectOptionalStorage": "Reject Optional Storage", "viewDetails": "View Details", "consentManagement": "Consent Management" } diff --git a/core-libs/core/src/site-theme/services/site-theme-persistence.service.ts b/core-libs/core/src/site-theme/services/site-theme-persistence.service.ts index d809b02e635..012f0c0668f 100644 --- a/core-libs/core/src/site-theme/services/site-theme-persistence.service.ts +++ b/core-libs/core/src/site-theme/services/site-theme-persistence.service.ts @@ -28,6 +28,7 @@ export class SiteThemePersistenceService { key: SITE_THEME_ID, state$: this.siteThemeService.getActive(), onRead: (state) => this.onRead(state), + storageCategory: 'optional', }); return this.initialized$; } diff --git a/core-libs/core/src/state/event/cookie-consent-changed.event.ts b/core-libs/core/src/state/event/cookie-consent-changed.event.ts new file mode 100644 index 00000000000..b4c0389a929 --- /dev/null +++ b/core-libs/core/src/state/event/cookie-consent-changed.event.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CxEvent } from '../../event/cx-event'; + +/** + * Fired when the user accepts or rejects optional browser storage (cookies, + * localStorage, sessionStorage). Customizations can subscribe via + * EventService.get(CookieConsentChangedEvent) to clear their own optional + * storage entries. + */ +export class CookieConsentChangedEvent extends CxEvent { + static readonly type = 'CookieConsentChangedEvent'; + /** true = user accepted optional cookies/storage; false = user rejected */ + accepted: boolean; +} diff --git a/core-libs/core/src/state/event/index.ts b/core-libs/core/src/state/event/index.ts index 76044da39cc..dbfbfb8d048 100644 --- a/core-libs/core/src/state/event/index.ts +++ b/core-libs/core/src/state/event/index.ts @@ -5,4 +5,5 @@ */ export * from './action-to-event-mapping'; +export * from './cookie-consent-changed.event'; export * from './state-event.service'; diff --git a/core-libs/core/src/state/index.ts b/core-libs/core/src/state/index.ts index 8d63f4ae743..2f89d50120f 100644 --- a/core-libs/core/src/state/index.ts +++ b/core-libs/core/src/state/index.ts @@ -6,6 +6,7 @@ export * from './config/state-config'; export * from './event/index'; +export * from './services/cookie-consent.service'; export * from './services/state-persistence.service'; export * from './state.module'; export * from './utils/index'; diff --git a/core-libs/core/src/state/services/cookie-consent.service.ts b/core-libs/core/src/state/services/cookie-consent.service.ts new file mode 100644 index 00000000000..694269f9a6d --- /dev/null +++ b/core-libs/core/src/state/services/cookie-consent.service.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP Spartacus team + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { inject, Injectable } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { distinctUntilChanged } from 'rxjs/operators'; +import { EventService } from '../../event/event.service'; +import { WindowRef } from '../../window/window-ref'; +import { CookieConsentChangedEvent } from '../event/cookie-consent-changed.event'; + +/** + * Manages the user's optional cookie/browser-storage consent. + * + * When the user rejects optional cookies, a CookieConsentChangedEvent is + * dispatched so that state-persistence features and customizations can stop + * writing to optional storage keys. + * + * The consent decision is persisted under a dedicated non-rejectable key + * (`spartacus⚿cookieConsent`) so it survives page refreshes. + * Default is accepted (true) for backward compatibility. + */ +@Injectable({ providedIn: 'root' }) +export class CookieConsentService { + protected winRef = inject(WindowRef); + protected eventService = inject(EventService); + + private readonly CONSENT_KEY = 'spartacus⚿cookieConsent'; + + private accepted$ = new BehaviorSubject(this.readPersistedConsent()); + + isOptionalCookiesAccepted(): Observable { + return this.accepted$.asObservable().pipe(distinctUntilChanged()); + } + + rejectOptionalCookies(): void { + this.setConsent(false); + } + + acceptOptionalCookies(): void { + this.setConsent(true); + } + + private setConsent(accepted: boolean): void { + this.winRef.localStorage?.setItem( + this.CONSENT_KEY, + JSON.stringify({ accepted }) + ); + this.accepted$.next(accepted); + const event = new CookieConsentChangedEvent(); + event.accepted = accepted; + this.eventService.dispatch(event); + } + + private readPersistedConsent(): boolean { + try { + const raw = this.winRef.localStorage?.getItem(this.CONSENT_KEY); + return raw ? (JSON.parse(raw).accepted ?? true) : true; + } catch { + return true; + } + } +} diff --git a/core-libs/core/src/state/services/state-persistence.service.ts b/core-libs/core/src/state/services/state-persistence.service.ts index dee99360f86..82a85472724 100644 --- a/core-libs/core/src/state/services/state-persistence.service.ts +++ b/core-libs/core/src/state/services/state-persistence.service.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Injectable } from '@angular/core'; -import { Observable, of, Subscription } from 'rxjs'; -import { map, tap, withLatestFrom } from 'rxjs/operators'; +import { inject, Injectable } from '@angular/core'; +import { EMPTY, Observable, of, Subscription } from 'rxjs'; +import { map, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; import { StorageSyncType } from '../../state/config/state-config'; import { WindowRef } from '../../window/window-ref'; import { @@ -14,6 +14,7 @@ import { persistToStorage, readFromStorage, } from '../utils/browser-storage'; +import { CookieConsentService } from './cookie-consent.service'; @Injectable({ providedIn: 'root', @@ -21,6 +22,8 @@ import { export class StatePersistenceService { constructor(protected winRef: WindowRef) {} + private storageConsentService = inject(CookieConsentService); + /** * Helper to synchronize state to more persistent storage (localStorage, sessionStorage). * It is context aware, so you can keep different state for te same feature based on specified context. @@ -49,12 +52,15 @@ export class StatePersistenceService { onRead = () => { // Intentional empty arrow function }, + storageCategory, }: { key: string; state$: Observable; context$?: Observable>; storageType?: StorageSyncType; onRead?: (stateFromStorage: T | undefined) => void; + /** Mark as 'optional' to gate writes behind CookieConsentService. */ + storageCategory?: 'required' | 'optional'; }): Subscription { const storage = getStorage(storageType, this.winRef); @@ -78,8 +84,26 @@ export class StatePersistenceService { ); if (storage) { + const write$ = + storageCategory === 'optional' + ? this.storageConsentService.isOptionalCookiesAccepted().pipe( + switchMap((accepted) => { + if (!accepted) { + return context$.pipe( + take(1), + tap((ctx) => + storage.removeItem(this.generateKeyWithContext(ctx, key)) + ), + switchMap(() => EMPTY) + ); + } + return state$.pipe(withLatestFrom(context$)); + }) + ) + : state$.pipe(withLatestFrom(context$)); + subscriptions.add( - state$.pipe(withLatestFrom(context$)).subscribe(([state, context]) => { + write$.subscribe(([state, context]) => { persistToStorage( this.generateKeyWithContext(context, key), state, diff --git a/core-libs/storefront/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.html b/core-libs/storefront/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.html index 2aea8e35acc..0b4698fac85 100644 --- a/core-libs/storefront/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.html +++ b/core-libs/storefront/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.html @@ -18,6 +18,9 @@ + diff --git a/core-libs/storefront/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.ts b/core-libs/storefront/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.ts index b1ef8496bde..635472bf844 100644 --- a/core-libs/storefront/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.ts +++ b/core-libs/storefront/cms-components/anonymous-consent-management/banner/anonymous-consent-management-banner.component.ts @@ -6,7 +6,11 @@ import { AsyncPipe, NgClass, NgIf } from '@angular/common'; import { Component, OnDestroy, ViewContainerRef } from '@angular/core'; -import { AnonymousConsentsService, TranslatePipe } from '@spartacus/core'; +import { + AnonymousConsentsService, + CookieConsentService, + TranslatePipe, +} from '@spartacus/core'; import { Observable, Subscription } from 'rxjs'; import { tap } from 'rxjs/operators'; import { LAUNCH_CALLER } from '../../../layout/launch-dialog/config/launch-config'; @@ -25,6 +29,7 @@ export class AnonymousConsentManagementBannerComponent implements OnDestroy { constructor( protected anonymousConsentsService: AnonymousConsentsService, + protected storageConsentService: CookieConsentService, protected vcr: ViewContainerRef, protected launchDialogService: LaunchDialogService ) {} @@ -42,6 +47,7 @@ export class AnonymousConsentManagementBannerComponent implements OnDestroy { } allowAll(): void { + this.storageConsentService.acceptOptionalCookies(); this.subscriptions.add( this.anonymousConsentsService .giveAllConsents() @@ -50,6 +56,11 @@ export class AnonymousConsentManagementBannerComponent implements OnDestroy { ); } + rejectOptionalStorage(): void { + this.storageConsentService.rejectOptionalCookies(); + this.hideBanner(); + } + hideBanner(): void { this.anonymousConsentsService.toggleBannerDismissed(true); }