Merged PR 1968: #5307 Entscheidungs Dialog

#5307 Entscheidungs Dialog
This commit is contained in:
Nino Righi
2025-10-16 08:56:56 +00:00
committed by Lorenz Hilpert
parent 596ae1da1b
commit b5c8dc4776
80 changed files with 3442 additions and 118 deletions

View File

@@ -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;
}
}
}

View File

@@ -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 {}

View File

@@ -13,8 +13,12 @@
keinen Artikel hinzugefügt.
</p>
<div class="btn-wrapper">
<a class="cta-primary" [routerLink]="productSearchBasePath">Artikel suchen</a>
<button class="cta-secondary" (click)="openDummyModal({})">Neuanlage</button>
<a class="cta-primary" [routerLink]="productSearchBasePath"
>Artikel suchen</a
>
<button class="cta-secondary" (click)="openDummyModal({})">
Neuanlage
</button>
</div>
</div>
</div>
@@ -24,11 +28,22 @@
<div class="cta-print-wrapper">
<button class="cta-print" (click)="openPrintModal()">Drucken</button>
</div>
<h1 class="header">Warenkorb</h1>
<div class="header-container">
<h1 class="header">Warenkorb</h1>
@if (orderTypesExist$ | async) {
<lib-reward-selection-trigger
class="pb-2 desktop-large:pb-0"
></lib-reward-selection-trigger>
}
</div>
@if (!(isDesktop$ | async)) {
<page-checkout-review-details></page-checkout-review-details>
}
@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) {
<hr />
<div class="row item-group-header bg-[#F5F7FA]">
@@ -40,20 +55,31 @@
></shared-icon>
}
<div class="label" [class.dummy]="group.orderType === 'Dummy'">
{{ group.orderType !== 'Dummy' ? group.orderType : 'Manuelle Anlage / Dummy Bestellung' }}
{{
group.orderType !== 'Dummy'
? group.orderType
: 'Manuelle Anlage / Dummy Bestellung'
}}
@if (group.orderType === 'Dummy') {
<button
class="text-brand border-none font-bold text-p1 outline-none pl-4"
(click)="openDummyModal({ changeDataFromCart: true })"
>
>
Hinzufügen
</button>
}
</div>
<div class="grow"></div>
@if (group.orderType !== 'Download' && group.orderType !== 'Dummy') {
@if (
group.orderType !== 'Download' && group.orderType !== 'Dummy'
) {
<div class="pl-4">
<button class="cta-edit" (click)="showPurchasingListModal(group.items)">Ändern</button>
<button
class="cta-edit"
(click)="showPurchasingListModal(group.items)"
>
Ändern
</button>
</div>
}
</div>
@@ -62,20 +88,44 @@
group.orderType === 'Versand' ||
group.orderType === 'B2B-Versand' ||
group.orderType === 'DIG-Versand'
) {
<hr
/>
) {
<hr />
}
}
@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
)
) {
<div
class="flex flex-row items-center px-5 pt-0 pb-[0.875rem] -mt-2 bg-[#F5F7FA]"
[class.multiple-destinations]="checkIfMultipleDestinationsForOrderTypeExist(targetBranch, group, i)"
[class.multiple-destinations]="
checkIfMultipleDestinationsForOrderTypeExist(
targetBranch,
group,
i
)
"
>
<span class="branch-name"
>{{ targetBranch?.name }} |
{{ targetBranch | branchAddress }}</span
>
<span class="branch-name">{{ targetBranch?.name }} | {{ targetBranch | branchAddress }}</span>
</div>
<hr />
}
@@ -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 @@
}
<div class="flex flex-col w-full">
<strong class="total-value">
Zwischensumme {{ shoppingCart?.total?.value | currency: shoppingCart?.total?.currency : 'code' }}
Zwischensumme
{{
shoppingCart?.total?.value
| currency: shoppingCart?.total?.currency : 'code'
}}
</strong>
<span class="shipping-cost-info">ohne Versandkosten</span>
</div>
@@ -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)))
"
>
>
<ui-spinner [show]="showOrderButtonSpinner">
{{ primaryCtaLabel$ | async }}
</ui-spinner>
@@ -137,4 +195,3 @@
<ui-spinner [show]="true"></ui-spinner>
</div>
}

View File

@@ -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 {

View File

@@ -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)),

View File

@@ -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: [

View File

@@ -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 {}

View File

@@ -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<void> {
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);
}
}

View File

@@ -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([]);
});
});

View File

@@ -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<Item[]> {
return this.#searchService.SearchByEAN(ean).pipe(
async searchByEans(
ean: string[],
abortSignal?: AbortSignal,
): Promise<Item[]> {
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(

View File

@@ -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';

View File

@@ -1,3 +1,4 @@
export * from './branch.facade';
export * from './purchase-options.facade';
export * from './shopping-cart.facade';
export * from './reward-selection.facade';

View File

@@ -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,
});
}
}

View File

@@ -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}`;
};

View File

@@ -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';

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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<ShoppingCartDTO, 'items'> & {
items: EntityContainer<ShoppingCartItem>[];
};

View File

@@ -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.ZodObject<any>> = 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.ZodObject<any>> = z
name: z.string().optional(),
parent: EntityContainerSchema(
z.lazy((): z.ZodOptional<z.ZodObject<any>> => BranchDTOSchema),
),
).optional(),
shortName: z.string().optional(),
})
.optional();

View File

@@ -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(),
);
}
}

View File

@@ -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);
});
});
});

View File

@@ -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<ShoppingCart> {
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<number> {
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<number> {
const rewardShoppingCartId =
this.#checkoutMetadataService.getRewardShoppingCartId(tabId);
if (rewardShoppingCartId) {
return rewardShoppingCartId;
}
const shoppingCart = await this.createShoppingCart();
this.#checkoutMetadataService.setRewardShoppingCartId(
tabId!,
shoppingCart.id,
);
return shoppingCart.id!;
}
}

View File

@@ -6,9 +6,7 @@
<br />
Sie können Prämien unter folgendem Link zurück in den Warenkorb legen:
</p>
<button class="-mt-2" uiTextButton color="strong">
Prämie oder Warenkorb
</button>
<lib-reward-selection-trigger></lib-reward-selection-trigger>
</div>
<checkout-customer-reward-card></checkout-customer-reward-card>
<checkout-billing-and-shipping-address-card></checkout-billing-and-shipping-address-card>

View File

@@ -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,

View File

@@ -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: `<lib-reward-selection-trigger />`,
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: `<button (click)="openRewardSelection()">Select Rewards</button>`,
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()) {
<button (click)="openDialog()" [disabled]="isLoading()">
{{ eligibleItemsCount() }} items as rewards ({{ availablePoints() }} points)
</button>
}
`,
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<RewardSelectionDialogResult>` - Opens dialog
- `reloadResources(): Promise<void>` - Reloads all data
### RewardSelectionPopUpService
**Methods:**
- `popUp(): Promise<NavigateAfterRewardSelection | undefined>` - 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

View File

@@ -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: {},
},
];

View File

@@ -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"
}
}
}

View File

@@ -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';

View File

@@ -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();
});
});

View File

@@ -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(' | ');
};

View File

@@ -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);
});
});

View File

@@ -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;
};

View File

@@ -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');
});
});

View File

@@ -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'
);
};

View File

@@ -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);
});
});

View File

@@ -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;
};

View File

@@ -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');
});
});

View File

@@ -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<number, RewardSelectionItem[]>());
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),
};
});
};

View File

@@ -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);
});
});

View File

@@ -0,0 +1,15 @@
import {
getOrderTypeFeature,
RewardSelectionItem,
} from '@isa/checkout/data-access';
export const groupByOrderType = (
items: RewardSelectionItem[],
): Map<string, RewardSelectionItem[]> => {
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<string, RewardSelectionItem[]>());
};

View File

@@ -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);
});
});

View File

@@ -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
);
});
};

View File

@@ -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();
});
});

View File

@@ -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<string, RewardSelectionItem>();
// 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());
};

View File

@@ -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);
});
});

View File

@@ -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;
};

View File

@@ -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<ItemWithOrderType[] | undefined>(undefined);
#priceAndRedemptionPointsResource = resource({
params: computed(() => ({ items: this.#items() })),
loader: async ({
params,
abortSignal,
}): Promise<PriceAndRedemptionPointsResult[]> => {
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();
}
}

View File

@@ -0,0 +1,3 @@
:host {
@apply flex flex-row justify-end gap-2 w-full mt-6;
}

View File

@@ -0,0 +1,22 @@
<button
uiButton
(click)="onContinue()"
color="secondary"
data-what="button"
data-which="continue-shopping"
type="button"
>
{{ host.data.closeText }}
</button>
<button
uiButton
(click)="onSave()"
color="primary"
data-what="button"
data-which="save"
[disabled]="insufficientLoyaltyPoints() || completeRewardSelectionLoading()"
[pending]="completeRewardSelectionLoading()"
type="button"
>
Speichern
</button>

View File

@@ -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 });
}
}

View File

@@ -0,0 +1,49 @@
<div class="flex flex-col gap-6 text-isa-neutral-900">
<lib-reward-selection-error></lib-reward-selection-error>
<div
class="flex flex-row py-3 pl-6 bg-isa-neutral-400 rounded-2xl justify-between"
>
<span class="isa-text-body-1-regular">Zahlen mit</span>
<div class="flex flex-row gap-3">
<div class="flex flex-col items-center justify-center w-[10.5rem]">
<span class="isa-text-body-1-regular">Lesepunkten</span>
<span class="isa-text-subtitle-2-bold">{{
store.customerRewardPoints()
}}</span>
</div>
<div class="flex flex-col items-center justify-center w-[10.5rem]">
<span class="isa-text-body-1-regular">Warenkorb</span>
<span class="isa-text-subtitle-2-bold">EUR</span>
</div>
</div>
</div>
<lib-reward-selection-items></lib-reward-selection-items>
<div
class="flex flex-row py-3 pl-6 bg-isa-neutral-400 rounded-2xl justify-between"
>
<span class="isa-text-body-1-regular">Gesamt</span>
<div class="flex flex-row gap-3">
<div class="flex flex-col items-center justify-center w-[10.5rem]">
<span class="isa-text-body-1-regular">Lesepunkten</span>
<span class="isa-text-subtitle-2-bold">{{
store.totalLoyaltyPointsNeeded()
}}</span>
</div>
<div class="flex flex-col items-center justify-center w-[10.5rem]">
<span class="isa-text-body-1-regular">Warenkorb</span>
<span class="isa-text-subtitle-2-bold"
>{{ store.totalPrice() }} EUR</span
>
</div>
</div>
</div>
<lib-reward-selection-actions></lib-reward-selection-actions>
</div>

View File

@@ -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);
}
}

View File

@@ -0,0 +1,3 @@
:host {
@apply text-isa-accent-red isa-text-body-2-bold flex flex-row gap-2 items-center;
}

View File

@@ -0,0 +1,8 @@
@if (store.totalLoyaltyPointsNeeded() > store.customerRewardPoints()) {
<ng-icon
class="w-6 h-6 inline-flex items-center justify-center"
size="1.5rem"
name="isaOtherInfo"
></ng-icon>
<span>Lesepunkte reichen nicht für alle Artikel</span>
}

View File

@@ -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);
}

View File

@@ -0,0 +1,29 @@
<div
class="h-14 w-[10.5rem] pl-4 grid grid-flow-col border border-isa-neutral-400 rounded-lg items-center"
>
<div class="isa-text-body-1-bold">{{ loyaltyValue() }} LP</div>
<shared-quantity-control
[ngModel]="rewardCartQuantity()"
(ngModelChange)="changeRewardCartQuantity($event)"
[max]="maxQuantity()"
data-what="dropdown"
data-which="loyalty-quantity"
[disabled]="!hasStock()"
></shared-quantity-control>
</div>
<div
class="h-14 w-[10.5rem] pl-4 grid grid-flow-col border border-isa-neutral-400 rounded-lg items-center"
>
<div class="isa-text-body-1-bold">{{ priceValue() }} EUR</div>
<shared-quantity-control
[ngModel]="cartQuantity()"
(ngModelChange)="changeCartQuantity($event)"
[max]="maxQuantity()"
data-what="dropdown"
data-which="price-quantity"
[disabled]="!hasStock()"
></shared-quantity-control>
</div>

View File

@@ -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,
);
}
}

View File

@@ -0,0 +1,29 @@
<div class="flex flex-row justify-between">
<div class="grid grid-cols-[auto,1fr] gap-6">
<div>
<img
sharedProductRouterLink
sharedProductImage
[ean]="rewardSelectionItem().item.product.ean"
[alt]="rewardSelectionItem().item.product.name"
class="w-14"
data-what="product-image"
/>
</div>
<div class="flex flex-1 flex-col gap-2 max-w-40">
<div
class="isa-text-body-2-bold text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ rewardSelectionItem().item.product.contributors }}
</div>
<div
class="isa-text-body-2-regular text-ellipsis whitespace-nowrap overflow-hidden"
>
{{ rewardSelectionItem().item.product.name }}
</div>
</div>
</div>
<lib-reward-selection-inputs></lib-reward-selection-inputs>
</div>

View File

@@ -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<RewardSelectionItem>();
}

View File

@@ -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;
}

View File

@@ -0,0 +1,39 @@
@if (showGrouping()) {
@for (group of groupedItems(); track group.orderType; let last = $last) {
<div class="item-group-header">
<ng-icon
[name]="group.icon"
[size]="group.orderType === 'B2B-Versand' ? '36' : '24'"
/>
<div class="label">{{ group.orderType }}</div>
</div>
@for (
subGroup of group.subGroups;
track subGroup.branchId ?? $index;
let isFirst = $first
) {
@if (subGroup.branchName) {
<div class="branch-header rounded-2xl" [class.rounded-t-none]="isFirst">
<span class="branch-name">
{{ subGroup.branchName }} | {{ subGroup.branchAddress }}
</span>
</div>
}
@for (item of subGroup.items; track item.item.id; let isLast = $last) {
<lib-reward-selection-item class="py-4" [rewardSelectionItem]="item" />
@if (!isLast) {
<hr />
}
}
}
}
} @else {
@for (item of items(); track item.item.id; let last = $last) {
<lib-reward-selection-item class="py-4" [rewardSelectionItem]="item" />
@if (!last) {
<hr />
}
}
}

View File

@@ -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),
}));
});
}

View File

@@ -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<NavigateAfterRewardSelection | undefined> {
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<NavigateAfterRewardSelection | undefined> {
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,
);
}
}

View File

@@ -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<RewardSelectionItem[]>(() => {
return mergeRewardSelectionItems(
this.shoppingCartItems(),
this.rewardShoppingCartItems(),
);
});
selectionItemsWithOrderType = computed<ItemWithOrderType[]>(() => {
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<RewardSelectionItem[]>(() => {
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<RewardSelectionDialogResult> {
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<void> {
// 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(),
),
);
}
}

View File

@@ -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<number, RewardSelectionItem>;
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<number, RewardSelectionItem>,
);
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,
},
},
});
},
})),
);

View File

@@ -0,0 +1,12 @@
@if (canOpen() || isLoading()) {
<button
*uiSkeletonLoader="isLoading(); width: '12.5rem'; height: '1.85rem'"
type="button"
(click)="openRewardSelectionDialog()"
uiTextButton
color="strong"
[disabled]="isLoading()"
>
Prämie oder Warenkorb
</button>
}

View File

@@ -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();
}
}

View File

@@ -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(),
);

View File

@@ -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"
}
]
}

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

@@ -0,0 +1,29 @@
/// <reference types='vitest' />
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,
},
},
}));

View File

@@ -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) {

View File

@@ -9,7 +9,10 @@ describe('QuantityControlComponent', () => {
let component: QuantityControlComponent;
let fixture: ComponentFixture<QuantityControlComponent>;
let liveAnnouncerMock: { announce: ReturnType<typeof vi.fn> };
let tooltipMock: { show: ReturnType<typeof vi.fn>; hide: ReturnType<typeof vi.fn> };
let tooltipMock: {
show: ReturnType<typeof vi.fn>;
hide: ReturnType<typeof vi.fn>;
};
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',
);
});

View File

@@ -91,9 +91,9 @@ export class QuantityControlComponent implements ControlValueAccessor {
/**
* Current quantity value
* @default 1
* @default 0
*/
value = model<number>(1);
value = model<number>(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<number, unknown>(1, {
min = input<number, unknown>(0, {
transform: coerceNumberProperty,
});

View File

@@ -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"],