Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions core-libs/assets/src/translations/en/user.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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$;
}
Expand Down
19 changes: 19 additions & 0 deletions core-libs/core/src/state/event/cookie-consent-changed.event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2026 SAP Spartacus team <spartacus-team@sap.com>
*
* 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;
}
1 change: 1 addition & 0 deletions core-libs/core/src/state/event/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
*/

export * from './action-to-event-mapping';
export * from './cookie-consent-changed.event';
export * from './state-event.service';
1 change: 1 addition & 0 deletions core-libs/core/src/state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
65 changes: 65 additions & 0 deletions core-libs/core/src/state/services/cookie-consent.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* SPDX-FileCopyrightText: 2026 SAP Spartacus team <spartacus-team@sap.com>
*
* 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<boolean>(this.readPersistedConsent());

isOptionalCookiesAccepted(): Observable<boolean> {
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;
}
}
}
32 changes: 28 additions & 4 deletions core-libs/core/src/state/services/state-persistence.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,26 @@
* 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 {
getStorage,
persistToStorage,
readFromStorage,
} from '../utils/browser-storage';
import { CookieConsentService } from './cookie-consent.service';

@Injectable({
providedIn: 'root',
})
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.
Expand Down Expand Up @@ -49,12 +52,15 @@ export class StatePersistenceService {
onRead = () => {
// Intentional empty arrow function
},
storageCategory,
}: {
key: string;
state$: Observable<T>;
context$?: Observable<string | Array<string>>;
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);

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
<button class="btn btn-secondary" (click)="viewDetails()">
{{ 'anonymousConsents.banner.consentManagement' | cxTranslate }}
</button>
<button class="btn btn-secondary" (click)="rejectOptionalStorage()">
{{ 'anonymousConsents.banner.rejectOptionalStorage' | cxTranslate }}
</button>
<button class="btn btn-primary" (click)="allowAll()">
{{ 'anonymousConsents.banner.allowAll' | cxTranslate }}
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,6 +29,7 @@ export class AnonymousConsentManagementBannerComponent implements OnDestroy {

constructor(
protected anonymousConsentsService: AnonymousConsentsService,
protected storageConsentService: CookieConsentService,
protected vcr: ViewContainerRef,
protected launchDialogService: LaunchDialogService
) {}
Expand All @@ -42,6 +47,7 @@ export class AnonymousConsentManagementBannerComponent implements OnDestroy {
}

allowAll(): void {
this.storageConsentService.acceptOptionalCookies();
this.subscriptions.add(
this.anonymousConsentsService
.giveAllConsents()
Expand All @@ -50,6 +56,11 @@ export class AnonymousConsentManagementBannerComponent implements OnDestroy {
);
}

rejectOptionalStorage(): void {
this.storageConsentService.rejectOptionalCookies();
this.hideBanner();
}

hideBanner(): void {
this.anonymousConsentsService.toggleBannerDismissed(true);
}
Expand Down
Loading