diff --git a/apps/isa-app/src/page/catalog/article-details/article-details.component.ts b/apps/isa-app/src/page/catalog/article-details/article-details.component.ts
index 9e44352dc..0fe446ffe 100644
--- a/apps/isa-app/src/page/catalog/article-details/article-details.component.ts
+++ b/apps/isa-app/src/page/catalog/article-details/article-details.component.ts
@@ -50,6 +50,12 @@ import {
import { DomainCheckoutService } from '@domain/checkout';
import { Store } from '@ngrx/store';
import moment, { Moment } from 'moment';
+import {
+ NavigateAfterRewardSelection,
+ RewardSelectionPopUpService,
+} from '@isa/checkout/shared/reward-selection-dialog';
+
+import { injectConfirmationDialog, injectFeedbackDialog } from '@isa/ui/dialog';
@Component({
selector: 'page-article-details',
@@ -61,6 +67,7 @@ import moment, { Moment } from 'moment';
standalone: false,
})
export class ArticleDetailsComponent implements OnInit, OnDestroy {
+ private _rewardSelectionPopUpService = inject(RewardSelectionPopUpService);
private _customerSearchNavigation = inject(CustomerSearchNavigation);
private readonly subscriptions = new Subscription();
@@ -534,16 +541,17 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy {
}
async showPurchasingModal(selectedBranch?: BranchDTO) {
+ const processId = this.applicationService.activatedProcessId;
const item = await this.store.item$.pipe(first()).toPromise();
const shoppingCart = await firstValueFrom(
this._domainCheckoutService.getShoppingCart({
- processId: this.applicationService.activatedProcessId,
+ processId,
}),
);
const modalRef = await this._purchaseOptionsModalService.open({
type: 'add',
- tabId: this.applicationService.activatedProcessId,
+ tabId: processId,
shoppingCartId: shoppingCart.id,
items: [item],
pickupBranch: selectedBranch,
@@ -556,11 +564,11 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy {
modalRef.afterClosed$.subscribe(async (result) => {
if (result?.data === 'continue') {
const customer = await this._domainCheckoutService
- .getBuyer({ processId: this.applicationService.activatedProcessId })
+ .getBuyer({ processId })
.pipe(first())
.toPromise();
if (customer) {
- await this.navigateToShoppingCart();
+ await this.#rewardSelectionPopUpFlow(processId);
} else {
await this.navigateToCustomerSearch();
}
@@ -629,4 +637,33 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy {
loadImage() {
this.imageLoaded$.next(true);
}
+
+ async #reloadShoppingCart(processId: number) {
+ await this._domainCheckoutService.reloadShoppingCart({ processId });
+ }
+
+ async #navigateToReward(processId: number) {
+ await this._router.navigate([`/${processId}`, 'reward', 'cart']);
+ }
+
+ async #rewardSelectionPopUpFlow(tabId: number) {
+ await this.#reloadShoppingCart(tabId);
+ const navigate: NavigateAfterRewardSelection =
+ await this._rewardSelectionPopUpService.popUp();
+ await this.#reloadShoppingCart(tabId);
+
+ switch (navigate) {
+ case NavigateAfterRewardSelection.CART:
+ await this.navigateToShoppingCart();
+ break;
+ case NavigateAfterRewardSelection.REWARD:
+ await this.#navigateToReward(tabId);
+ break;
+ case NavigateAfterRewardSelection.CATALOG:
+ await this.navigateToResultList();
+ break;
+ default:
+ break;
+ }
+ }
}
diff --git a/apps/isa-app/src/page/catalog/article-details/article-details.module.ts b/apps/isa-app/src/page/catalog/article-details/article-details.module.ts
index a3888800f..97437eb51 100644
--- a/apps/isa-app/src/page/catalog/article-details/article-details.module.ts
+++ b/apps/isa-app/src/page/catalog/article-details/article-details.module.ts
@@ -15,6 +15,15 @@ import { IconModule } from '@shared/components/icon';
import { ArticleDetailsTextComponent } from './article-details-text/article-details-text.component';
import { IconBadgeComponent } from 'apps/isa-app/src/shared/components/icon/badge/icon-badge.component';
import { MatomoModule } from 'ngx-matomo-client';
+import {
+ SelectedRewardShoppingCartResource,
+ SelectedShoppingCartResource,
+} from '@isa/checkout/data-access';
+import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
+import {
+ RewardSelectionService,
+ RewardSelectionPopUpService,
+} from '@isa/checkout/shared/reward-selection-dialog';
@NgModule({
imports: [
@@ -35,5 +44,12 @@ import { MatomoModule } from 'ngx-matomo-client';
],
exports: [ArticleDetailsComponent, ArticleRecommendationsComponent],
declarations: [ArticleDetailsComponent, ArticleRecommendationsComponent],
+ providers: [
+ SelectedShoppingCartResource,
+ SelectedRewardShoppingCartResource,
+ SelectedCustomerBonusCardsResource,
+ RewardSelectionService,
+ RewardSelectionPopUpService,
+ ],
})
export class ArticleDetailsModule {}
diff --git a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.html b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.html
index 5ddca304d..bca0deef1 100644
--- a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.html
+++ b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.html
@@ -13,8 +13,12 @@
keinen Artikel hinzugefügt.
@@ -24,11 +28,22 @@
-
+
@if (!(isDesktop$ | async)) {
}
- @for (group of groupedItems$ | async; track trackByGroupedItems($index, group); let lastGroup = $last) {
+ @for (
+ group of groupedItems$ | async;
+ track trackByGroupedItems($index, group);
+ let lastGroup = $last
+ ) {
@if (group?.orderType !== undefined) {
@@ -62,20 +88,44 @@
group.orderType === 'Versand' ||
group.orderType === 'B2B-Versand' ||
group.orderType === 'DIG-Versand'
- ) {
-
+ ) {
+
}
}
- @for (item of group.items; track trackByItemId(i, item); let lastItem = $last; let i = $index) {
- @if (group?.orderType !== undefined && (item.features?.orderType === 'Abholung' || item.features?.orderType === 'Rücklage')) {
+ @for (
+ item of group.items;
+ track trackByItemId(i, item);
+ let lastItem = $last;
+ let i = $index
+ ) {
+ @if (
+ group?.orderType !== undefined &&
+ (item.features?.orderType === 'Abholung' ||
+ item.features?.orderType === 'Rücklage')
+ ) {
@if (item?.destination?.data?.targetBranch?.data; as targetBranch) {
- @if (i === 0 || checkIfMultipleDestinationsForOrderTypeExist(targetBranch, group, i)) {
+ @if (
+ i === 0 ||
+ checkIfMultipleDestinationsForOrderTypeExist(
+ targetBranch,
+ group,
+ i
+ )
+ ) {
+ {{ targetBranch?.name }} |
+ {{ targetBranch | branchAddress }}
- {{ targetBranch?.name }} | {{ targetBranch | branchAddress }}
}
@@ -85,7 +135,9 @@
(changeItem)="changeItem($event)"
(changeDummyItem)="changeDummyItem($event)"
(changeQuantity)="updateItemQuantity($event)"
- [quantityError]="(quantityError$ | async)[item.product.catalogProductNumber]"
+ [quantityError]="
+ (quantityError$ | async)[item.product.catalogProductNumber]
+ "
[item]="item"
[orderType]="group?.orderType"
[loadingOnItemChangeById]="loadingOnItemChangeById$ | async"
@@ -109,7 +161,11 @@
}
- Zwischensumme {{ shoppingCart?.total?.value | currency: shoppingCart?.total?.currency : 'code' }}
+ Zwischensumme
+ {{
+ shoppingCart?.total?.value
+ | currency: shoppingCart?.total?.currency : 'code'
+ }}
ohne Versandkosten
@@ -119,11 +175,13 @@
(click)="order()"
[disabled]="
showOrderButtonSpinner ||
- ((primaryCtaLabel$ | async) === 'Bestellen' && !(checkNotificationChannelControl$ | async)) ||
+ ((primaryCtaLabel$ | async) === 'Bestellen' &&
+ !(checkNotificationChannelControl$ | async)) ||
notificationsControl?.invalid ||
- ((primaryCtaLabel$ | async) === 'Bestellen' && ((checkingOla$ | async) || (checkoutIsInValid$ | async)))
+ ((primaryCtaLabel$ | async) === 'Bestellen' &&
+ ((checkingOla$ | async) || (checkoutIsInValid$ | async)))
"
- >
+ >
{{ primaryCtaLabel$ | async }}
@@ -137,4 +195,3 @@
}
-
diff --git a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.scss b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.scss
index 18ef18b11..0a9d7de0d 100644
--- a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.scss
+++ b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.scss
@@ -72,8 +72,12 @@ button {
@apply text-lg;
}
+.header-container {
+ @apply flex flex-col items-center justify-center desktop-large:pb-10 -mt-2;
+}
+
.header {
- @apply text-center text-h2 desktop-large:pb-10 -mt-2;
+ @apply text-center text-h2;
}
hr {
diff --git a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.ts b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.ts
index a5293bb63..a15763063 100644
--- a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.ts
+++ b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.ts
@@ -144,6 +144,10 @@ export class CheckoutReviewComponent
),
);
+ orderTypesExist$ = this.groupedItems$.pipe(
+ map((groups) => groups?.every((group) => group?.orderType !== undefined)),
+ );
+
totalItemCount$ = this._store.shoppingCartItems$.pipe(
takeUntil(this._store.orderCompleted),
map((items) => items.reduce((total, item) => total + item.quantity, 0)),
diff --git a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.module.ts b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.module.ts
index 9346aeb0c..240a6a5e9 100644
--- a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.module.ts
+++ b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.module.ts
@@ -19,7 +19,11 @@ import { CheckoutReviewDetailsComponent } from './details/checkout-review-detail
import { CheckoutReviewStore } from './checkout-review.store';
import { IconModule } from '@shared/components/icon';
import { TextFieldModule } from '@angular/cdk/text-field';
-import { LoaderComponent, SkeletonLoaderComponent } from '@shared/components/loader';
+import {
+ LoaderComponent,
+ SkeletonLoaderComponent,
+} from '@shared/components/loader';
+import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog';
@NgModule({
imports: [
@@ -40,6 +44,7 @@ import { LoaderComponent, SkeletonLoaderComponent } from '@shared/components/loa
TextFieldModule,
LoaderComponent,
SkeletonLoaderComponent,
+ RewardSelectionTriggerComponent,
],
exports: [CheckoutReviewComponent, CheckoutReviewDetailsComponent],
declarations: [
diff --git a/apps/isa-app/src/page/customer/customer-search/customer-search.module.ts b/apps/isa-app/src/page/customer/customer-search/customer-search.module.ts
index a090db2f7..3f0582451 100644
--- a/apps/isa-app/src/page/customer/customer-search/customer-search.module.ts
+++ b/apps/isa-app/src/page/customer/customer-search/customer-search.module.ts
@@ -12,6 +12,15 @@ import { MainSideViewModule } from './main-side-view/main-side-view.module';
import { OrderDetailsSideViewComponent } from './order-details-side-view/order-details-side-view.component';
import { CustomerMainViewComponent } from './main-view/main-view.component';
import { SharedSplitscreenComponent } from '@shared/components/splitscreen';
+import {
+ SelectedShoppingCartResource,
+ SelectedRewardShoppingCartResource,
+} from '@isa/checkout/data-access';
+import {
+ RewardSelectionService,
+ RewardSelectionPopUpService,
+} from '@isa/checkout/shared/reward-selection-dialog';
+import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
@NgModule({
imports: [
@@ -29,5 +38,12 @@ import { SharedSplitscreenComponent } from '@shared/components/splitscreen';
],
exports: [CustomerSearchComponent],
declarations: [CustomerSearchComponent],
+ providers: [
+ SelectedShoppingCartResource,
+ SelectedRewardShoppingCartResource,
+ SelectedCustomerBonusCardsResource,
+ RewardSelectionService,
+ RewardSelectionPopUpService,
+ ],
})
export class CustomerSearchModule {}
diff --git a/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts b/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts
index 1e357e1e5..dac2d6014 100644
--- a/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts
+++ b/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts
@@ -40,6 +40,10 @@ import { injectTab } from '@isa/core/tabs';
import { toSignal } from '@angular/core/rxjs-interop';
import { CrmTabMetadataService, Customer } from '@isa/crm/data-access';
import { CustomerAdapter } from '@isa/checkout/data-access';
+import {
+ NavigateAfterRewardSelection,
+ RewardSelectionPopUpService,
+} from '@isa/checkout/shared/reward-selection-dialog';
import { NavigationStateService } from '@isa/core/navigation';
export interface CustomerDetailsViewMainState {
@@ -75,6 +79,7 @@ export class CustomerDetailsViewMainComponent
customerService = inject(CrmCustomerService);
crmTabMetadataService = inject(CrmTabMetadataService);
+ private _rewardSelectionPopUpService = inject(RewardSelectionPopUpService);
tab = injectTab();
// Signal to track if return URL exists
@@ -97,7 +102,8 @@ export class CustomerDetailsViewMainComponent
}
async checkHasReturnUrl(): Promise {
- const hasContext = await this._navigationState.hasPreservedContext('select-customer');
+ const hasContext =
+ await this._navigationState.hasPreservedContext('select-customer');
this.hasReturnUrlSignal.set(hasContext);
}
@@ -362,10 +368,7 @@ export class CustomerDetailsViewMainComponent
// #5262 Für die Auswahl des Kunden im "Prämienshop-Modus" (Getrennt vom regulären Checkout-Prozess)
async continueReward() {
if (this.isRewardTab()) {
- this.crmTabMetadataService.setSelectedCustomerId(
- this.processId,
- this.customer.id,
- );
+ this._setSelectedCustomerIdInTab();
// Restore from preserved context (auto-scoped to current tab) and clean up
const context = await this._navigationState.restoreAndClearContext<{
@@ -421,6 +424,8 @@ export class CustomerDetailsViewMainComponent
this._setBuyer();
+ this._setSelectedCustomerIdInTab();
+
await this._updateNotifcationChannelsAsync(currentBuyer);
this._setPayer();
@@ -428,17 +433,13 @@ export class CustomerDetailsViewMainComponent
this._setShippingAddress();
if (this.shoppingCartHasItems) {
- // Navigation zum Warenkorb
- const path = this._checkoutNavigation.getCheckoutReviewPath(
- this.processId,
- ).path;
- this._router.navigate(path);
+ await this.#rewardSelectionPopUpFlow(this.processId);
} else {
// Navigation zur Artikelsuche
const path = this._catalogNavigation.getArticleSearchBasePath(
this.processId,
).path;
- this._router.navigate(path);
+ await this._router.navigate(path);
}
this.setIsBusy(false);
@@ -608,6 +609,15 @@ export class CustomerDetailsViewMainComponent
});
}
+ // #5307 Für den Warenkorb und den "Prämien oder Warenkorb" CTA ist es wichtig, dass die customerId im Tab hinterlegt ist
+ @log
+ _setSelectedCustomerIdInTab() {
+ this.crmTabMetadataService.setSelectedCustomerId(
+ this.processId,
+ this.customer.id,
+ );
+ }
+
@logAsync
async _updateNotifcationChannelsAsync(currentBuyer: BuyerDTO | undefined) {
if (currentBuyer?.buyerNumber !== this.customer.customerNumber) {
@@ -642,4 +652,44 @@ export class CustomerDetailsViewMainComponent
});
}
}
+
+ async #rewardSelectionPopUpFlow(tabId: number) {
+ await this.#reloadShoppingCart(tabId);
+ const navigate: NavigateAfterRewardSelection =
+ await this._rewardSelectionPopUpService.popUp();
+ await this.#reloadShoppingCart(tabId);
+
+ switch (navigate) {
+ case NavigateAfterRewardSelection.CART:
+ await this.#navigateToCart(tabId);
+ break;
+ case NavigateAfterRewardSelection.REWARD:
+ await this.#navigateToReward(tabId);
+ break;
+ case NavigateAfterRewardSelection.CATALOG:
+ await this.#navigateToResultList(tabId);
+ break;
+ default:
+ break;
+ }
+ }
+
+ async #reloadShoppingCart(tabId: number) {
+ await this._checkoutService.reloadShoppingCart({ processId: tabId });
+ }
+
+ async #navigateToReward(tabId: number) {
+ await this._router.navigate([`/${tabId}`, 'reward', 'cart']);
+ }
+
+ async #navigateToCart(tabId: number) {
+ const path = this._checkoutNavigation.getCheckoutReviewPath(tabId).path;
+ await this._router.navigate(path);
+ }
+
+ async #navigateToResultList(tabId: number) {
+ const path =
+ this._catalogNavigation.getArticleSearchResultsPath(tabId).path;
+ await this._router.navigate(path);
+ }
}
diff --git a/libs/catalogue/data-access/src/lib/services/catalouge-search.service.spec.ts b/libs/catalogue/data-access/src/lib/services/catalouge-search.service.spec.ts
index 82d9b907d..cd5946c2f 100644
--- a/libs/catalogue/data-access/src/lib/services/catalouge-search.service.spec.ts
+++ b/libs/catalogue/data-access/src/lib/services/catalouge-search.service.spec.ts
@@ -36,7 +36,7 @@ describe('CatalougeSearchService', () => {
});
describe('searchByEans', () => {
- it('should return items when search is successful', (done) => {
+ it('should return items when search is successful', async () => {
// Arrange
const mockItems: Item[] = [
{
@@ -57,21 +57,17 @@ describe('CatalougeSearchService', () => {
searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse));
// Act
- service.searchByEans('123456789', '987654321').subscribe({
- next: (result) => {
- // Assert
- expect(result).toEqual(mockItems);
- expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith([
- '123456789',
- '987654321',
- ]);
- done();
- },
- error: done.fail,
- });
+ const result = await service.searchByEans(['123456789', '987654321']);
+
+ // Assert
+ expect(result).toEqual(mockItems);
+ expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith([
+ '123456789',
+ '987654321',
+ ]);
});
- it('should throw error when response has error', (done) => {
+ it('should return empty array when response has error', async () => {
// Arrange
const mockResponse = {
error: true,
@@ -80,18 +76,14 @@ describe('CatalougeSearchService', () => {
searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse));
// Act
- service.searchByEans('123456789').subscribe({
- next: () => done.fail('Should have thrown error'),
- error: (error) => {
- // Assert
- expect(error).toBeInstanceOf(Error);
- expect(error.message).toBe('Search failed');
- done();
- },
- });
+ const result = await service.searchByEans(['123456789']);
+
+ // Assert
+ expect(result).toEqual([]);
+ expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith(['123456789']);
});
- it('should handle single EAN', (done) => {
+ it('should handle single EAN', async () => {
// Arrange
const mockItems: Item[] = [
{
@@ -107,20 +99,14 @@ describe('CatalougeSearchService', () => {
searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse));
// Act
- service.searchByEans('123456789').subscribe({
- next: (result) => {
- // Assert
- expect(result).toEqual(mockItems);
- expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith([
- '123456789',
- ]);
- done();
- },
- error: done.fail,
- });
+ const result = await service.searchByEans(['123456789']);
+
+ // Assert
+ expect(result).toEqual(mockItems);
+ expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith(['123456789']);
});
- it('should handle empty EAN array', (done) => {
+ it('should handle empty EAN array', async () => {
// Arrange
const mockResponse = {
error: false,
@@ -129,15 +115,11 @@ describe('CatalougeSearchService', () => {
searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse));
// Act
- service.searchByEans().subscribe({
- next: (result) => {
- // Assert
- expect(result).toEqual([]);
- expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith([]);
- done();
- },
- error: done.fail,
- });
+ const result = await service.searchByEans([]);
+
+ // Assert
+ expect(result).toEqual([]);
+ expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith([]);
});
});
diff --git a/libs/catalogue/data-access/src/lib/services/catalouge-search.service.ts b/libs/catalogue/data-access/src/lib/services/catalouge-search.service.ts
index e8c8763e2..86e10e59d 100644
--- a/libs/catalogue/data-access/src/lib/services/catalouge-search.service.ts
+++ b/libs/catalogue/data-access/src/lib/services/catalouge-search.service.ts
@@ -4,7 +4,7 @@ import {
ResponseArgsOfUISettingsDTO,
SearchService,
} from '@generated/swagger/cat-search-api';
-import { firstValueFrom, map, Observable } from 'rxjs';
+import { firstValueFrom, map } from 'rxjs';
import {
catchResponseArgsErrorPipe,
ResponseArgsError,
@@ -26,8 +26,13 @@ export class CatalougeSearchService {
#searchService = inject(SearchService);
#logger = logger(() => ({ service: 'CatalougeSearchService' }));
- searchByEans(...ean: string[]): Observable- {
- return this.#searchService.SearchByEAN(ean).pipe(
+ async searchByEans(
+ ean: string[],
+ abortSignal?: AbortSignal,
+ ): Promise
- {
+ this.#logger.info('Searching items by EANs', () => ({ count: ean.length }));
+
+ let req$ = this.#searchService.SearchByEAN(ean).pipe(
map((res) => {
if (res.error) {
throw new Error(res.message);
@@ -36,6 +41,27 @@ export class CatalougeSearchService {
return res.result as Item[];
}),
);
+
+ if (abortSignal) {
+ req$ = req$.pipe(takeUntilAborted(abortSignal));
+ }
+
+ try {
+ const items = await firstValueFrom(req$);
+ this.#logger.debug('Successfully fetched items by EANs', () => ({
+ count: items.length,
+ }));
+ return items;
+ } catch (error) {
+ this.#logger.error(
+ 'Error fetching items by EANs',
+ error as Error,
+ () => ({
+ eanCount: ean.length,
+ }),
+ );
+ return [];
+ }
}
async searchByTerm(
diff --git a/libs/checkout/data-access/src/lib/constants.ts b/libs/checkout/data-access/src/lib/constants.ts
index 70b96993e..8a0a3d5a0 100644
--- a/libs/checkout/data-access/src/lib/constants.ts
+++ b/libs/checkout/data-access/src/lib/constants.ts
@@ -6,3 +6,6 @@ export const CHECKOUT_SHOPPING_CART_ID_METADATA_KEY =
export const CHECKOUT_REWARD_SHOPPING_CART_ID_METADATA_KEY =
'checkout-data-access.checkoutRewardShoppingCartId';
+
+export const CHECKOUT_REWARD_SELECTION_POPUP_OPENED_STATE_KEY =
+ 'checkout-data-access.checkoutRewardSelectionPopupOpenedState';
diff --git a/libs/checkout/data-access/src/lib/facades/index.ts b/libs/checkout/data-access/src/lib/facades/index.ts
index 6c3089b11..93997a74d 100644
--- a/libs/checkout/data-access/src/lib/facades/index.ts
+++ b/libs/checkout/data-access/src/lib/facades/index.ts
@@ -1,3 +1,4 @@
export * from './branch.facade';
export * from './purchase-options.facade';
export * from './shopping-cart.facade';
+export * from './reward-selection.facade';
diff --git a/libs/checkout/data-access/src/lib/facades/reward-selection.facade.ts b/libs/checkout/data-access/src/lib/facades/reward-selection.facade.ts
new file mode 100644
index 000000000..3cd2a80cd
--- /dev/null
+++ b/libs/checkout/data-access/src/lib/facades/reward-selection.facade.ts
@@ -0,0 +1,21 @@
+import { inject, Injectable } from '@angular/core';
+import { ShoppingCartService } from '../services';
+import { RewardSelectionItem } from '../models';
+
+@Injectable()
+export class RewardSelectionFacade {
+ #shoppingCartService = inject(ShoppingCartService);
+
+ completeRewardSelection({
+ tabId,
+ rewardSelectionItems,
+ }: {
+ tabId: number;
+ rewardSelectionItems: RewardSelectionItem[];
+ }) {
+ return this.#shoppingCartService.completeRewardSelection({
+ tabId,
+ rewardSelectionItems,
+ });
+ }
+}
diff --git a/libs/checkout/data-access/src/lib/helpers/get-shopping-cart-item-key.helper.ts b/libs/checkout/data-access/src/lib/helpers/get-shopping-cart-item-key.helper.ts
new file mode 100644
index 000000000..d35a8cb8e
--- /dev/null
+++ b/libs/checkout/data-access/src/lib/helpers/get-shopping-cart-item-key.helper.ts
@@ -0,0 +1,15 @@
+import {
+ getOrderTypeFeature,
+ ShoppingCartItem,
+} from '@isa/checkout/data-access';
+
+/**
+ * Creates a unique key for an item based on EAN, destination, and orderItemType.
+ * Items are only considered identical if all three match.
+ */
+export const getItemKey = (item: ShoppingCartItem): string => {
+ const ean = item.product.ean ?? 'no-ean';
+ const destinationId = item.destination?.data?.id ?? 'no-destination';
+ const orderType = getOrderTypeFeature(item.features) ?? 'no-orderType';
+ return `${ean}|${destinationId}|${orderType}`;
+};
diff --git a/libs/checkout/data-access/src/lib/helpers/index.ts b/libs/checkout/data-access/src/lib/helpers/index.ts
index 4db928777..a1807a692 100644
--- a/libs/checkout/data-access/src/lib/helpers/index.ts
+++ b/libs/checkout/data-access/src/lib/helpers/index.ts
@@ -2,3 +2,4 @@ export * from './get-order-type-feature.helper';
export * from './checkout-analysis.helpers';
export * from './checkout-business-logic.helpers';
export * from './checkout-data.helpers';
+export * from './get-shopping-cart-item-key.helper';
diff --git a/libs/checkout/data-access/src/lib/models/branch-type.ts b/libs/checkout/data-access/src/lib/models/branch-type.ts
new file mode 100644
index 000000000..325184983
--- /dev/null
+++ b/libs/checkout/data-access/src/lib/models/branch-type.ts
@@ -0,0 +1,11 @@
+import { BranchType } from '@generated/swagger/checkout-api';
+
+export type BranchTypeEnum = BranchType;
+
+export const BranchTypeEnum = {
+ NotSet: 0,
+ Store: 1,
+ WebStore: 2,
+ CallCenter: 4,
+ Headquarter: 8,
+} as const;
diff --git a/libs/checkout/data-access/src/lib/models/index.ts b/libs/checkout/data-access/src/lib/models/index.ts
index 2cbc48eb7..be212c9f4 100644
--- a/libs/checkout/data-access/src/lib/models/index.ts
+++ b/libs/checkout/data-access/src/lib/models/index.ts
@@ -24,3 +24,5 @@ export * from './supplier';
export * from './shopping-cart';
export * from './update-shopping-cart-item';
export * from './vat-type';
+export * from './reward-selection-item';
+export * from './branch-type';
diff --git a/libs/checkout/data-access/src/lib/models/reward-selection-item.ts b/libs/checkout/data-access/src/lib/models/reward-selection-item.ts
new file mode 100644
index 000000000..f70ab6421
--- /dev/null
+++ b/libs/checkout/data-access/src/lib/models/reward-selection-item.ts
@@ -0,0 +1,14 @@
+import { Price as CatalogPrice } from '@isa/catalogue/data-access';
+import {
+ ShoppingCartItem,
+ Price as AvailabilityPrice,
+} from '@isa/checkout/data-access';
+
+export interface RewardSelectionItem {
+ item: ShoppingCartItem;
+ catalogPrice: CatalogPrice | undefined;
+ availabilityPrice: AvailabilityPrice | undefined;
+ catalogRewardPoints: number | undefined;
+ cartQuantity: number;
+ rewardCartQuantity: number;
+}
diff --git a/libs/checkout/data-access/src/lib/models/shopping-cart.ts b/libs/checkout/data-access/src/lib/models/shopping-cart.ts
index 48f4df87c..2e819494a 100644
--- a/libs/checkout/data-access/src/lib/models/shopping-cart.ts
+++ b/libs/checkout/data-access/src/lib/models/shopping-cart.ts
@@ -1,3 +1,7 @@
import { ShoppingCartDTO } from '@generated/swagger/checkout-api';
+import { EntityContainer } from '@isa/common/data-access';
+import { ShoppingCartItem } from './shopping-cart-item';
-export type ShoppingCart = ShoppingCartDTO;
+export type ShoppingCart = Omit & {
+ items: EntityContainer[];
+};
diff --git a/libs/checkout/data-access/src/lib/schemas/base-schemas.ts b/libs/checkout/data-access/src/lib/schemas/base-schemas.ts
index 8d9dbdaf3..4314a6721 100644
--- a/libs/checkout/data-access/src/lib/schemas/base-schemas.ts
+++ b/libs/checkout/data-access/src/lib/schemas/base-schemas.ts
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { AvailabilityType, Gender, ShippingTarget } from '../models';
import { OrderType } from '../models';
+import { BranchTypeEnum } from '../models';
import {
AddressSchema,
CommunicationDetailsSchema,
@@ -196,7 +197,7 @@ export const BranchDTOSchema: z.ZodOptional> = z
modifiedAt: z.string().optional(),
address: AddressSchema.optional(),
branchNumber: z.string().optional(),
- branchType: z.string().optional(), // BranchType enum - treating as string for now
+ branchType: z.nativeEnum(BranchTypeEnum).optional(),
isDefault: z.string().optional(),
isOnline: z.boolean().optional(),
isOrderingEnabled: z.boolean().optional(),
@@ -206,7 +207,7 @@ export const BranchDTOSchema: z.ZodOptional> = z
name: z.string().optional(),
parent: EntityContainerSchema(
z.lazy((): z.ZodOptional> => BranchDTOSchema),
- ),
+ ).optional(),
shortName: z.string().optional(),
})
.optional();
diff --git a/libs/checkout/data-access/src/lib/services/checkout-metadata.service.ts b/libs/checkout/data-access/src/lib/services/checkout-metadata.service.ts
index 318dee365..62e532008 100644
--- a/libs/checkout/data-access/src/lib/services/checkout-metadata.service.ts
+++ b/libs/checkout/data-access/src/lib/services/checkout-metadata.service.ts
@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core';
import { TabService, getMetadataHelper } from '@isa/core/tabs';
import {
+ CHECKOUT_REWARD_SELECTION_POPUP_OPENED_STATE_KEY,
CHECKOUT_REWARD_SHOPPING_CART_ID_METADATA_KEY,
CHECKOUT_SHOPPING_CART_ID_METADATA_KEY,
SELECTED_BRANCH_METADATA_KEY,
@@ -55,4 +56,22 @@ export class CheckoutMetadataService {
this.#tabService.entityMap(),
);
}
+
+ setRewardSelectionPopupOpenedState(
+ tabId: number,
+ opened: boolean | undefined,
+ ) {
+ this.#tabService.patchTabMetadata(tabId, {
+ [CHECKOUT_REWARD_SELECTION_POPUP_OPENED_STATE_KEY]: opened,
+ });
+ }
+
+ getRewardSelectionPopupOpenedState(tabId: number): boolean | undefined {
+ return getMetadataHelper(
+ tabId,
+ CHECKOUT_REWARD_SELECTION_POPUP_OPENED_STATE_KEY,
+ z.boolean().optional(),
+ this.#tabService.entityMap(),
+ );
+ }
}
diff --git a/libs/checkout/data-access/src/lib/services/shopping-cart.service.spec.ts b/libs/checkout/data-access/src/lib/services/shopping-cart.service.spec.ts
new file mode 100644
index 000000000..fa2bea5d1
--- /dev/null
+++ b/libs/checkout/data-access/src/lib/services/shopping-cart.service.spec.ts
@@ -0,0 +1,255 @@
+import { TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { ShoppingCartService } from './shopping-cart.service';
+import { CheckoutMetadataService } from './checkout-metadata.service';
+import { StoreCheckoutShoppingCartService } from '@generated/swagger/checkout-api';
+import { RewardSelectionItem, ShoppingCart, ShoppingCartItem } from '../models';
+import { provideLogging, LogLevel } from '@isa/core/logging';
+
+describe('ShoppingCartService', () => {
+ let service: ShoppingCartService;
+ let mockStoreCheckoutShoppingCartService: any;
+ let mockCheckoutMetadataService: any;
+
+ const createMockShoppingCartItem = (): ShoppingCartItem =>
+ ({
+ id: 1,
+ product: {
+ catalogProductNumber: 'PROD-123',
+ name: 'Test Product',
+ },
+ quantity: 1,
+ destination: { id: 1 },
+ availability: {
+ price: { value: { value: 10 } },
+ },
+ }) as ShoppingCartItem;
+
+ const createMockShoppingCart = (items: any[] = []): ShoppingCart =>
+ ({
+ id: 123,
+ items: items.map((data) => ({ id: data.id, data })),
+ }) as ShoppingCart;
+
+ const createMockRewardSelectionItem = (
+ cartQuantity = 1,
+ rewardCartQuantity = 0,
+ ): RewardSelectionItem => ({
+ item: createMockShoppingCartItem(),
+ catalogPrice: { value: { value: 10 } } as any,
+ availabilityPrice: { value: { value: 10 } } as any,
+ catalogRewardPoints: 100,
+ cartQuantity,
+ rewardCartQuantity,
+ });
+
+ beforeEach(() => {
+ mockStoreCheckoutShoppingCartService = {
+ StoreCheckoutShoppingCartCreateShoppingCart: vi.fn(),
+ StoreCheckoutShoppingCartGetShoppingCart: vi.fn(),
+ StoreCheckoutShoppingCartAddItemToShoppingCart: vi.fn(),
+ StoreCheckoutShoppingCartUpdateShoppingCartItem: vi.fn(),
+ };
+
+ mockCheckoutMetadataService = {
+ getShoppingCartId: vi.fn(),
+ setShoppingCartId: vi.fn(),
+ getRewardShoppingCartId: vi.fn(),
+ setRewardShoppingCartId: vi.fn(),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ ShoppingCartService,
+ {
+ provide: StoreCheckoutShoppingCartService,
+ useValue: mockStoreCheckoutShoppingCartService,
+ },
+ {
+ provide: CheckoutMetadataService,
+ useValue: mockCheckoutMetadataService,
+ },
+ provideLogging({ level: LogLevel.Off }),
+ ],
+ });
+
+ service = TestBed.inject(ShoppingCartService);
+ });
+
+ describe('completeRewardSelection', () => {
+ it('should add item to regular cart when cartQuantity > 0', async () => {
+ // Arrange
+ const tabId = 1;
+ const rewardSelectionItem = createMockRewardSelectionItem(2, 0);
+
+ mockCheckoutMetadataService.getShoppingCartId.mockReturnValue(123);
+ mockCheckoutMetadataService.getRewardShoppingCartId.mockReturnValue(456);
+
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartGetShoppingCart.mockReturnValue(
+ of({ result: createMockShoppingCart([]), error: null }),
+ );
+
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartAddItemToShoppingCart.mockReturnValue(
+ of({ result: createMockShoppingCart([]), error: null }),
+ );
+
+ // Act
+ await service.completeRewardSelection({
+ tabId,
+ rewardSelectionItems: [rewardSelectionItem],
+ });
+
+ // Assert
+ const call =
+ mockStoreCheckoutShoppingCartService
+ .StoreCheckoutShoppingCartAddItemToShoppingCart.mock.calls[0][0];
+ expect(call.shoppingCartId).toBe(123);
+ expect(call.items[0].quantity).toBe(2);
+ expect(call.items[0].loyalty).toBeUndefined();
+ });
+
+ it('should add item to reward cart when rewardCartQuantity > 0', async () => {
+ // Arrange
+ const tabId = 1;
+ const rewardSelectionItem = createMockRewardSelectionItem(0, 3);
+
+ mockCheckoutMetadataService.getShoppingCartId.mockReturnValue(123);
+ mockCheckoutMetadataService.getRewardShoppingCartId.mockReturnValue(456);
+
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartGetShoppingCart.mockReturnValue(
+ of({ result: createMockShoppingCart([]), error: null }),
+ );
+
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartAddItemToShoppingCart.mockReturnValue(
+ of({ result: createMockShoppingCart([]), error: null }),
+ );
+
+ // Act
+ await service.completeRewardSelection({
+ tabId,
+ rewardSelectionItems: [rewardSelectionItem],
+ });
+
+ // Assert
+ const calls =
+ mockStoreCheckoutShoppingCartService
+ .StoreCheckoutShoppingCartAddItemToShoppingCart.mock.calls;
+
+ // Should only be called once for reward cart (not regular cart since cartQuantity is 0)
+ expect(calls).toHaveLength(1);
+ expect(calls[0][0].shoppingCartId).toBe(456);
+ expect(calls[0][0].items[0].quantity).toBe(3);
+ // Note: Zod schema validation transforms the data before the mock captures it
+ // The actual service logic sets loyalty, but the schema may transform it
+ });
+
+ it('should update item quantity in regular cart', async () => {
+ // Arrange
+ const tabId = 1;
+ const existingItem = createMockShoppingCartItem();
+ const rewardSelectionItem = createMockRewardSelectionItem(5, 0);
+
+ mockCheckoutMetadataService.getShoppingCartId.mockReturnValue(123);
+ mockCheckoutMetadataService.getRewardShoppingCartId.mockReturnValue(456);
+
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartGetShoppingCart.mockReturnValueOnce(
+ of({ result: createMockShoppingCart([existingItem]), error: null }),
+ ).mockReturnValueOnce(
+ of({ result: createMockShoppingCart([]), error: null }),
+ );
+
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem.mockReturnValue(
+ of({ result: createMockShoppingCart([]), error: null }),
+ );
+
+ // Act
+ await service.completeRewardSelection({
+ tabId,
+ rewardSelectionItems: [rewardSelectionItem],
+ });
+
+ // Assert
+ expect(
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem,
+ ).toHaveBeenCalledWith({
+ shoppingCartId: 123,
+ shoppingCartItemId: 1,
+ values: { quantity: 5 },
+ });
+ });
+
+ it('should remove item from cart when quantity is 0', async () => {
+ // Arrange
+ const tabId = 1;
+ const existingItem = createMockShoppingCartItem();
+ const rewardSelectionItem = createMockRewardSelectionItem(0, 0);
+
+ mockCheckoutMetadataService.getShoppingCartId.mockReturnValue(123);
+ mockCheckoutMetadataService.getRewardShoppingCartId.mockReturnValue(456);
+
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartGetShoppingCart.mockReturnValueOnce(
+ of({ result: createMockShoppingCart([existingItem]), error: null }),
+ ).mockReturnValueOnce(
+ of({ result: createMockShoppingCart([]), error: null }),
+ );
+
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem.mockReturnValue(
+ of({ result: createMockShoppingCart([]), error: null }),
+ );
+
+ // Act
+ await service.completeRewardSelection({
+ tabId,
+ rewardSelectionItems: [rewardSelectionItem],
+ });
+
+ // Assert
+ expect(
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem,
+ ).toHaveBeenCalledWith({
+ shoppingCartId: 123,
+ shoppingCartItemId: 1,
+ values: { quantity: 0 },
+ });
+ });
+
+ it('should create shopping cart if not exists', async () => {
+ // Arrange
+ const tabId = 1;
+ const rewardSelectionItem = createMockRewardSelectionItem(1, 0);
+
+ mockCheckoutMetadataService.getShoppingCartId.mockReturnValue(null);
+ mockCheckoutMetadataService.getRewardShoppingCartId.mockReturnValue(null);
+
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartCreateShoppingCart.mockReturnValue(
+ of({ result: { id: 999 }, error: null }),
+ );
+
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartGetShoppingCart.mockReturnValue(
+ of({ result: createMockShoppingCart([]), error: null }),
+ );
+
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartAddItemToShoppingCart.mockReturnValue(
+ of({ result: createMockShoppingCart([]), error: null }),
+ );
+
+ // Act
+ await service.completeRewardSelection({
+ tabId,
+ rewardSelectionItems: [rewardSelectionItem],
+ });
+
+ // Assert
+ expect(
+ mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartCreateShoppingCart,
+ ).toHaveBeenCalledTimes(2);
+ expect(
+ mockCheckoutMetadataService.setShoppingCartId,
+ ).toHaveBeenCalledWith(tabId, 999);
+ expect(
+ mockCheckoutMetadataService.setRewardShoppingCartId,
+ ).toHaveBeenCalledWith(tabId, 999);
+ });
+ });
+});
diff --git a/libs/checkout/data-access/src/lib/services/shopping-cart.service.ts b/libs/checkout/data-access/src/lib/services/shopping-cart.service.ts
index 3e82caa70..e981f4d5b 100644
--- a/libs/checkout/data-access/src/lib/services/shopping-cart.service.ts
+++ b/libs/checkout/data-access/src/lib/services/shopping-cart.service.ts
@@ -16,15 +16,18 @@ import {
UpdateShoppingCartItemParams,
UpdateShoppingCartItemParamsSchema,
} from '../schemas';
-import { ShoppingCart } from '../models';
+import { RewardSelectionItem, ShoppingCart, ShoppingCartItem } from '../models';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { logger } from '@isa/core/logging';
+import { CheckoutMetadataService } from './checkout-metadata.service';
+import { getItemKey } from '../helpers';
@Injectable({ providedIn: 'root' })
export class ShoppingCartService {
#logger = logger(() => ({ service: 'ShoppingCartService' }));
#storeCheckoutShoppingCartService = inject(StoreCheckoutShoppingCartService);
+ #checkoutMetadataService = inject(CheckoutMetadataService);
async createShoppingCart(): Promise {
const req$ =
@@ -63,7 +66,7 @@ export class ShoppingCartService {
this.#logger.error('Failed to fetch shopping cart', err);
throw err;
}
- return res.result;
+ return res.result as ShoppingCart;
}
async canAddItems(
@@ -163,4 +166,226 @@ export class ShoppingCartService {
return res.result as ShoppingCart;
}
+
+ async completeRewardSelection({
+ tabId,
+ rewardSelectionItems,
+ }: {
+ tabId: number;
+ rewardSelectionItems: RewardSelectionItem[];
+ }) {
+ // Fetch or create both shopping cart IDs
+ const shoppingCartId = await this.#getOrCreateShoppingCartId(tabId);
+ const rewardShoppingCartId =
+ await this.#getOrCreateRewardShoppingCartId(tabId);
+
+ // Get Current Carts
+ const currentCart = await this.getShoppingCart(shoppingCartId);
+ const currentRewardCart = await this.getShoppingCart(rewardShoppingCartId);
+
+ // Get Current Items from Cart
+ const currentCartItems = currentCart?.items?.map((item) => item.data) ?? [];
+ const currentRewardCartItems =
+ currentRewardCart?.items?.map((item) => item.data) ?? [];
+
+ for (const selectionItem of rewardSelectionItems) {
+ // Search by Key needed because of different ShoppingCartItem IDs the items have in the different carts
+ const selectionItemKey = getItemKey(selectionItem.item);
+ const currentInCart = currentCartItems.find(
+ (item) => item && getItemKey(item) === selectionItemKey,
+ );
+ const currentInRewardCart = currentRewardCartItems.find(
+ (item) => item && getItemKey(item) === selectionItemKey,
+ );
+
+ // Handle regular cart
+ await this.#handleCart({
+ shoppingCartId,
+ itemId: currentInCart?.id,
+ currentCartItem: currentInCart,
+ rewardSelectionItem: selectionItem,
+ });
+
+ // Handle reward cart
+ await this.#handleRewardCart({
+ rewardShoppingCartId,
+ itemId: currentInRewardCart?.id,
+ currentRewardCartItem: currentInRewardCart,
+ rewardSelectionItem: selectionItem,
+ });
+ }
+ }
+
+ async #handleCart({
+ shoppingCartId,
+ itemId,
+ currentCartItem,
+ rewardSelectionItem,
+ }: {
+ shoppingCartId: number;
+ itemId: number | undefined;
+ currentCartItem: ShoppingCartItem | undefined;
+ rewardSelectionItem: RewardSelectionItem;
+ }) {
+ const desiredCartQuantity = rewardSelectionItem.cartQuantity;
+ if (currentCartItem && itemId) {
+ const currentQuantity = currentCartItem.quantity;
+ if (desiredCartQuantity !== currentQuantity) {
+ if (desiredCartQuantity === 0) {
+ this.#logger.info('Removing item from regular cart', () => ({
+ shoppingCartId,
+ itemId,
+ currentQuantity,
+ }));
+ await this.removeItem({
+ shoppingCartId,
+ shoppingCartItemId: itemId,
+ });
+ } else {
+ this.#logger.info('Updating item quantity in regular cart', () => ({
+ shoppingCartId,
+ itemId,
+ currentQuantity,
+ desiredCartQuantity,
+ }));
+ await this.updateItem({
+ shoppingCartId,
+ shoppingCartItemId: itemId,
+ values: { quantity: desiredCartQuantity },
+ });
+ }
+ }
+ } else if (desiredCartQuantity > 0) {
+ this.#logger.info('Adding item to regular cart', () => ({
+ shoppingCartId,
+ itemId,
+ desiredCartQuantity,
+ productNumber: rewardSelectionItem?.item?.product?.catalogProductNumber,
+ }));
+ await this.addItem({
+ shoppingCartId,
+ items: [
+ {
+ destination: rewardSelectionItem.item.destination,
+ product: {
+ ...rewardSelectionItem.item.product,
+ catalogProductNumber:
+ rewardSelectionItem?.item?.product?.catalogProductNumber ??
+ String(rewardSelectionItem?.item?.id),
+ },
+ availability: {
+ ...rewardSelectionItem.item.availability,
+ price:
+ rewardSelectionItem?.availabilityPrice ??
+ rewardSelectionItem?.catalogPrice,
+ },
+ promotion: rewardSelectionItem?.item?.promotion,
+ quantity: desiredCartQuantity,
+ loyalty: undefined,
+ },
+ ],
+ });
+ }
+ }
+
+ async #handleRewardCart({
+ rewardShoppingCartId,
+ itemId,
+ currentRewardCartItem,
+ rewardSelectionItem,
+ }: {
+ rewardShoppingCartId: number;
+ itemId: number | undefined;
+ currentRewardCartItem: ShoppingCartItem | undefined;
+ rewardSelectionItem: RewardSelectionItem;
+ }) {
+ const desiredRewardCartQuantity = rewardSelectionItem.rewardCartQuantity;
+ if (currentRewardCartItem && itemId) {
+ const currentQuantity = currentRewardCartItem.quantity;
+ if (desiredRewardCartQuantity !== currentQuantity) {
+ if (desiredRewardCartQuantity === 0) {
+ this.#logger.info('Removing item from reward cart', () => ({
+ rewardShoppingCartId,
+ itemId,
+ currentQuantity,
+ }));
+ await this.removeItem({
+ shoppingCartId: rewardShoppingCartId,
+ shoppingCartItemId: itemId,
+ });
+ } else {
+ this.#logger.info('Updating item quantity in reward cart', () => ({
+ rewardShoppingCartId,
+ itemId,
+ currentQuantity,
+ desiredRewardCartQuantity,
+ }));
+ await this.updateItem({
+ shoppingCartId: rewardShoppingCartId,
+ shoppingCartItemId: itemId,
+ values: { quantity: desiredRewardCartQuantity },
+ });
+ }
+ }
+ } else if (desiredRewardCartQuantity > 0) {
+ this.#logger.info('Adding item to reward cart', () => ({
+ rewardShoppingCartId,
+ itemId,
+ desiredRewardCartQuantity,
+ rewardPoints: rewardSelectionItem.catalogRewardPoints,
+ productNumber: rewardSelectionItem?.item?.product?.catalogProductNumber,
+ }));
+ await this.addItem({
+ shoppingCartId: rewardShoppingCartId,
+ items: [
+ {
+ destination: rewardSelectionItem.item.destination,
+ product: {
+ ...rewardSelectionItem.item.product,
+ catalogProductNumber:
+ rewardSelectionItem?.item?.product?.catalogProductNumber ??
+ String(rewardSelectionItem?.item?.id),
+ },
+ quantity: desiredRewardCartQuantity,
+ promotion: undefined, // If loyalty is set, we need to remove promotion
+ loyalty: { value: rewardSelectionItem.catalogRewardPoints }, // Set loyalty points from item
+ availability: {
+ ...rewardSelectionItem.item.availability,
+ price: {
+ value: { value: 0 },
+ },
+ },
+ },
+ ],
+ });
+ }
+ }
+
+ async #getOrCreateShoppingCartId(tabId: number): Promise {
+ const shoppingCartId =
+ this.#checkoutMetadataService.getShoppingCartId(tabId);
+
+ if (shoppingCartId) {
+ return shoppingCartId;
+ }
+
+ const shoppingCart = await this.createShoppingCart();
+ this.#checkoutMetadataService.setShoppingCartId(tabId!, shoppingCart.id);
+ return shoppingCart.id!;
+ }
+
+ async #getOrCreateRewardShoppingCartId(tabId: number): Promise {
+ const rewardShoppingCartId =
+ this.#checkoutMetadataService.getRewardShoppingCartId(tabId);
+ if (rewardShoppingCartId) {
+ return rewardShoppingCartId;
+ }
+
+ const shoppingCart = await this.createShoppingCart();
+ this.#checkoutMetadataService.setRewardShoppingCartId(
+ tabId!,
+ shoppingCart.id,
+ );
+ return shoppingCart.id!;
+ }
}
diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.html b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.html
index b5fdae272..e4f46e2f0 100644
--- a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.html
+++ b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.html
@@ -6,9 +6,7 @@
Sie können Prämien unter folgendem Link zurück in den Warenkorb legen:
-
+
diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.ts b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.ts
index 01f605e1f..a5bb57e0c 100644
--- a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.ts
+++ b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.ts
@@ -1,6 +1,5 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NavigateBackButtonComponent } from '@isa/core/tabs';
-import { TextButtonComponent } from '@isa/ui/buttons';
import { CheckoutCustomerRewardCardComponent } from './customer-reward-card/customer-reward-card.component';
import { BillingAndShippingAddressCardComponent } from './billing-and-shipping-address-card/billing-and-shipping-address-card.component';
import { RewardShoppingCartItemsComponent } from './reward-shopping-cart-items/reward-shopping-cart-items.component';
@@ -10,6 +9,7 @@ import {
SelectedCustomerBonusCardsResource,
} from '@isa/crm/data-access';
import { CompleteOrderButtonComponent } from './complete-order-button/complete-order-button.component';
+import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog';
@Component({
selector: 'checkout-reward-shopping-cart',
@@ -18,11 +18,11 @@ import { CompleteOrderButtonComponent } from './complete-order-button/complete-o
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
NavigateBackButtonComponent,
- TextButtonComponent,
CheckoutCustomerRewardCardComponent,
BillingAndShippingAddressCardComponent,
RewardShoppingCartItemsComponent,
CompleteOrderButtonComponent,
+ RewardSelectionTriggerComponent,
],
providers: [
SelectedRewardShoppingCartResource,
diff --git a/libs/checkout/shared/reward-selection-dialog/README.md b/libs/checkout/shared/reward-selection-dialog/README.md
new file mode 100644
index 000000000..b1eda1263
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/README.md
@@ -0,0 +1,214 @@
+# Reward Selection Dialog
+
+Angular library for managing reward selection in shopping cart context. Allows users to toggle between regular purchase and reward redemption using bonus points.
+
+## Features
+
+- 🎯 Pre-built trigger component or direct service integration
+- 🔄 Automatic resource management (carts, bonus cards)
+- 📊 Smart grouping by order type and branch
+- 💾 NgRx Signals state management
+- ✅ Full TypeScript support
+
+## Installation
+
+```typescript
+import {
+ RewardSelectionService,
+ RewardSelectionPopUpService,
+ RewardSelectionTriggerComponent,
+} from '@isa/checkout/shared/reward-selection-dialog';
+```
+
+## Quick Start
+
+### Using the Trigger Component (Recommended)
+
+Simplest integration - includes all providers automatically:
+
+```typescript
+import { Component } from '@angular/core';
+import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog';
+
+@Component({
+ selector: 'app-checkout',
+ template: ``,
+ imports: [RewardSelectionTriggerComponent],
+})
+export class CheckoutComponent {}
+```
+
+### Using the Pop-Up Service
+
+More control over navigation flow:
+
+```typescript
+import { Component, inject } from '@angular/core';
+import {
+ RewardSelectionPopUpService,
+ NavigateAfterRewardSelection,
+ RewardSelectionService,
+} from '@isa/checkout/shared/reward-selection-dialog';
+import {
+ SelectedShoppingCartResource,
+ SelectedRewardShoppingCartResource,
+} from '@isa/checkout/data-access';
+import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
+
+@Component({
+ selector: 'app-custom-checkout',
+ template: ``,
+ providers: [
+ // Required providers
+ SelectedShoppingCartResource,
+ SelectedRewardShoppingCartResource,
+ SelectedCustomerBonusCardsResource,
+ RewardSelectionService,
+ RewardSelectionPopUpService,
+ ],
+})
+export class CustomCheckoutComponent {
+ #popUpService = inject(RewardSelectionPopUpService);
+
+ async openRewardSelection() {
+ const result = await this.#popUpService.popUp();
+
+ // Handle navigation: 'cart' | 'reward' | 'catalog' | undefined
+ if (result === NavigateAfterRewardSelection.CART) {
+ // Navigate to cart
+ }
+ }
+}
+```
+
+### Using the Service Directly
+
+For custom UI or advanced use cases:
+
+```typescript
+import { Component, inject } from '@angular/core';
+import { RewardSelectionService } from '@isa/checkout/shared/reward-selection-dialog';
+import {
+ SelectedShoppingCartResource,
+ SelectedRewardShoppingCartResource,
+} from '@isa/checkout/data-access';
+import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
+
+@Component({
+ selector: 'app-advanced',
+ template: `
+ @if (canOpen()) {
+
+ }
+ `,
+ providers: [
+ SelectedShoppingCartResource,
+ SelectedRewardShoppingCartResource,
+ SelectedCustomerBonusCardsResource,
+ RewardSelectionService,
+ ],
+})
+export class AdvancedComponent {
+ #service = inject(RewardSelectionService);
+
+ canOpen = this.#service.canOpen;
+ isLoading = this.#service.isLoading;
+ eligibleItemsCount = computed(() => this.#service.eligibleItems().length);
+ availablePoints = this.#service.primaryBonusCardPoints;
+
+ async openDialog() {
+ const result = await this.#service.open({ closeText: 'Cancel' });
+ if (result) {
+ // Handle result.rewardSelectionItems
+ await this.#service.reloadResources();
+ }
+ }
+}
+```
+
+## API Reference
+
+### RewardSelectionService
+
+**Key Signals:**
+- `canOpen()`: `boolean` - Can dialog be opened
+- `isLoading()`: `boolean` - Loading state
+- `eligibleItems()`: `RewardSelectionItem[]` - Items available as rewards
+- `primaryBonusCardPoints()`: `number` - Available points
+
+**Methods:**
+- `open({ closeText }): Promise` - Opens dialog
+- `reloadResources(): Promise` - Reloads all data
+
+### RewardSelectionPopUpService
+
+**Methods:**
+- `popUp(): Promise` - Opens dialog with navigation flow
+
+**Return values:**
+- `'cart'` - Navigate to shopping cart
+- `'reward'` - Navigate to reward checkout
+- `'catalog'` - Navigate to catalog
+- `undefined` - No navigation needed
+
+### Types
+
+```typescript
+interface RewardSelectionItem {
+ item: ShoppingCartItem;
+ catalogPrice: Price | undefined;
+ availabilityPrice: Price | undefined;
+ catalogRewardPoints: number | undefined;
+ cartQuantity: number;
+ rewardCartQuantity: number;
+}
+
+type RewardSelectionDialogResult = {
+ rewardSelectionItems: RewardSelectionItem[];
+} | undefined;
+
+type NavigateAfterRewardSelection = 'cart' | 'reward' | 'catalog';
+```
+
+## Required Providers
+
+When using `RewardSelectionService` or `RewardSelectionPopUpService` directly, provide:
+
+```typescript
+providers: [
+ SelectedShoppingCartResource, // Regular cart data
+ SelectedRewardShoppingCartResource, // Reward cart data
+ SelectedCustomerBonusCardsResource, // Customer bonus cards
+ RewardSelectionService, // Core service
+ RewardSelectionPopUpService, // Optional: only if using pop-up
+]
+```
+
+**Note:** `RewardSelectionTriggerComponent` includes all required providers automatically.
+
+## Testing
+
+```bash
+nx test reward-selection-dialog
+```
+
+## Architecture
+
+```
+reward-selection-dialog/
+├── helper/ # Pure utility functions
+├── resource/ # Data resources
+├── service/ # Business logic
+├── store/ # NgRx Signals state
+└── trigger/ # Trigger component
+```
+
+## Dependencies
+
+- `@isa/checkout/data-access` - Cart resources
+- `@isa/crm/data-access` - Customer data
+- `@isa/catalogue/data-access` - Product catalog
+- `@isa/ui/dialog` - Dialog infrastructure
+- `@ngrx/signals` - State management
diff --git a/libs/checkout/shared/reward-selection-dialog/eslint.config.cjs b/libs/checkout/shared/reward-selection-dialog/eslint.config.cjs
new file mode 100644
index 000000000..86877dc4b
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/eslint.config.cjs
@@ -0,0 +1,34 @@
+const nx = require('@nx/eslint-plugin');
+const baseConfig = require('../../../../eslint.config.js');
+
+module.exports = [
+ ...baseConfig,
+ ...nx.configs['flat/angular'],
+ ...nx.configs['flat/angular-template'],
+ {
+ files: ['**/*.ts'],
+ rules: {
+ '@angular-eslint/directive-selector': [
+ 'error',
+ {
+ type: 'attribute',
+ prefix: 'lib',
+ style: 'camelCase',
+ },
+ ],
+ '@angular-eslint/component-selector': [
+ 'error',
+ {
+ type: 'element',
+ prefix: 'lib',
+ style: 'kebab-case',
+ },
+ ],
+ },
+ },
+ {
+ files: ['**/*.html'],
+ // Override or add rules here
+ rules: {},
+ },
+];
diff --git a/libs/checkout/shared/reward-selection-dialog/project.json b/libs/checkout/shared/reward-selection-dialog/project.json
new file mode 100644
index 000000000..526a1ac1f
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/project.json
@@ -0,0 +1,20 @@
+{
+ "name": "reward-selection-dialog",
+ "$schema": "../../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "libs/checkout/shared/reward-selection-dialog/src",
+ "prefix": "lib",
+ "projectType": "library",
+ "tags": [],
+ "targets": {
+ "test": {
+ "executor": "@nx/vite:test",
+ "outputs": ["{options.reportsDirectory}"],
+ "options": {
+ "reportsDirectory": "../../../../coverage/libs/checkout/shared/reward-selection-dialog"
+ }
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint"
+ }
+ }
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/index.ts b/libs/checkout/shared/reward-selection-dialog/src/index.ts
new file mode 100644
index 000000000..4eba54f4f
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/index.ts
@@ -0,0 +1,4 @@
+export * from './lib/reward-selection-dialog/service/reward-selection.service';
+export * from './lib/reward-selection-dialog/service/reward-selection-pop-up.service';
+export * from './lib/reward-selection-dialog/trigger/reward-selection-trigger.component';
+export * from './lib/reward-selection-dialog/reward-selection-dialog.component';
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/format-address.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/format-address.helper.spec.ts
new file mode 100644
index 000000000..bf05b5e71
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/format-address.helper.spec.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from 'vitest';
+import { formatAddress } from './format-address.helper';
+
+describe('formatAddress', () => {
+ it('should format complete address correctly', () => {
+ const address = {
+ street: 'Main Street',
+ streetNumber: '123',
+ zipCode: '12345',
+ city: 'Berlin',
+ };
+
+ const result = formatAddress(address);
+
+ expect(result).toBe('Main Street 123 | 12345 Berlin');
+ });
+
+ it('should handle missing street number', () => {
+ const address = {
+ street: 'Main Street',
+ zipCode: '12345',
+ city: 'Berlin',
+ };
+
+ const result = formatAddress(address);
+
+ expect(result).toBe('Main Street | 12345 Berlin');
+ });
+
+ it('should return undefined for undefined address', () => {
+ const result = formatAddress(undefined);
+
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/format-address.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/format-address.helper.ts
new file mode 100644
index 000000000..e7a7d5f4e
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/format-address.helper.ts
@@ -0,0 +1,13 @@
+import { Address } from '@isa/shared/address';
+
+export const formatAddress = (address?: Address): string | undefined => {
+ if (!address) return undefined;
+
+ const streetPart = [address.street, address.streetNumber]
+ .filter(Boolean)
+ .join(' ');
+
+ const cityPart = [address.zipCode, address.city].filter(Boolean).join(' ');
+
+ return [streetPart, cityPart].filter(Boolean).join(' | ');
+};
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-loyalty-points.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-loyalty-points.helper.spec.ts
new file mode 100644
index 000000000..ead87b437
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-loyalty-points.helper.spec.ts
@@ -0,0 +1,52 @@
+import { describe, it, expect } from 'vitest';
+import { calculateLoyaltyPointsValue } from './get-loyalty-points.helper';
+import { RewardSelectionItem } from '@isa/checkout/data-access';
+
+describe('calculateLoyaltyPointsValue', () => {
+ it('should return item loyalty points when available', () => {
+ const item: RewardSelectionItem = {
+ item: {
+ loyalty: { value: 100 },
+ },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: 50,
+ } as RewardSelectionItem;
+
+ const result = calculateLoyaltyPointsValue(item);
+
+ expect(result).toBe(100);
+ });
+
+ it('should return catalog loyalty points when item points not available', () => {
+ const item: RewardSelectionItem = {
+ item: {},
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: 50,
+ } as RewardSelectionItem;
+
+ const result = calculateLoyaltyPointsValue(item);
+
+ expect(result).toBe(50);
+ });
+
+ it('should return 0 when no loyalty points available', () => {
+ const item: RewardSelectionItem = {
+ item: {},
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ } as RewardSelectionItem;
+
+ const result = calculateLoyaltyPointsValue(item);
+
+ expect(result).toBe(0);
+ });
+});
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-loyalty-points.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-loyalty-points.helper.ts
new file mode 100644
index 000000000..84c868acd
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-loyalty-points.helper.ts
@@ -0,0 +1,18 @@
+import { RewardSelectionItem } from '@isa/checkout/data-access';
+
+export const calculateLoyaltyPointsValue = (
+ rewardSelectionItem: RewardSelectionItem,
+): number => {
+ const itemLoyaltyPoints = rewardSelectionItem.item?.loyalty?.value;
+ const catalogLoyaltyPoints = rewardSelectionItem?.catalogRewardPoints;
+
+ if (itemLoyaltyPoints != null && itemLoyaltyPoints !== 0) {
+ return itemLoyaltyPoints;
+ }
+
+ if (catalogLoyaltyPoints != null && catalogLoyaltyPoints !== 0) {
+ return catalogLoyaltyPoints;
+ }
+
+ return 0;
+};
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-order-type-icon.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-order-type-icon.helper.spec.ts
new file mode 100644
index 000000000..4653021cb
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-order-type-icon.helper.spec.ts
@@ -0,0 +1,28 @@
+import { describe, it, expect } from 'vitest';
+import { getOrderTypeIcon } from './get-order-type-icon.helper';
+
+describe('getOrderTypeIcon', () => {
+ it('should return correct icon for Versand', () => {
+ const result = getOrderTypeIcon('Versand');
+
+ expect(result).toBe('isaDeliveryVersand');
+ });
+
+ it('should return correct icon for Abholung', () => {
+ const result = getOrderTypeIcon('Abholung');
+
+ expect(result).toBe('isaDeliveryRuecklage2');
+ });
+
+ it('should return correct icon for Rücklage', () => {
+ const result = getOrderTypeIcon('Rücklage');
+
+ expect(result).toBe('isaDeliveryRuecklage1');
+ });
+
+ it('should return default icon for unknown order type', () => {
+ const result = getOrderTypeIcon('UnknownType');
+
+ expect(result).toBe('isaDeliveryVersand');
+ });
+});
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-order-type-icon.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-order-type-icon.helper.ts
new file mode 100644
index 000000000..eb0e97300
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-order-type-icon.helper.ts
@@ -0,0 +1,14 @@
+export const ORDER_TYPE_ICONS = {
+ 'Versand': 'isaDeliveryVersand',
+ 'DIG-Versand': 'isaDeliveryVersand',
+ 'B2B-Versand': 'isaDeliveryB2BVersand1',
+ 'Abholung': 'isaDeliveryRuecklage2',
+ 'Rücklage': 'isaDeliveryRuecklage1',
+} as const;
+
+export const getOrderTypeIcon = (orderType: string): string => {
+ return (
+ ORDER_TYPE_ICONS[orderType as keyof typeof ORDER_TYPE_ICONS] ??
+ 'isaDeliveryVersand'
+ );
+};
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-price.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-price.helper.spec.ts
new file mode 100644
index 000000000..9b80bc4dd
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-price.helper.spec.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect } from 'vitest';
+import { calculatePriceValue } from './get-price.helper';
+import { RewardSelectionItem } from '@isa/checkout/data-access';
+
+describe('calculatePriceValue', () => {
+ it('should return item total price when available', () => {
+ const item: RewardSelectionItem = {
+ item: {
+ total: { value: { value: 99.99 } },
+ },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: { value: { value: 89.99 } },
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ } as RewardSelectionItem;
+
+ const result = calculatePriceValue(item);
+
+ expect(result).toBe(99.99);
+ });
+
+ it('should return availability price when total price not available', () => {
+ const item: RewardSelectionItem = {
+ item: {
+ availability: { price: { value: { value: 79.99 } } },
+ },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: { value: { value: 89.99 } },
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ } as RewardSelectionItem;
+
+ const result = calculatePriceValue(item);
+
+ expect(result).toBe(79.99);
+ });
+
+ it('should return catalog price as fallback', () => {
+ const item: RewardSelectionItem = {
+ item: {},
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: { value: { value: 89.99 } },
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ } as RewardSelectionItem;
+
+ const result = calculatePriceValue(item);
+
+ expect(result).toBe(89.99);
+ });
+
+ it('should return 0 when no price available', () => {
+ const item: RewardSelectionItem = {
+ item: {},
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ } as RewardSelectionItem;
+
+ const result = calculatePriceValue(item);
+
+ expect(result).toBe(0);
+ });
+});
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-price.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-price.helper.ts
new file mode 100644
index 000000000..804ddb7c3
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-price.helper.ts
@@ -0,0 +1,24 @@
+import { RewardSelectionItem } from '@isa/checkout/data-access';
+
+export const calculatePriceValue = (
+ rewardSelectionItem: RewardSelectionItem,
+): number => {
+ const itemTotalPrice = rewardSelectionItem.item?.total?.value?.value;
+ const availabilityPrice =
+ rewardSelectionItem.item?.availability?.price?.value?.value;
+ const catalogPrice = rewardSelectionItem.catalogPrice?.value?.value;
+
+ if (itemTotalPrice != null && itemTotalPrice !== 0) {
+ return itemTotalPrice;
+ }
+
+ if (availabilityPrice != null && availabilityPrice !== 0) {
+ return availabilityPrice;
+ }
+
+ if (catalogPrice != null && catalogPrice !== 0) {
+ return catalogPrice;
+ }
+
+ return 0;
+};
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-branch.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-branch.helper.spec.ts
new file mode 100644
index 000000000..126c10c3f
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-branch.helper.spec.ts
@@ -0,0 +1,95 @@
+import { describe, it, expect } from 'vitest';
+import { groupByBranch } from './group-by-branch.helper';
+import { RewardSelectionItem } from '@isa/checkout/data-access';
+
+describe('groupByBranch', () => {
+ it('should not group items for Versand order type', () => {
+ const items: RewardSelectionItem[] = [
+ {
+ item: {
+ product: { name: 'Product A' },
+ },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ } as RewardSelectionItem,
+ {
+ item: {
+ product: { name: 'Product B' },
+ },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ } as RewardSelectionItem,
+ ];
+
+ const result = groupByBranch('Versand', items);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].branchId).toBeUndefined();
+ expect(result[0].items).toHaveLength(2);
+ });
+
+ it('should group items by branch for Abholung order type', () => {
+ const items: RewardSelectionItem[] = [
+ {
+ item: {
+ product: { name: 'Product A' },
+ destination: {
+ data: {
+ targetBranch: {
+ id: 1,
+ data: { name: 'Branch 1' },
+ },
+ },
+ },
+ },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ } as RewardSelectionItem,
+ {
+ item: {
+ product: { name: 'Product B' },
+ destination: {
+ data: {
+ targetBranch: {
+ id: 2,
+ data: { name: 'Branch 2' },
+ },
+ },
+ },
+ },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ } as RewardSelectionItem,
+ ];
+
+ const result = groupByBranch('Abholung', items);
+
+ expect(result).toHaveLength(2);
+ expect(result[0].branchId).toBe(1);
+ expect(result[1].branchId).toBe(2);
+ });
+
+ it('should sort items by product name within groups', () => {
+ const items: RewardSelectionItem[] = [
+ {
+ item: {
+ product: { name: 'Zebra' },
+ },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ } as RewardSelectionItem,
+ {
+ item: {
+ product: { name: 'Apple' },
+ },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ } as RewardSelectionItem,
+ ];
+
+ const result = groupByBranch('Versand', items);
+
+ expect(result[0].items[0].item.product.name).toBe('Apple');
+ expect(result[0].items[1].item.product.name).toBe('Zebra');
+ });
+});
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-branch.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-branch.helper.ts
new file mode 100644
index 000000000..e3679159b
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-branch.helper.ts
@@ -0,0 +1,56 @@
+import { RewardSelectionItem } from '@isa/checkout/data-access';
+import { formatAddress } from './format-address.helper';
+
+export type SubGroup = {
+ branchId?: number;
+ branchName?: string;
+ branchAddress?: string;
+ items: RewardSelectionItem[];
+};
+
+const ORDER_TYPES_WITH_BRANCH_GROUPING = ['Abholung', 'Rücklage'] as const;
+
+const sortByProductName = (items: RewardSelectionItem[]): RewardSelectionItem[] => {
+ return [...items].sort((a, b) =>
+ (a.item.product.name ?? '').localeCompare(b.item.product.name ?? ''),
+ );
+};
+
+export const groupByBranch = (
+ orderType: string,
+ items: RewardSelectionItem[],
+): SubGroup[] => {
+ const needsBranchGrouping = ORDER_TYPES_WITH_BRANCH_GROUPING.includes(
+ orderType as (typeof ORDER_TYPES_WITH_BRANCH_GROUPING)[number],
+ );
+
+ if (!needsBranchGrouping) {
+ return [
+ {
+ branchId: undefined,
+ branchName: undefined,
+ branchAddress: undefined,
+ items: sortByProductName(items),
+ },
+ ];
+ }
+
+ const branchGroups = items.reduce((map, item) => {
+ const branchId = item.item.destination?.data?.targetBranch?.id ?? 0;
+ if (!map.has(branchId)) map.set(branchId, []);
+ map.get(branchId)!.push(item);
+ return map;
+ }, new Map());
+
+ return Array.from(branchGroups.entries())
+ .sort(([a], [b]) => a - b)
+ .map(([branchId, branchItems]) => {
+ const branch = branchItems[0]?.item.destination?.data?.targetBranch?.data;
+ return {
+ branchId: branchId || undefined,
+ branchName: branch?.name,
+ branchAddress: formatAddress(branch?.address),
+ items: sortByProductName(branchItems),
+ };
+ });
+};
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-order-type.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-order-type.helper.spec.ts
new file mode 100644
index 000000000..09671129c
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-order-type.helper.spec.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect } from 'vitest';
+import { groupByOrderType } from './group-by-order-type.helper';
+import {
+ RewardSelectionItem,
+ ShoppingCartItem,
+} from '@isa/checkout/data-access';
+
+describe('groupByOrderType', () => {
+ it('should group items by order type', () => {
+ const items: RewardSelectionItem[] = [
+ {
+ item: {
+ features: { orderType: 'Versand' },
+ product: {},
+ } as unknown as ShoppingCartItem,
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ },
+ {
+ item: {
+ features: { orderType: 'Abholung' },
+ product: {},
+ } as unknown as ShoppingCartItem,
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ },
+ {
+ item: {
+ features: { orderType: 'Versand' },
+ product: {},
+ } as unknown as ShoppingCartItem,
+ cartQuantity: 2,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ },
+ ];
+
+ const result = groupByOrderType(items);
+
+ expect(result.size).toBe(2);
+ expect(result.get('Versand')).toHaveLength(2);
+ expect(result.get('Abholung')).toHaveLength(1);
+ });
+
+ it('should handle items without order type feature', () => {
+ const items: RewardSelectionItem[] = [
+ {
+ item: {
+ features: {},
+ product: {},
+ } as unknown as ShoppingCartItem,
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ },
+ ];
+
+ const result = groupByOrderType(items);
+
+ expect(result.size).toBe(1);
+ expect(result.has('Unbekannt')).toBe(true);
+ });
+
+ it('should return empty map for empty items array', () => {
+ const result = groupByOrderType([]);
+
+ expect(result.size).toBe(0);
+ });
+});
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-order-type.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-order-type.helper.ts
new file mode 100644
index 000000000..6287ad968
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-order-type.helper.ts
@@ -0,0 +1,15 @@
+import {
+ getOrderTypeFeature,
+ RewardSelectionItem,
+} from '@isa/checkout/data-access';
+
+export const groupByOrderType = (
+ items: RewardSelectionItem[],
+): Map => {
+ return items.reduce((map, item) => {
+ const orderType = getOrderTypeFeature(item.item.features) ?? 'Unbekannt';
+ if (!map.has(orderType)) map.set(orderType, []);
+ map.get(orderType)!.push(item);
+ return map;
+ }, new Map());
+};
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/item-selection-changed.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/item-selection-changed.helper.spec.ts
new file mode 100644
index 000000000..ecf3ae469
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/item-selection-changed.helper.spec.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect } from 'vitest';
+import { itemSelectionChanged } from './item-selection-changed.helper';
+import { RewardSelectionItem } from '@isa/checkout/data-access';
+
+describe('itemSelectionChanged', () => {
+ it('should return true when result items are undefined', () => {
+ const inputItems: RewardSelectionItem[] = [
+ {
+ item: { product: { ean: '123' } },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ } as RewardSelectionItem,
+ ];
+
+ const result = itemSelectionChanged(inputItems, undefined);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when quantities are unchanged', () => {
+ const inputItems: RewardSelectionItem[] = [
+ {
+ item: { product: { ean: '123' } },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ } as RewardSelectionItem,
+ ];
+
+ const resultItems: RewardSelectionItem[] = [
+ {
+ item: { product: { ean: '123' } },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ } as RewardSelectionItem,
+ ];
+
+ const result = itemSelectionChanged(inputItems, resultItems);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true when cart quantity changed', () => {
+ const inputItems: RewardSelectionItem[] = [
+ {
+ item: { product: { ean: '123' } },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ } as RewardSelectionItem,
+ ];
+
+ const resultItems: RewardSelectionItem[] = [
+ {
+ item: { product: { ean: '123' } },
+ cartQuantity: 2,
+ rewardCartQuantity: 0,
+ } as RewardSelectionItem,
+ ];
+
+ const result = itemSelectionChanged(inputItems, resultItems);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return true when reward cart quantity changed', () => {
+ const inputItems: RewardSelectionItem[] = [
+ {
+ item: { product: { ean: '123' } },
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ } as RewardSelectionItem,
+ ];
+
+ const resultItems: RewardSelectionItem[] = [
+ {
+ item: { product: { ean: '123' } },
+ cartQuantity: 1,
+ rewardCartQuantity: 1,
+ } as RewardSelectionItem,
+ ];
+
+ const result = itemSelectionChanged(inputItems, resultItems);
+
+ expect(result).toBe(true);
+ });
+});
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/item-selection-changed.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/item-selection-changed.helper.ts
new file mode 100644
index 000000000..ea87825be
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/item-selection-changed.helper.ts
@@ -0,0 +1,20 @@
+import { getItemKey, RewardSelectionItem } from '@isa/checkout/data-access';
+
+export const itemSelectionChanged = (
+ inputItems: RewardSelectionItem[],
+ resultItems?: RewardSelectionItem[],
+): boolean => {
+ if (!resultItems) return true;
+
+ return inputItems.some((inputItem) => {
+ const resultItem = resultItems.find(
+ (i) => getItemKey(i.item) === getItemKey(inputItem.item),
+ );
+
+ return (
+ !resultItem ||
+ resultItem.cartQuantity !== inputItem.cartQuantity ||
+ resultItem.rewardCartQuantity !== inputItem.rewardCartQuantity
+ );
+ });
+};
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/merge-reward-selection-items.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/merge-reward-selection-items.helper.spec.ts
new file mode 100644
index 000000000..ce470d2c1
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/merge-reward-selection-items.helper.spec.ts
@@ -0,0 +1,91 @@
+import { describe, it, expect } from 'vitest';
+import { mergeRewardSelectionItems } from './merge-reward-selection-items.helper';
+import { ShoppingCartItem } from '@isa/checkout/data-access';
+
+describe('mergeRewardSelectionItems', () => {
+ it('should merge items from both carts', () => {
+ const shoppingCartItems: ShoppingCartItem[] = [
+ {
+ product: { ean: '123' },
+ quantity: 2,
+ } as ShoppingCartItem,
+ ];
+
+ const rewardShoppingCartItems: ShoppingCartItem[] = [
+ {
+ product: { ean: '123' },
+ quantity: 1,
+ } as ShoppingCartItem,
+ ];
+
+ const result = mergeRewardSelectionItems(
+ shoppingCartItems,
+ rewardShoppingCartItems,
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].cartQuantity).toBe(2);
+ expect(result[0].rewardCartQuantity).toBe(1);
+ });
+
+ it('should handle items only in shopping cart', () => {
+ const shoppingCartItems: ShoppingCartItem[] = [
+ {
+ product: { ean: '123' },
+ quantity: 2,
+ } as ShoppingCartItem,
+ ];
+
+ const rewardShoppingCartItems: ShoppingCartItem[] = [];
+
+ const result = mergeRewardSelectionItems(
+ shoppingCartItems,
+ rewardShoppingCartItems,
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].cartQuantity).toBe(2);
+ expect(result[0].rewardCartQuantity).toBe(0);
+ });
+
+ it('should handle items only in reward cart', () => {
+ const shoppingCartItems: ShoppingCartItem[] = [];
+
+ const rewardShoppingCartItems: ShoppingCartItem[] = [
+ {
+ product: { ean: '456' },
+ quantity: 1,
+ } as ShoppingCartItem,
+ ];
+
+ const result = mergeRewardSelectionItems(
+ shoppingCartItems,
+ rewardShoppingCartItems,
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].cartQuantity).toBe(0);
+ expect(result[0].rewardCartQuantity).toBe(1);
+ });
+
+ it('should handle empty arrays', () => {
+ const result = mergeRewardSelectionItems([], []);
+
+ expect(result).toHaveLength(0);
+ });
+
+ it('should initialize catalog fields as undefined', () => {
+ const shoppingCartItems: ShoppingCartItem[] = [
+ {
+ product: { ean: '123' },
+ quantity: 1,
+ } as ShoppingCartItem,
+ ];
+
+ const result = mergeRewardSelectionItems(shoppingCartItems, []);
+
+ expect(result[0].catalogPrice).toBeUndefined();
+ expect(result[0].availabilityPrice).toBeUndefined();
+ expect(result[0].catalogRewardPoints).toBeUndefined();
+ });
+});
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/merge-reward-selection-items.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/merge-reward-selection-items.helper.ts
new file mode 100644
index 000000000..751d8ba47
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/merge-reward-selection-items.helper.ts
@@ -0,0 +1,61 @@
+import {
+ getItemKey,
+ ShoppingCartItem,
+ RewardSelectionItem,
+} from '@isa/checkout/data-access';
+
+/**
+ * Merges items from regular shopping cart and reward shopping cart.
+ * Items are grouped by EAN, destination, and orderItemType.
+ * If an item exists in both carts with the same key, it will have both
+ * cartQuantity and rewardCartQuantity.
+ *
+ * @param shoppingCartItems - Items from the regular shopping cart
+ * @param rewardShoppingCartItems - Items from the reward shopping cart
+ * @returns Merged array of reward selection items
+ */
+export const mergeRewardSelectionItems = (
+ shoppingCartItems: ShoppingCartItem[],
+ rewardShoppingCartItems: ShoppingCartItem[],
+): RewardSelectionItem[] => {
+ const itemsMap = new Map();
+
+ // Add items from regular shopping cart
+ shoppingCartItems.forEach((shoppingCartItem) => {
+ const key = getItemKey(shoppingCartItem);
+ itemsMap.set(key, {
+ item: shoppingCartItem,
+ cartQuantity: shoppingCartItem.quantity ?? 0,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ });
+ });
+
+ // Merge items from reward shopping cart
+ rewardShoppingCartItems.forEach((shoppingCartItem) => {
+ const key = getItemKey(shoppingCartItem);
+ const existingItem = itemsMap.get(key);
+
+ if (existingItem) {
+ // Item exists in both carts with same EAN, destination, and orderItemType - merge quantities
+ itemsMap.set(key, {
+ ...existingItem,
+ rewardCartQuantity: shoppingCartItem.quantity ?? 0,
+ });
+ } else {
+ // Item only in reward cart or different destination/orderItemType
+ itemsMap.set(key, {
+ item: shoppingCartItem,
+ cartQuantity: 0,
+ rewardCartQuantity: shoppingCartItem.quantity ?? 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ });
+ }
+ });
+
+ return Array.from(itemsMap.values());
+};
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/should-show-grouping.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/should-show-grouping.helper.spec.ts
new file mode 100644
index 000000000..d5348153b
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/should-show-grouping.helper.spec.ts
@@ -0,0 +1,127 @@
+import { describe, it, expect } from 'vitest';
+import { shouldShowGrouping } from './should-show-grouping.helper';
+import {
+ RewardSelectionItem,
+ ShoppingCartItem,
+} from '@isa/checkout/data-access';
+
+describe('shouldShowGrouping', () => {
+ it('should return false for single item', () => {
+ const items: RewardSelectionItem[] = [
+ {
+ item: {
+ features: { orderType: 'Versand' },
+ product: {},
+ } as unknown as ShoppingCartItem,
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ },
+ ];
+
+ const result = shouldShowGrouping(items);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true when items have different order types', () => {
+ const items: RewardSelectionItem[] = [
+ {
+ item: {
+ features: { orderType: 'Versand' },
+ product: {},
+ } as unknown as ShoppingCartItem,
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ },
+ {
+ item: {
+ features: { orderType: 'Abholung' },
+ product: {},
+ } as unknown as ShoppingCartItem,
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ },
+ ];
+
+ const result = shouldShowGrouping(items);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when items have same order type and branch', () => {
+ const items: RewardSelectionItem[] = [
+ {
+ item: {
+ features: { orderType: 'Versand' },
+ product: {},
+ } as unknown as ShoppingCartItem,
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ },
+ {
+ item: {
+ features: { orderType: 'Versand' },
+ product: {},
+ } as unknown as ShoppingCartItem,
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ },
+ ];
+
+ const result = shouldShowGrouping(items);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true for Abholung with different branches', () => {
+ const items: RewardSelectionItem[] = [
+ {
+ item: {
+ features: { orderType: 'Abholung' },
+ destination: {
+ data: { targetBranch: { id: 1 } },
+ },
+ product: {},
+ } as unknown as ShoppingCartItem,
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ },
+ {
+ item: {
+ features: { orderType: 'Abholung' },
+ destination: {
+ data: { targetBranch: { id: 2 } },
+ },
+ product: {},
+ } as unknown as ShoppingCartItem,
+ cartQuantity: 1,
+ rewardCartQuantity: 0,
+ catalogPrice: undefined,
+ availabilityPrice: undefined,
+ catalogRewardPoints: undefined,
+ },
+ ];
+
+ const result = shouldShowGrouping(items);
+
+ expect(result).toBe(true);
+ });
+});
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/should-show-grouping.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/should-show-grouping.helper.ts
new file mode 100644
index 000000000..5f88a60ca
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/should-show-grouping.helper.ts
@@ -0,0 +1,30 @@
+import {
+ getOrderTypeFeature,
+ RewardSelectionItem,
+} from '@isa/checkout/data-access';
+
+const ORDER_TYPES_WITH_BRANCH_DISTINCTION = ['Abholung', 'Rücklage'] as const;
+
+export const shouldShowGrouping = (items: RewardSelectionItem[]): boolean => {
+ if (items.length <= 1) return false;
+
+ // Prüfe ob alle Items denselben OrderType haben
+ const firstOrderType = getOrderTypeFeature(items[0]?.item.features);
+ const hasDifferentOrderTypes = items.some(
+ (item) => getOrderTypeFeature(item.item.features) !== firstOrderType,
+ );
+
+ if (hasDifferentOrderTypes) return true;
+
+ // Wenn alle denselben OrderType haben, prüfe ob verschiedene Branches existieren
+ // (nur relevant für Abholung/Rücklage)
+ if (ORDER_TYPES_WITH_BRANCH_DISTINCTION.includes(firstOrderType as any)) {
+ const firstBranchId = items[0]?.item.destination?.data?.targetBranch?.id;
+ const hasDifferentBranches = items.some(
+ (item) => item.item.destination?.data?.targetBranch?.id !== firstBranchId,
+ );
+ return hasDifferentBranches;
+ }
+
+ return false;
+};
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/resource/price-and-redemption-points.resource.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/resource/price-and-redemption-points.resource.ts
new file mode 100644
index 000000000..0ff082166
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/resource/price-and-redemption-points.resource.ts
@@ -0,0 +1,175 @@
+import { computed, inject, Injectable, resource, signal } from '@angular/core';
+import { AvailabilityService } from '@isa/availability/data-access';
+import {
+ CatalougeSearchService,
+ Price as CatalogPrice,
+} from '@isa/catalogue/data-access';
+import {
+ OrderType,
+ Price as AvailabilityPrice,
+} from '@isa/checkout/data-access';
+import { logger } from '@isa/core/logging';
+
+/**
+ * Input item for availability check - contains EAN, orderType and optional branchId
+ */
+export interface ItemWithOrderType {
+ ean: string;
+ orderType: OrderType;
+ branchId?: number;
+}
+
+/**
+ * Result containing price from availability and redemption points from catalog
+ */
+export interface PriceAndRedemptionPointsResult {
+ ean: string;
+ availabilityPrice?: AvailabilityPrice;
+ catalogPrice?: CatalogPrice;
+ redemptionPoints?: number;
+}
+
+/**
+ * Resource for fetching combined price and redemption points data.
+ *
+ * This resource:
+ * 1. Fetches catalog items by EAN to get redemption points
+ * 2. Groups items by order type
+ * 3. Fetches availability data for each order type group
+ * 4. Combines catalog redemption points with availability prices
+ *
+ * @example
+ * ```typescript
+ * const resource = inject(PriceAndRedemptionPointsResource);
+ *
+ * // Load data for items
+ * resource.loadPriceAndRedemptionPoints([
+ * { ean: '1234567890', orderType: OrderType.Delivery },
+ * { ean: '0987654321', orderType: OrderType.Pickup }
+ * ]);
+ *
+ * // Access results
+ * const results = resource.priceAndRedemptionPoints();
+ * ```
+ */
+@Injectable({ providedIn: 'root' })
+export class PriceAndRedemptionPointsResource {
+ #catalogueSearchService = inject(CatalougeSearchService);
+ #availabilityService = inject(AvailabilityService);
+ #logger = logger(() => ({ resource: 'PriceAndRedemptionPoints' }));
+
+ #items = signal(undefined);
+
+ #priceAndRedemptionPointsResource = resource({
+ params: computed(() => ({ items: this.#items() })),
+ loader: async ({
+ params,
+ abortSignal,
+ }): Promise => {
+ if (!params?.items || params.items.length === 0) {
+ return [];
+ }
+
+ // Extract unique EANs for catalog lookup
+ const eans = [...new Set(params.items.map((item) => item.ean))];
+
+ // Fetch catalog items to get redemption points
+ const catalogItems = await this.#catalogueSearchService.searchByEans(
+ eans,
+ abortSignal,
+ );
+
+ // Create a map for quick catalog lookup by EAN
+ const catalogByEan = new Map(
+ catalogItems.map((item) => [item.product.ean, item]),
+ );
+
+ // Fetch availability for each item individually (in parallel)
+ const availabilityPromises = params.items.map(async (checkItem) => {
+ const catalogItem = catalogByEan.get(checkItem.ean);
+
+ // Skip items without catalog entry
+ if (!catalogItem?.id) {
+ return { ean: checkItem.ean, price: undefined };
+ }
+
+ try {
+ // Call getAvailability for single item
+ // InStore (Rücklage) has different schema: uses itemId instead of item object
+ const params =
+ checkItem.orderType === OrderType.InStore
+ ? {
+ orderType: checkItem.orderType,
+ branchId: checkItem.branchId,
+ itemId: catalogItem.id,
+ }
+ : {
+ orderType: checkItem.orderType,
+ branchId: checkItem.branchId,
+ item: {
+ itemId: catalogItem.id,
+ ean: checkItem.ean,
+ quantity: 1,
+ price: catalogItem.catalogAvailability?.price,
+ },
+ };
+
+ const availability = await this.#availabilityService.getAvailability(
+ params as any,
+ abortSignal,
+ );
+
+ return {
+ ean: checkItem.ean,
+ price: availability?.price as AvailabilityPrice | undefined,
+ };
+ } catch (error) {
+ this.#logger.error(
+ 'Failed to fetch availability for item',
+ error as Error,
+ () => ({
+ ean: checkItem.ean,
+ orderType: checkItem.orderType,
+ branchId: checkItem.branchId,
+ }),
+ );
+ return { ean: checkItem.ean, price: undefined };
+ }
+ });
+
+ // Wait for all availability requests to complete
+ const availabilityResults = await Promise.all(availabilityPromises);
+
+ // Build price map from results
+ const pricesByEan = new Map(
+ availabilityResults.map((result) => [result.ean, result.price]),
+ );
+
+ // Build final result: combine catalog prices, availability prices and redemption points
+ const results: PriceAndRedemptionPointsResult[] = eans.map((ean) => ({
+ ean,
+ availabilityPrice: pricesByEan.get(ean),
+ catalogPrice: catalogByEan.get(ean)?.catalogAvailability?.price,
+ redemptionPoints: catalogByEan.get(ean)?.redemptionPoints,
+ }));
+
+ return results;
+ },
+ defaultValue: [],
+ });
+
+ readonly priceAndRedemptionPoints =
+ this.#priceAndRedemptionPointsResource.value.asReadonly();
+ readonly loading = this.#priceAndRedemptionPointsResource.isLoading;
+ readonly error = computed(
+ () => this.#priceAndRedemptionPointsResource.error()?.message ?? null,
+ );
+
+ loadPriceAndRedemptionPoints(items: ItemWithOrderType[] | undefined) {
+ this.#items.set(items);
+ }
+
+ refresh() {
+ this.#priceAndRedemptionPointsResource.reload();
+ }
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.css b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.css
new file mode 100644
index 000000000..dccd29424
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.css
@@ -0,0 +1,3 @@
+:host {
+ @apply flex flex-row justify-end gap-2 w-full mt-6;
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.html
new file mode 100644
index 000000000..795c77768
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.html
@@ -0,0 +1,22 @@
+
+
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.ts
new file mode 100644
index 000000000..42d6abf92
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.ts
@@ -0,0 +1,61 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ inject,
+ computed,
+ signal,
+} from '@angular/core';
+import { ButtonComponent } from '@isa/ui/buttons';
+import { RewardSelectionStore } from '../store/reward-selection-dialog.store';
+import { RewardSelectionDialogComponent } from '../reward-selection-dialog.component';
+import { RewardSelectionFacade } from '@isa/checkout/data-access';
+import { injectTabId } from '@isa/core/tabs';
+
+@Component({
+ selector: 'lib-reward-selection-actions',
+ templateUrl: './reward-selection-actions.component.html',
+ styleUrl: './reward-selection-actions.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [ButtonComponent],
+})
+export class RewardSelectionActionsComponent {
+ host = inject(RewardSelectionDialogComponent, { host: true });
+ #tabId = injectTabId();
+ #store = inject(RewardSelectionStore);
+ #rewardSelectionFacade = inject(RewardSelectionFacade);
+
+ insufficientLoyaltyPoints = computed(
+ () =>
+ this.#store.totalLoyaltyPointsNeeded() >
+ this.#store.customerRewardPoints(),
+ );
+
+ completeRewardSelectionLoading = signal(false);
+
+ onContinue() {
+ this.host.close(undefined);
+ }
+
+ async onSave() {
+ const tabId = this.#tabId();
+ if (
+ !tabId ||
+ this.insufficientLoyaltyPoints() ||
+ this.completeRewardSelectionLoading()
+ ) {
+ return;
+ }
+
+ this.completeRewardSelectionLoading.set(true);
+ const rewardSelectionItems = Object.values(
+ this.#store.rewardSelectionItems(),
+ );
+ await this.#rewardSelectionFacade.completeRewardSelection({
+ tabId,
+ rewardSelectionItems,
+ });
+ this.completeRewardSelectionLoading.set(false);
+
+ this.host.close({ rewardSelectionItems });
+ }
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.css b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.css
new file mode 100644
index 000000000..e69de29bb
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.html
new file mode 100644
index 000000000..184f70f16
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.html
@@ -0,0 +1,49 @@
+
+
+
+
+
Zahlen mit
+
+
+
+ Lesepunkten
+ {{
+ store.customerRewardPoints()
+ }}
+
+
+
+ Warenkorb
+ EUR
+
+
+
+
+
+
+
+
Gesamt
+
+
+
+ Lesepunkten
+ {{
+ store.totalLoyaltyPointsNeeded()
+ }}
+
+
+
+ Warenkorb
+ {{ store.totalPrice() }} EUR
+
+
+
+
+
+
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.ts
new file mode 100644
index 000000000..d3787eaea
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.ts
@@ -0,0 +1,46 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { DialogContentDirective } from '@isa/ui/dialog';
+import {
+ RewardSelectionFacade,
+ RewardSelectionItem,
+} from '@isa/checkout/data-access';
+import { RewardSelectionItemsComponent } from './reward-selection-items/reward-selection-items.component';
+import { RewardSelectionActionsComponent } from './reward-selection-actions/reward-selection-actions.component';
+import { RewardSelectionErrorComponent } from './reward-selection-error/reward-selection-error.component';
+import { RewardSelectionStore } from './store/reward-selection-dialog.store';
+
+export type RewardSelectionDialogData = {
+ rewardSelectionItems: RewardSelectionItem[];
+ customerRewardPoints: number;
+ closeText: string;
+};
+
+export type RewardSelectionDialogResult =
+ | {
+ rewardSelectionItems: RewardSelectionItem[];
+ }
+ | undefined;
+
+@Component({
+ selector: 'lib-reward-selection-dialog',
+ templateUrl: './reward-selection-dialog.component.html',
+ styleUrl: './reward-selection-dialog.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ RewardSelectionItemsComponent,
+ RewardSelectionActionsComponent,
+ RewardSelectionErrorComponent,
+ ],
+ providers: [RewardSelectionStore, RewardSelectionFacade],
+})
+export class RewardSelectionDialogComponent extends DialogContentDirective<
+ RewardSelectionDialogData,
+ RewardSelectionDialogResult
+> {
+ store = inject(RewardSelectionStore);
+
+ constructor() {
+ super();
+ this.store.initState(this.data);
+ }
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.css b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.css
new file mode 100644
index 000000000..4fe16d287
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.css
@@ -0,0 +1,3 @@
+:host {
+ @apply text-isa-accent-red isa-text-body-2-bold flex flex-row gap-2 items-center;
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.html
new file mode 100644
index 000000000..201f2e798
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.html
@@ -0,0 +1,8 @@
+@if (store.totalLoyaltyPointsNeeded() > store.customerRewardPoints()) {
+
+ Lesepunkte reichen nicht für alle Artikel
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.ts
new file mode 100644
index 000000000..451f02766
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.ts
@@ -0,0 +1,16 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import { isaOtherInfo } from '@isa/icons';
+import { RewardSelectionStore } from '../store/reward-selection-dialog.store';
+
+@Component({
+ selector: 'lib-reward-selection-error',
+ templateUrl: './reward-selection-error.component.html',
+ styleUrl: './reward-selection-error.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [NgIcon],
+ providers: [provideIcons({ isaOtherInfo })],
+})
+export class RewardSelectionErrorComponent {
+ store = inject(RewardSelectionStore);
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.css b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.css
new file mode 100644
index 000000000..e65860fce
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.css
@@ -0,0 +1,3 @@
+:host {
+ @apply flex flex-row gap-3;
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.html
new file mode 100644
index 000000000..3fcfa451b
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.html
@@ -0,0 +1,29 @@
+
+
{{ loyaltyValue() }} LP
+
+
+
+
+
+
{{ priceValue() }} EUR
+
+
+
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.ts
new file mode 100644
index 000000000..6ee18471e
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.ts
@@ -0,0 +1,74 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ inject,
+ computed,
+} from '@angular/core';
+import { RewardSelectionItemComponent } from '../reward-selection-item.component';
+import { RewardSelectionStore } from '../../../store/reward-selection-dialog.store';
+import { getOrderTypeFeature, OrderType } from '@isa/checkout/data-access';
+import { calculatePriceValue } from '../../../helper/get-price.helper';
+import { calculateLoyaltyPointsValue } from '../../../helper/get-loyalty-points.helper';
+import { QuantityControlComponent } from '@isa/shared/quantity-control';
+import { FormsModule } from '@angular/forms';
+
+@Component({
+ selector: 'lib-reward-selection-inputs',
+ templateUrl: './reward-selection-inputs.component.html',
+ styleUrl: './reward-selection-inputs.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [FormsModule, QuantityControlComponent],
+})
+export class RewardSelectionInputsComponent {
+ store = inject(RewardSelectionStore);
+ rewardSelectionItem = inject(RewardSelectionItemComponent, { host: true })
+ .rewardSelectionItem;
+
+ loyaltyValue = computed(() =>
+ calculateLoyaltyPointsValue(this.rewardSelectionItem()),
+ );
+
+ priceValue = computed(() => calculatePriceValue(this.rewardSelectionItem()));
+
+ rewardCartQuantity = computed(
+ () => this.rewardSelectionItem()?.rewardCartQuantity ?? 0,
+ );
+
+ cartQuantity = computed(() => this.rewardSelectionItem()?.cartQuantity ?? 0);
+
+ // Rücklage
+ orderTypeIsInStore = computed(() => {
+ const item = this.rewardSelectionItem().item;
+ return getOrderTypeFeature(item.features) === OrderType.InStore;
+ });
+
+ hasStock = computed(() => {
+ const item = this.rewardSelectionItem().item;
+ if (this.orderTypeIsInStore()) {
+ return !!item?.availability?.inStock && item.availability.inStock > 0;
+ }
+ return true;
+ });
+
+ maxQuantity = computed(() => {
+ const item = this.rewardSelectionItem().item;
+ if (this.orderTypeIsInStore()) {
+ return item?.availability?.inStock;
+ }
+ return undefined;
+ });
+
+ changeCartQuantity(quantity: number) {
+ this.store.updateItemCartQuantity(
+ this.rewardSelectionItem().item.id!,
+ quantity,
+ );
+ }
+
+ changeRewardCartQuantity(quantity: number) {
+ this.store.updateItemRewardCartQuantity(
+ this.rewardSelectionItem().item.id!,
+ quantity,
+ );
+ }
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.css b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.css
new file mode 100644
index 000000000..06ba4d420
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.css
@@ -0,0 +1,3 @@
+:host {
+ @apply w-full flex flex-col gap-4;
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.html
new file mode 100644
index 000000000..e80c67070
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.html
@@ -0,0 +1,29 @@
+
+
+
+
![]()
+
+
+
+
+ {{ rewardSelectionItem().item.product.contributors }}
+
+
+ {{ rewardSelectionItem().item.product.name }}
+
+
+
+
+
+
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.ts
new file mode 100644
index 000000000..da5891cdf
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.ts
@@ -0,0 +1,20 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { ProductImageDirective } from '@isa/shared/product-image';
+import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
+import { RewardSelectionInputsComponent } from './reward-selection-inputs/reward-selection-inputs.component';
+import { RewardSelectionItem } from '@isa/checkout/data-access';
+
+@Component({
+ selector: 'lib-reward-selection-item',
+ templateUrl: './reward-selection-item.component.html',
+ styleUrl: './reward-selection-item.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ ProductImageDirective,
+ ProductRouterLinkDirective,
+ RewardSelectionInputsComponent,
+ ],
+})
+export class RewardSelectionItemComponent {
+ rewardSelectionItem = input.required();
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.css b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.css
new file mode 100644
index 000000000..ca97fc217
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.css
@@ -0,0 +1,19 @@
+:host {
+ @apply grid grid-flow-row w-full items-center bg-isa-white max-h-96 overflow-hidden overflow-y-scroll;
+}
+
+.item-group-header {
+ @apply flex flex-row items-center py-3 px-5 gap-3 bg-isa-neutral-100 rounded-t-2xl;
+}
+
+.label {
+ @apply isa-text-body-1-bold text-isa-neutral-900;
+}
+
+.branch-header {
+ @apply flex flex-row items-center px-5 py-3 bg-isa-neutral-100;
+}
+
+.branch-name {
+ @apply isa-text-body-2-bold text-isa-neutral-900;
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.html
new file mode 100644
index 000000000..0167b23b3
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.html
@@ -0,0 +1,39 @@
+@if (showGrouping()) {
+ @for (group of groupedItems(); track group.orderType; let last = $last) {
+
+
+ @for (
+ subGroup of group.subGroups;
+ track subGroup.branchId ?? $index;
+ let isFirst = $first
+ ) {
+ @if (subGroup.branchName) {
+
+ }
+
+ @for (item of subGroup.items; track item.item.id; let isLast = $last) {
+
+ @if (!isLast) {
+
+ }
+ }
+ }
+ }
+} @else {
+ @for (item of items(); track item.item.id; let last = $last) {
+
+ @if (!last) {
+
+ }
+ }
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.ts
new file mode 100644
index 000000000..5d1aedea3
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.ts
@@ -0,0 +1,52 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ inject,
+} from '@angular/core';
+import { RewardSelectionItemComponent } from './reward-selection-item/reward-selection-item.component';
+import { RewardSelectionStore } from '../store/reward-selection-dialog.store';
+import { NgIcon, provideIcons } from '@ng-icons/core';
+import {
+ isaDeliveryVersand,
+ isaDeliveryRuecklage2,
+ isaDeliveryRuecklage1,
+ isaDeliveryB2BVersand1,
+} from '@isa/icons';
+import { getOrderTypeIcon } from '../helper/get-order-type-icon.helper';
+import { groupByOrderType } from '../helper/group-by-order-type.helper';
+import { groupByBranch } from '../helper/group-by-branch.helper';
+import { shouldShowGrouping } from '../helper/should-show-grouping.helper';
+
+@Component({
+ selector: 'lib-reward-selection-items',
+ templateUrl: './reward-selection-items.component.html',
+ styleUrl: './reward-selection-items.component.css',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [RewardSelectionItemComponent, NgIcon],
+ providers: [
+ provideIcons({
+ isaDeliveryVersand,
+ isaDeliveryRuecklage2,
+ isaDeliveryRuecklage1,
+ isaDeliveryB2BVersand1,
+ }),
+ ],
+})
+export class RewardSelectionItemsComponent {
+ store = inject(RewardSelectionStore);
+
+ items = computed(() => Object.values(this.store.rewardSelectionItems()));
+ showGrouping = computed(() => shouldShowGrouping(this.items()));
+
+ groupedItems = computed(() => {
+ if (!this.showGrouping()) return [];
+
+ const groups = groupByOrderType(this.items());
+ return Array.from(groups.entries()).map(([orderType, items]) => ({
+ orderType,
+ icon: getOrderTypeIcon(orderType),
+ subGroups: groupByBranch(orderType, items),
+ }));
+ });
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/service/reward-selection-pop-up.service.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/service/reward-selection-pop-up.service.ts
new file mode 100644
index 000000000..9cacaa9e1
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/service/reward-selection-pop-up.service.ts
@@ -0,0 +1,162 @@
+import { inject, Injectable } from '@angular/core';
+import { injectConfirmationDialog, injectFeedbackDialog } from '@isa/ui/dialog';
+import { firstValueFrom } from 'rxjs';
+import { RewardSelectionService } from './reward-selection.service';
+import { CheckoutMetadataService } from '@isa/checkout/data-access';
+import { injectTabId } from '@isa/core/tabs';
+
+export const NavigateAfterRewardSelection = {
+ CART: 'cart',
+ REWARD: 'reward',
+ CATALOG: 'catalog',
+} as const;
+export type NavigateAfterRewardSelection =
+ (typeof NavigateAfterRewardSelection)[keyof typeof NavigateAfterRewardSelection];
+
+@Injectable()
+export class RewardSelectionPopUpService {
+ #tabId = injectTabId();
+ #feedbackDialog = injectFeedbackDialog();
+ #confirmationDialog = injectConfirmationDialog();
+ #rewardSelectionService = inject(RewardSelectionService);
+ #checkoutMetadataService = inject(CheckoutMetadataService);
+
+ /**
+ * Displays the reward selection popup dialog if conditions are met.
+ *
+ * This method manages the complete flow of the reward selection popup:
+ * 1. Checks if the popup has already been shown in the current tab (prevents duplicate displays)
+ * 2. Reloads necessary resources for the reward selection dialog
+ * 3. Opens the reward selection dialog if conditions allow
+ * 4. Marks the popup as opened for the current tab using {@link #setPopUpOpenedState}
+ * 5. Processes user selections and determines navigation flow
+ *
+ * @returns A promise that resolves to:
+ * - `NavigateAfterRewardSelection.CART` - Navigate to the shopping cart
+ * - `NavigateAfterRewardSelection.REWARD` - Navigate to the reward cart
+ * - `NavigateAfterRewardSelection.CATALOG` - Navigate back to the catalog
+ * - `undefined` - Stay on the current page (e.g., when all quantities are set to 0)
+ *
+ * @example
+ * ```typescript
+ * const result = await rewardSelectionPopUpService.popUp();
+ * if (result === NavigateAfterRewardSelection.CART) {
+ * this.router.navigate(['/cart']);
+ * }
+ * ```
+ */
+ async popUp(): Promise {
+ if (this.#popUpAlreadyOpened(this.#tabId())) {
+ return NavigateAfterRewardSelection.CART;
+ }
+
+ await this.#rewardSelectionService.reloadResources();
+
+ if (this.#rewardSelectionService.canOpen()) {
+ const dialogResult = await this.#rewardSelectionService.open({
+ closeText: 'Weiter einkaufen',
+ });
+
+ this.#setPopUpOpenedState(this.#tabId(), true);
+
+ if (dialogResult) {
+ // Wenn der User alles 0 setzt, bleibt man einfach auf der aktuellen Seite
+ if (dialogResult?.rewardSelectionItems?.length === 0) {
+ await this.#feedback();
+ return undefined;
+ }
+
+ if (dialogResult.rewardSelectionItems?.length > 0) {
+ const hasRegularCartItems = dialogResult.rewardSelectionItems?.some(
+ (item) => item?.cartQuantity > 0,
+ );
+ const hasRewardCartItems = dialogResult.rewardSelectionItems?.some(
+ (item) => item?.rewardCartQuantity > 0,
+ );
+
+ return await this.#confirmDialog(
+ hasRegularCartItems,
+ hasRewardCartItems,
+ );
+ }
+ }
+ }
+
+ return NavigateAfterRewardSelection.CART;
+ }
+
+ async #feedback() {
+ this.#feedbackDialog({
+ data: { message: 'Auswahl gespeichert' },
+ });
+ }
+
+ async #confirmDialog(
+ hasRegularCartItems: boolean,
+ hasRewardCartItems: boolean,
+ ): Promise {
+ const title = hasRewardCartItems
+ ? 'Artikel wurde der Prämienausgabe hinzugefügt'
+ : 'Artikel wurde zum Warenkorb hinzugefügt';
+
+ const message = hasRegularCartItems
+ ? 'Bitte schließen sie erst den Warenkorb ab und dann die Prämienausgabe'
+ : hasRegularCartItems && !hasRewardCartItems
+ ? 'Bitte schließen sie den Warenkorb ab'
+ : '';
+
+ const dialogRef = this.#confirmationDialog({
+ title,
+ data: {
+ message,
+ closeText: 'Weiter einkaufen',
+ confirmText: hasRegularCartItems
+ ? 'Zum Warenkorb'
+ : 'Zur Prämienausgabe',
+ },
+ });
+
+ const dialogResult = await firstValueFrom(dialogRef.closed);
+
+ if (dialogResult) {
+ if (dialogResult.confirmed && hasRegularCartItems) {
+ return NavigateAfterRewardSelection.CART;
+ } else if (dialogResult.confirmed) {
+ return NavigateAfterRewardSelection.REWARD;
+ } else {
+ return NavigateAfterRewardSelection.CATALOG;
+ }
+ }
+
+ return undefined;
+ }
+
+ #popUpAlreadyOpened(tabId: number | null): boolean | undefined {
+ if (tabId == null) return;
+ return this.#checkoutMetadataService.getRewardSelectionPopupOpenedState(
+ tabId,
+ );
+ }
+
+ /**
+ * Sets the opened state of the reward selection popup for a specific tab.
+ *
+ * This method persists the popup state to prevent the popup from being displayed
+ * multiple times within the same tab session. It's called after successfully
+ * opening the reward selection dialog.
+ *
+ * @param tabId - The unique identifier of the tab. If null, the method returns early without setting state.
+ * @param opened - The opened state to set. `true` indicates the popup has been shown, `false` or `undefined` resets the state.
+ *
+ * @remarks
+ * This state is typically set to `true` after the user has seen the popup to ensure
+ * it doesn't appear again during the same browsing session in that tab.
+ */
+ #setPopUpOpenedState(tabId: number | null, opened: boolean | undefined) {
+ if (tabId == null) return;
+ this.#checkoutMetadataService.setRewardSelectionPopupOpenedState(
+ tabId,
+ opened,
+ );
+ }
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/service/reward-selection.service.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/service/reward-selection.service.ts
new file mode 100644
index 000000000..916046177
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/service/reward-selection.service.ts
@@ -0,0 +1,204 @@
+import { computed, effect, inject, Injectable, untracked } from '@angular/core';
+import {
+ SelectedRewardShoppingCartResource,
+ SelectedShoppingCartResource,
+ ShoppingCartItem,
+ getOrderTypeFeature,
+} from '@isa/checkout/data-access';
+import { injectDialog } from '@isa/ui/dialog';
+import {
+ RewardSelectionDialogComponent,
+ RewardSelectionDialogResult,
+} from '../reward-selection-dialog.component';
+import {
+ getPrimaryBonusCard,
+ SelectedCustomerBonusCardsResource,
+} from '@isa/crm/data-access';
+import { firstValueFrom } from 'rxjs';
+import { RewardSelectionItem } from '@isa/checkout/data-access';
+import { toObservable } from '@angular/core/rxjs-interop';
+import { filter, first } from 'rxjs/operators';
+import { itemSelectionChanged } from '../helper/item-selection-changed.helper';
+import { mergeRewardSelectionItems } from '../helper/merge-reward-selection-items.helper';
+import {
+ PriceAndRedemptionPointsResource,
+ ItemWithOrderType,
+} from '../resource/price-and-redemption-points.resource';
+
+@Injectable()
+export class RewardSelectionService {
+ priceAndRedemptionPointsResource = inject(PriceAndRedemptionPointsResource);
+ rewardSelectionDialog = injectDialog(RewardSelectionDialogComponent);
+
+ #customerBonusCardsResource = inject(SelectedCustomerBonusCardsResource)
+ .resource;
+ #shoppingCartResource = inject(SelectedShoppingCartResource).resource;
+ #rewardShoppingCartResource = inject(SelectedRewardShoppingCartResource)
+ .resource;
+
+ readonly shoppingCartResponseValue =
+ this.#shoppingCartResource.value.asReadonly();
+
+ readonly rewardShoppingCartResponseValue =
+ this.#rewardShoppingCartResource.value.asReadonly();
+
+ readonly customerCardResponseValue =
+ this.#customerBonusCardsResource.value.asReadonly();
+
+ readonly priceAndRedemptionPoints =
+ this.priceAndRedemptionPointsResource.priceAndRedemptionPoints;
+
+ shoppingCartItems = computed(() => {
+ return (
+ this.shoppingCartResponseValue()
+ ?.items?.map((item) => item?.data as ShoppingCartItem)
+ .filter((item): item is ShoppingCartItem => item != null) ?? []
+ );
+ });
+
+ rewardShoppingCartItems = computed(() => {
+ return (
+ this.rewardShoppingCartResponseValue()
+ ?.items?.map((item) => item?.data as ShoppingCartItem)
+ .filter((item): item is ShoppingCartItem => item != null) ?? []
+ );
+ });
+
+ mergedRewardSelectionItems = computed(() => {
+ return mergeRewardSelectionItems(
+ this.shoppingCartItems(),
+ this.rewardShoppingCartItems(),
+ );
+ });
+
+ selectionItemsWithOrderType = computed(() => {
+ return this.mergedRewardSelectionItems()
+ .map((item): ItemWithOrderType | null => {
+ const ean = item.item.product.ean;
+ const orderType = getOrderTypeFeature(item.item.features);
+ const branchId = item.item.destination?.data?.targetBranch?.data?.id;
+
+ if (!ean || !orderType) {
+ return null;
+ }
+
+ return { ean, orderType, branchId };
+ })
+ .filter((item): item is ItemWithOrderType => item !== null);
+ });
+
+ updatedRewardSelectionItems = computed(() => {
+ const rewardSelectionItems = this.mergedRewardSelectionItems();
+ const priceAndRedemptionResults = this.priceAndRedemptionPoints();
+
+ return rewardSelectionItems.map((selectionItem) => {
+ const ean = selectionItem.item.product.ean;
+ const result = priceAndRedemptionResults?.find((r) => r.ean === ean);
+
+ return {
+ ...selectionItem,
+ catalogPrice: result?.catalogPrice,
+ catalogRewardPoints: result?.redemptionPoints,
+ availabilityPrice: result?.availabilityPrice,
+ };
+ });
+ });
+
+ bonusCards = computed(() => {
+ return this.customerCardResponseValue() ?? [];
+ });
+
+ primaryBonusCard = computed(() => getPrimaryBonusCard(this.bonusCards()));
+ primaryBonusCardPoints = computed(
+ () => this.primaryBonusCard()?.totalPoints ?? 0,
+ );
+
+ isLoading = computed(
+ () =>
+ this.#shoppingCartResource.isLoading() ||
+ this.#rewardShoppingCartResource.isLoading() ||
+ this.#customerBonusCardsResource.isLoading() ||
+ this.priceAndRedemptionPointsResource.loading(),
+ );
+ #isLoading$ = toObservable(this.isLoading);
+
+ eligibleItems = computed(() =>
+ this.updatedRewardSelectionItems().filter(
+ (selectionItem) =>
+ (selectionItem.item.loyalty?.value != null &&
+ selectionItem.item.loyalty.value !== 0) ||
+ (selectionItem?.catalogRewardPoints != null &&
+ selectionItem.catalogRewardPoints !== 0),
+ ),
+ );
+
+ canOpen = computed(
+ () => this.eligibleItems().length > 0 && !!this.primaryBonusCard(),
+ );
+
+ constructor() {
+ effect(() => {
+ const items = this.selectionItemsWithOrderType();
+
+ untracked(() => {
+ const resourceLoading = this.priceAndRedemptionPointsResource.loading();
+ if (!resourceLoading && items.length > 0) {
+ this.priceAndRedemptionPointsResource.loadPriceAndRedemptionPoints(
+ items,
+ );
+ }
+ });
+ });
+ }
+
+ async open({
+ closeText,
+ }: {
+ closeText: string;
+ }): Promise {
+ const rewardSelectionItems = this.eligibleItems();
+ console.log(rewardSelectionItems);
+ const dialogRef = this.rewardSelectionDialog({
+ title: 'Ein oder mehrere Artikel sind als Prämie verfügbar',
+ data: {
+ rewardSelectionItems,
+ customerRewardPoints: this.primaryBonusCardPoints(),
+ closeText,
+ },
+ displayClose: true,
+ disableClose: false,
+ width: '44.5rem',
+ });
+ const dialogResult = await firstValueFrom(dialogRef.closed);
+
+ if (
+ itemSelectionChanged(
+ rewardSelectionItems,
+ dialogResult?.rewardSelectionItems,
+ )
+ ) {
+ return dialogResult;
+ }
+
+ return undefined;
+ }
+
+ async reloadResources(): Promise {
+ // Start reloading all resources
+ // Note: Price and redemption points will be loaded automatically by the effect
+ // when selectionItemsWithOrderType changes after cart resources are reloaded
+ await Promise.all([
+ this.#shoppingCartResource.reload(),
+ this.#rewardShoppingCartResource.reload(),
+ this.#customerBonusCardsResource.reload(),
+ ]);
+
+ // Wait until all resources are fully loaded (isLoading becomes false)
+ await firstValueFrom(
+ this.#isLoading$.pipe(
+ filter((isLoading) => !isLoading),
+ first(),
+ ),
+ );
+ }
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/store/reward-selection-dialog.store.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/store/reward-selection-dialog.store.ts
new file mode 100644
index 000000000..0828d98d4
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/store/reward-selection-dialog.store.ts
@@ -0,0 +1,106 @@
+import {
+ patchState,
+ signalStore,
+ withComputed,
+ withMethods,
+ withState,
+} from '@ngrx/signals';
+import { RewardSelectionItem } from '@isa/checkout/data-access';
+import { computed } from '@angular/core';
+import { calculatePriceValue } from '../helper/get-price.helper';
+import { calculateLoyaltyPointsValue } from '../helper/get-loyalty-points.helper';
+
+interface RewardSelectionState {
+ // Single source of truth for all items with their allocations
+ rewardSelectionItems: Record;
+ customerRewardPoints: number;
+}
+
+const initialState: RewardSelectionState = {
+ rewardSelectionItems: {},
+ customerRewardPoints: 0,
+};
+
+export const RewardSelectionStore = signalStore(
+ withState(initialState),
+ withComputed((store) => ({
+ // Calculate total loyalty points needed for all selected reward items
+ totalLoyaltyPointsNeeded: computed(() =>
+ Object.values(store.rewardSelectionItems()).reduce(
+ (sum, item) =>
+ sum +
+ calculateLoyaltyPointsValue(item) * (item?.rewardCartQuantity ?? 0),
+ 0,
+ ),
+ ),
+
+ // Calculate total price for all selected items
+ totalPrice: computed(() =>
+ Object.values(store.rewardSelectionItems()).reduce(
+ (sum, item) =>
+ sum + calculatePriceValue(item) * (item?.cartQuantity ?? 0),
+ 0,
+ ),
+ ),
+ })),
+ withMethods((store) => ({
+ /**
+ * Initialize store with cart and reward items
+ */
+ initState({
+ rewardSelectionItems,
+ customerRewardPoints,
+ }: {
+ rewardSelectionItems: RewardSelectionItem[];
+ customerRewardPoints: number;
+ }) {
+ const rewardSelectionItemsMapped = rewardSelectionItems.reduce(
+ (acc, item) => {
+ const itemId = item.item.id;
+ if (itemId !== undefined) {
+ acc[itemId] = item;
+ }
+ return acc;
+ },
+ {} as Record,
+ );
+
+ patchState(store, {
+ rewardSelectionItems: rewardSelectionItemsMapped,
+ customerRewardPoints,
+ });
+ },
+
+ /**
+ * Update the quantity of an item in the regular cart
+ */
+ updateItemCartQuantity(itemId: number, quantity: number) {
+ const currentItem = store.rewardSelectionItems()[itemId];
+ patchState(store, {
+ rewardSelectionItems: {
+ ...store.rewardSelectionItems(),
+ [itemId]: {
+ ...currentItem,
+ cartQuantity: quantity,
+ },
+ },
+ });
+ },
+
+ /**
+ * Update the quantity of an item in the reward cart
+ */
+ updateItemRewardCartQuantity(itemId: number, quantity: number) {
+ const currentItem = store.rewardSelectionItems()[itemId];
+ patchState(store, {
+ rewardSelectionItems: {
+ ...store.rewardSelectionItems(),
+ [itemId]: {
+ ...currentItem,
+ rewardCartQuantity: quantity,
+ },
+ },
+ });
+ },
+ })),
+);
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/trigger/reward-selection-trigger.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/trigger/reward-selection-trigger.component.html
new file mode 100644
index 000000000..898aeb790
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/trigger/reward-selection-trigger.component.html
@@ -0,0 +1,12 @@
+@if (canOpen() || isLoading()) {
+
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/trigger/reward-selection-trigger.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/trigger/reward-selection-trigger.component.ts
new file mode 100644
index 000000000..9077239e9
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/trigger/reward-selection-trigger.component.ts
@@ -0,0 +1,97 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { TextButtonComponent } from '@isa/ui/buttons';
+import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
+import { injectTabId } from '@isa/core/tabs';
+import { DomainCheckoutService } from '@domain/checkout';
+import { injectFeedbackDialog } from '@isa/ui/dialog';
+import { RewardSelectionService } from '../service/reward-selection.service';
+import { Router } from '@angular/router';
+import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
+import {
+ SelectedRewardShoppingCartResource,
+ SelectedShoppingCartResource,
+} from '@isa/checkout/data-access';
+import { CheckoutNavigationService } from '@shared/services/navigation';
+
+@Component({
+ selector: 'lib-reward-selection-trigger',
+ templateUrl: './reward-selection-trigger.component.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [TextButtonComponent, SkeletonLoaderDirective],
+ providers: [
+ SelectedShoppingCartResource,
+ SelectedRewardShoppingCartResource,
+ SelectedCustomerBonusCardsResource,
+ RewardSelectionService,
+ ],
+})
+export class RewardSelectionTriggerComponent {
+ #router = inject(Router);
+ #tabId = injectTabId();
+ #feedbackDialog = injectFeedbackDialog();
+ #rewardSelectionService = inject(RewardSelectionService);
+ #domainCheckoutService = inject(DomainCheckoutService);
+ #checkoutNavigationService = inject(CheckoutNavigationService);
+
+ canOpen = this.#rewardSelectionService.canOpen;
+ isLoading = this.#rewardSelectionService.isLoading;
+
+ async openRewardSelectionDialog() {
+ const tabId = this.#tabId();
+ const dialogResult = await this.#rewardSelectionService.open({
+ closeText: 'Abbrechen',
+ });
+
+ if (dialogResult && tabId) {
+ await this.#reloadShoppingCart(tabId);
+
+ // Wenn der User alles 0 setzt, bleibt man einfach auf der aktuellen Seite
+ if (dialogResult.rewardSelectionItems?.length === 0) {
+ await this.#feedback();
+ }
+
+ if (dialogResult.rewardSelectionItems?.length > 0) {
+ const hasRegularCartItems = dialogResult.rewardSelectionItems?.some(
+ (item) => item?.cartQuantity > 0,
+ );
+ const hasRewardCartItems = dialogResult.rewardSelectionItems?.some(
+ (item) => item?.rewardCartQuantity > 0,
+ );
+
+ await this.#feedback();
+
+ // Wenn Nutzer im Warenkorb ist und alle Items als Prämie setzt -> Navigation zum Prämien Checkout
+ if (!hasRegularCartItems && hasRewardCartItems) {
+ await this.#navigateToRewardCheckout(tabId);
+ }
+
+ // Wenn Nutzer im Prämien Checkout ist und alle Items in den Warenkorb setzt -> Navigation zu Warenkorb
+ if (hasRegularCartItems && !hasRewardCartItems) {
+ await this.#navigateToCheckout(tabId);
+ }
+ }
+ }
+ }
+
+ async #reloadShoppingCart(tabId: number) {
+ await this.#domainCheckoutService.reloadShoppingCart({
+ processId: tabId,
+ });
+ }
+
+ async #feedback() {
+ this.#feedbackDialog({
+ data: { message: 'Auswahl gespeichert' },
+ });
+ }
+
+ async #navigateToRewardCheckout(tabId: number) {
+ await this.#router.navigate([`/${tabId}`, 'reward', 'cart']);
+ }
+
+ async #navigateToCheckout(tabId: number) {
+ await this.#checkoutNavigationService
+ .getCheckoutReviewPath(tabId)
+ .navigate();
+ }
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/src/test-setup.ts b/libs/checkout/shared/reward-selection-dialog/src/test-setup.ts
new file mode 100644
index 000000000..cebf5ae72
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/src/test-setup.ts
@@ -0,0 +1,13 @@
+import '@angular/compiler';
+import '@analogjs/vitest-angular/setup-zone';
+
+import {
+ BrowserTestingModule,
+ platformBrowserTesting,
+} from '@angular/platform-browser/testing';
+import { getTestBed } from '@angular/core/testing';
+
+getTestBed().initTestEnvironment(
+ BrowserTestingModule,
+ platformBrowserTesting(),
+);
diff --git a/libs/checkout/shared/reward-selection-dialog/tsconfig.json b/libs/checkout/shared/reward-selection-dialog/tsconfig.json
new file mode 100644
index 000000000..06f8b89a6
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "extends": "../../../../tsconfig.base.json",
+ "compilerOptions": {
+ "importHelpers": true,
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "preserve"
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "typeCheckHostBindings": true,
+ "strictTemplates": true
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.lib.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ]
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/tsconfig.lib.json b/libs/checkout/shared/reward-selection-dialog/tsconfig.lib.json
new file mode 100644
index 000000000..9259117c2
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/tsconfig.lib.json
@@ -0,0 +1,27 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../../dist/out-tsc",
+ "declaration": true,
+ "declarationMap": true,
+ "inlineSources": true,
+ "types": []
+ },
+ "exclude": [
+ "src/**/*.spec.ts",
+ "src/test-setup.ts",
+ "jest.config.ts",
+ "src/**/*.test.ts",
+ "vite.config.ts",
+ "vite.config.mts",
+ "vitest.config.ts",
+ "vitest.config.mts",
+ "src/**/*.test.tsx",
+ "src/**/*.spec.tsx",
+ "src/**/*.test.js",
+ "src/**/*.spec.js",
+ "src/**/*.test.jsx",
+ "src/**/*.spec.jsx"
+ ],
+ "include": ["src/**/*.ts"]
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/tsconfig.spec.json b/libs/checkout/shared/reward-selection-dialog/tsconfig.spec.json
new file mode 100644
index 000000000..b2f92f3ec
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/tsconfig.spec.json
@@ -0,0 +1,29 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../../dist/out-tsc",
+ "types": [
+ "vitest/globals",
+ "vitest/importMeta",
+ "vite/client",
+ "node",
+ "vitest"
+ ]
+ },
+ "include": [
+ "vite.config.ts",
+ "vite.config.mts",
+ "vitest.config.ts",
+ "vitest.config.mts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.test.tsx",
+ "src/**/*.spec.tsx",
+ "src/**/*.test.js",
+ "src/**/*.spec.js",
+ "src/**/*.test.jsx",
+ "src/**/*.spec.jsx",
+ "src/**/*.d.ts"
+ ],
+ "files": ["src/test-setup.ts"]
+}
diff --git a/libs/checkout/shared/reward-selection-dialog/vite.config.mts b/libs/checkout/shared/reward-selection-dialog/vite.config.mts
new file mode 100644
index 000000000..d5e79cbb4
--- /dev/null
+++ b/libs/checkout/shared/reward-selection-dialog/vite.config.mts
@@ -0,0 +1,29 @@
+///
+import { defineConfig } from 'vite';
+import angular from '@analogjs/vite-plugin-angular';
+import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
+import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
+
+export default defineConfig(() => ({
+ root: __dirname,
+ cacheDir:
+ '../../../../node_modules/.vite/libs/checkout/shared/reward-selection-dialog',
+ plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
+ // Uncomment this if you are using workers.
+ // worker: {
+ // plugins: [ nxViteTsPaths() ],
+ // },
+ test: {
+ watch: false,
+ globals: true,
+ environment: 'jsdom',
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
+ setupFiles: ['src/test-setup.ts'],
+ reporters: ['default'],
+ coverage: {
+ reportsDirectory:
+ '../../../../coverage/libs/checkout/shared/reward-selection-dialog',
+ provider: 'v8' as const,
+ },
+ },
+}));
diff --git a/libs/oms/feature/return-process/src/lib/return-process-questions/return-process-product-question/return-process-product-question.component.ts b/libs/oms/feature/return-process/src/lib/return-process-questions/return-process-product-question/return-process-product-question.component.ts
index 6d4197100..597cca99a 100644
--- a/libs/oms/feature/return-process/src/lib/return-process-questions/return-process-product-question/return-process-product-question.component.ts
+++ b/libs/oms/feature/return-process/src/lib/return-process-questions/return-process-product-question/return-process-product-question.component.ts
@@ -26,6 +26,7 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { filter, pipe, switchMap, tap } from 'rxjs';
import { CatalougeSearchService } from '@isa/catalogue/data-access';
import { tapResponse } from '@ngrx/operators';
+import { from } from 'rxjs';
import { ProductImageDirective } from '@isa/shared/product-image';
import { provideIcons } from '@ng-icons/core';
import { isaActionScanner } from '@isa/icons';
@@ -118,8 +119,10 @@ export class ReturnProcessProductQuestionComponent {
this.status.set({ fetching: true, hasResult: undefined });
}),
switchMap(() =>
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- this.#catalogueSearchService.searchByEans(this.control.value!).pipe(
+
+ from(
+ this.#catalogueSearchService.searchByEans([this.control.value!]),
+ ).pipe(
tapResponse({
next: (value) => {
if (!!value[0] && value[0].product) {
diff --git a/libs/shared/quantity-control/src/lib/quantity-control.component.spec.ts b/libs/shared/quantity-control/src/lib/quantity-control.component.spec.ts
index 2ac5b5b09..b4909c4e1 100644
--- a/libs/shared/quantity-control/src/lib/quantity-control.component.spec.ts
+++ b/libs/shared/quantity-control/src/lib/quantity-control.component.spec.ts
@@ -9,7 +9,10 @@ describe('QuantityControlComponent', () => {
let component: QuantityControlComponent;
let fixture: ComponentFixture;
let liveAnnouncerMock: { announce: ReturnType };
- let tooltipMock: { show: ReturnType; hide: ReturnType };
+ let tooltipMock: {
+ show: ReturnType;
+ hide: ReturnType;
+ };
beforeEach(async () => {
liveAnnouncerMock = {
@@ -43,16 +46,16 @@ describe('QuantityControlComponent', () => {
expect(component).toBeTruthy();
});
- it('should have default value of 1', () => {
- expect(component.value()).toBe(1);
+ it('should have default value of 0', () => {
+ expect(component.value()).toBe(0);
});
it('should not be disabled by default', () => {
expect(component.disabled()).toBe(false);
});
- it('should have default min of 1', () => {
- expect(component.min()).toBe(1);
+ it('should have default min of 0', () => {
+ expect(component.min()).toBe(0);
});
it('should have default max of undefined', () => {
@@ -68,9 +71,9 @@ describe('QuantityControlComponent', () => {
it('should generate options from min to presetLimit with Edit', () => {
const options = component.generatedOptions();
- expect(options).toHaveLength(11); // 1-10 + Edit
- expect(options[0]).toEqual({ value: 1, label: '1' });
- expect(options[9]).toEqual({ value: 10, label: '10' });
+ expect(options).toHaveLength(11); // 0-9 + Edit
+ expect(options[0]).toEqual({ value: 0, label: '0' });
+ expect(options[9]).toEqual({ value: 9, label: '9' });
expect(options[10]).toEqual({ value: 'edit', label: 'Edit' });
});
@@ -85,6 +88,7 @@ describe('QuantityControlComponent', () => {
});
it('should not show Edit option when max is less than or equal to presetLimit', () => {
+ fixture.componentRef.setInput('min', 1);
fixture.componentRef.setInput('max', 5);
fixture.componentRef.setInput('presetLimit', 10);
fixture.detectChanges();
@@ -105,7 +109,10 @@ describe('QuantityControlComponent', () => {
const options = component.generatedOptions();
expect(options).toHaveLength(11); // 1-10 + Edit
- expect(options[options.length - 1]).toEqual({ value: 'edit', label: 'Edit' });
+ expect(options[options.length - 1]).toEqual({
+ value: 'edit',
+ label: 'Edit',
+ });
});
it('should generate correct range with custom min and presetLimit', () => {
@@ -159,7 +166,7 @@ describe('QuantityControlComponent', () => {
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
'Quantity changed to 8',
- 'polite'
+ 'polite',
);
});
});
@@ -217,7 +224,7 @@ describe('QuantityControlComponent', () => {
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
'Edit mode activated. Type a quantity and press Enter to confirm or Escape to cancel.',
- 'polite'
+ 'polite',
);
});
@@ -241,7 +248,7 @@ describe('QuantityControlComponent', () => {
expect(component.value()).toBe(1); // Clamped to min
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
'Adjusted to minimum 1',
- 'polite'
+ 'polite',
);
});
@@ -256,7 +263,7 @@ describe('QuantityControlComponent', () => {
expect(component.value()).toBe(50); // Clamped to max
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
'Adjusted to maximum 50',
- 'polite'
+ 'polite',
);
});
@@ -270,7 +277,7 @@ describe('QuantityControlComponent', () => {
expect(component.value()).toBe(originalValue); // Value unchanged
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
'Invalid input. Please enter a valid number.',
- 'assertive'
+ 'assertive',
);
});
@@ -291,7 +298,7 @@ describe('QuantityControlComponent', () => {
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
'Edit mode cancelled',
- 'polite'
+ 'polite',
);
});
diff --git a/libs/shared/quantity-control/src/lib/quantity-control.component.ts b/libs/shared/quantity-control/src/lib/quantity-control.component.ts
index d527877c5..ca6e2382c 100644
--- a/libs/shared/quantity-control/src/lib/quantity-control.component.ts
+++ b/libs/shared/quantity-control/src/lib/quantity-control.component.ts
@@ -91,9 +91,9 @@ export class QuantityControlComponent implements ControlValueAccessor {
/**
* Current quantity value
- * @default 1
+ * @default 0
*/
- value = model(1);
+ value = model(0);
/**
* Whether the control is disabled
@@ -103,9 +103,9 @@ export class QuantityControlComponent implements ControlValueAccessor {
/**
* Minimum selectable value (starting point for dropdown options).
- * @default 1
+ * @default 0
*/
- min = input(1, {
+ min = input(0, {
transform: coerceNumberProperty,
});
diff --git a/tsconfig.base.json b/tsconfig.base.json
index cbc97288c..014bdf195 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -53,6 +53,9 @@
"@isa/checkout/shared/product-info": [
"libs/checkout/shared/product-info/src/index.ts"
],
+ "@isa/checkout/shared/reward-selection-dialog": [
+ "libs/checkout/shared/reward-selection-dialog/src/index.ts"
+ ],
"@isa/common/data-access": ["libs/common/data-access/src/index.ts"],
"@isa/common/decorators": ["libs/common/decorators/src/index.ts"],
"@isa/common/print": ["libs/common/print/src/index.ts"],