diff --git a/apps/isa-app/src/page/catalog/article-details/article-details.component.ts b/apps/isa-app/src/page/catalog/article-details/article-details.component.ts index 9e44352dc..0fe446ffe 100644 --- a/apps/isa-app/src/page/catalog/article-details/article-details.component.ts +++ b/apps/isa-app/src/page/catalog/article-details/article-details.component.ts @@ -50,6 +50,12 @@ import { import { DomainCheckoutService } from '@domain/checkout'; import { Store } from '@ngrx/store'; import moment, { Moment } from 'moment'; +import { + NavigateAfterRewardSelection, + RewardSelectionPopUpService, +} from '@isa/checkout/shared/reward-selection-dialog'; + +import { injectConfirmationDialog, injectFeedbackDialog } from '@isa/ui/dialog'; @Component({ selector: 'page-article-details', @@ -61,6 +67,7 @@ import moment, { Moment } from 'moment'; standalone: false, }) export class ArticleDetailsComponent implements OnInit, OnDestroy { + private _rewardSelectionPopUpService = inject(RewardSelectionPopUpService); private _customerSearchNavigation = inject(CustomerSearchNavigation); private readonly subscriptions = new Subscription(); @@ -534,16 +541,17 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy { } async showPurchasingModal(selectedBranch?: BranchDTO) { + const processId = this.applicationService.activatedProcessId; const item = await this.store.item$.pipe(first()).toPromise(); const shoppingCart = await firstValueFrom( this._domainCheckoutService.getShoppingCart({ - processId: this.applicationService.activatedProcessId, + processId, }), ); const modalRef = await this._purchaseOptionsModalService.open({ type: 'add', - tabId: this.applicationService.activatedProcessId, + tabId: processId, shoppingCartId: shoppingCart.id, items: [item], pickupBranch: selectedBranch, @@ -556,11 +564,11 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy { modalRef.afterClosed$.subscribe(async (result) => { if (result?.data === 'continue') { const customer = await this._domainCheckoutService - .getBuyer({ processId: this.applicationService.activatedProcessId }) + .getBuyer({ processId }) .pipe(first()) .toPromise(); if (customer) { - await this.navigateToShoppingCart(); + await this.#rewardSelectionPopUpFlow(processId); } else { await this.navigateToCustomerSearch(); } @@ -629,4 +637,33 @@ export class ArticleDetailsComponent implements OnInit, OnDestroy { loadImage() { this.imageLoaded$.next(true); } + + async #reloadShoppingCart(processId: number) { + await this._domainCheckoutService.reloadShoppingCart({ processId }); + } + + async #navigateToReward(processId: number) { + await this._router.navigate([`/${processId}`, 'reward', 'cart']); + } + + async #rewardSelectionPopUpFlow(tabId: number) { + await this.#reloadShoppingCart(tabId); + const navigate: NavigateAfterRewardSelection = + await this._rewardSelectionPopUpService.popUp(); + await this.#reloadShoppingCart(tabId); + + switch (navigate) { + case NavigateAfterRewardSelection.CART: + await this.navigateToShoppingCart(); + break; + case NavigateAfterRewardSelection.REWARD: + await this.#navigateToReward(tabId); + break; + case NavigateAfterRewardSelection.CATALOG: + await this.navigateToResultList(); + break; + default: + break; + } + } } diff --git a/apps/isa-app/src/page/catalog/article-details/article-details.module.ts b/apps/isa-app/src/page/catalog/article-details/article-details.module.ts index a3888800f..97437eb51 100644 --- a/apps/isa-app/src/page/catalog/article-details/article-details.module.ts +++ b/apps/isa-app/src/page/catalog/article-details/article-details.module.ts @@ -15,6 +15,15 @@ import { IconModule } from '@shared/components/icon'; import { ArticleDetailsTextComponent } from './article-details-text/article-details-text.component'; import { IconBadgeComponent } from 'apps/isa-app/src/shared/components/icon/badge/icon-badge.component'; import { MatomoModule } from 'ngx-matomo-client'; +import { + SelectedRewardShoppingCartResource, + SelectedShoppingCartResource, +} from '@isa/checkout/data-access'; +import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access'; +import { + RewardSelectionService, + RewardSelectionPopUpService, +} from '@isa/checkout/shared/reward-selection-dialog'; @NgModule({ imports: [ @@ -35,5 +44,12 @@ import { MatomoModule } from 'ngx-matomo-client'; ], exports: [ArticleDetailsComponent, ArticleRecommendationsComponent], declarations: [ArticleDetailsComponent, ArticleRecommendationsComponent], + providers: [ + SelectedShoppingCartResource, + SelectedRewardShoppingCartResource, + SelectedCustomerBonusCardsResource, + RewardSelectionService, + RewardSelectionPopUpService, + ], }) export class ArticleDetailsModule {} diff --git a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.html b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.html index 5ddca304d..bca0deef1 100644 --- a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.html +++ b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.html @@ -13,8 +13,12 @@ keinen Artikel hinzugefügt.

- Artikel suchen - + Artikel suchen +
@@ -24,11 +28,22 @@
-

Warenkorb

+
+

Warenkorb

+ @if (orderTypesExist$ | async) { + + } +
@if (!(isDesktop$ | async)) { } - @for (group of groupedItems$ | async; track trackByGroupedItems($index, group); let lastGroup = $last) { + @for ( + group of groupedItems$ | async; + track trackByGroupedItems($index, group); + let lastGroup = $last + ) { @if (group?.orderType !== undefined) {
@@ -40,20 +55,31 @@ > }
- {{ group.orderType !== 'Dummy' ? group.orderType : 'Manuelle Anlage / Dummy Bestellung' }} + {{ + group.orderType !== 'Dummy' + ? group.orderType + : 'Manuelle Anlage / Dummy Bestellung' + }} @if (group.orderType === 'Dummy') { }
- @if (group.orderType !== 'Download' && group.orderType !== 'Dummy') { + @if ( + group.orderType !== 'Download' && group.orderType !== 'Dummy' + ) {
- +
}
@@ -62,20 +88,44 @@ group.orderType === 'Versand' || group.orderType === 'B2B-Versand' || group.orderType === 'DIG-Versand' - ) { -
+ ) { +
} } - @for (item of group.items; track trackByItemId(i, item); let lastItem = $last; let i = $index) { - @if (group?.orderType !== undefined && (item.features?.orderType === 'Abholung' || item.features?.orderType === 'Rücklage')) { + @for ( + item of group.items; + track trackByItemId(i, item); + let lastItem = $last; + let i = $index + ) { + @if ( + group?.orderType !== undefined && + (item.features?.orderType === 'Abholung' || + item.features?.orderType === 'Rücklage') + ) { @if (item?.destination?.data?.targetBranch?.data; as targetBranch) { - @if (i === 0 || checkIfMultipleDestinationsForOrderTypeExist(targetBranch, group, i)) { + @if ( + i === 0 || + checkIfMultipleDestinationsForOrderTypeExist( + targetBranch, + group, + i + ) + ) {
+ {{ targetBranch?.name }} | + {{ targetBranch | branchAddress }} - {{ targetBranch?.name }} | {{ targetBranch | branchAddress }}

} @@ -85,7 +135,9 @@ (changeItem)="changeItem($event)" (changeDummyItem)="changeDummyItem($event)" (changeQuantity)="updateItemQuantity($event)" - [quantityError]="(quantityError$ | async)[item.product.catalogProductNumber]" + [quantityError]=" + (quantityError$ | async)[item.product.catalogProductNumber] + " [item]="item" [orderType]="group?.orderType" [loadingOnItemChangeById]="loadingOnItemChangeById$ | async" @@ -109,7 +161,11 @@ }
- Zwischensumme {{ shoppingCart?.total?.value | currency: shoppingCart?.total?.currency : 'code' }} + Zwischensumme + {{ + shoppingCart?.total?.value + | currency: shoppingCart?.total?.currency : 'code' + }} ohne Versandkosten
@@ -119,11 +175,13 @@ (click)="order()" [disabled]=" showOrderButtonSpinner || - ((primaryCtaLabel$ | async) === 'Bestellen' && !(checkNotificationChannelControl$ | async)) || + ((primaryCtaLabel$ | async) === 'Bestellen' && + !(checkNotificationChannelControl$ | async)) || notificationsControl?.invalid || - ((primaryCtaLabel$ | async) === 'Bestellen' && ((checkingOla$ | async) || (checkoutIsInValid$ | async))) + ((primaryCtaLabel$ | async) === 'Bestellen' && + ((checkingOla$ | async) || (checkoutIsInValid$ | async))) " - > + > {{ primaryCtaLabel$ | async }} @@ -137,4 +195,3 @@ } - diff --git a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.scss b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.scss index 18ef18b11..0a9d7de0d 100644 --- a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.scss +++ b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.scss @@ -72,8 +72,12 @@ button { @apply text-lg; } +.header-container { + @apply flex flex-col items-center justify-center desktop-large:pb-10 -mt-2; +} + .header { - @apply text-center text-h2 desktop-large:pb-10 -mt-2; + @apply text-center text-h2; } hr { diff --git a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.ts b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.ts index a5293bb63..a15763063 100644 --- a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.ts +++ b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.component.ts @@ -144,6 +144,10 @@ export class CheckoutReviewComponent ), ); + orderTypesExist$ = this.groupedItems$.pipe( + map((groups) => groups?.every((group) => group?.orderType !== undefined)), + ); + totalItemCount$ = this._store.shoppingCartItems$.pipe( takeUntil(this._store.orderCompleted), map((items) => items.reduce((total, item) => total + item.quantity, 0)), diff --git a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.module.ts b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.module.ts index 9346aeb0c..240a6a5e9 100644 --- a/apps/isa-app/src/page/checkout/checkout-review/checkout-review.module.ts +++ b/apps/isa-app/src/page/checkout/checkout-review/checkout-review.module.ts @@ -19,7 +19,11 @@ import { CheckoutReviewDetailsComponent } from './details/checkout-review-detail import { CheckoutReviewStore } from './checkout-review.store'; import { IconModule } from '@shared/components/icon'; import { TextFieldModule } from '@angular/cdk/text-field'; -import { LoaderComponent, SkeletonLoaderComponent } from '@shared/components/loader'; +import { + LoaderComponent, + SkeletonLoaderComponent, +} from '@shared/components/loader'; +import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog'; @NgModule({ imports: [ @@ -40,6 +44,7 @@ import { LoaderComponent, SkeletonLoaderComponent } from '@shared/components/loa TextFieldModule, LoaderComponent, SkeletonLoaderComponent, + RewardSelectionTriggerComponent, ], exports: [CheckoutReviewComponent, CheckoutReviewDetailsComponent], declarations: [ diff --git a/apps/isa-app/src/page/customer/customer-search/customer-search.module.ts b/apps/isa-app/src/page/customer/customer-search/customer-search.module.ts index a090db2f7..3f0582451 100644 --- a/apps/isa-app/src/page/customer/customer-search/customer-search.module.ts +++ b/apps/isa-app/src/page/customer/customer-search/customer-search.module.ts @@ -12,6 +12,15 @@ import { MainSideViewModule } from './main-side-view/main-side-view.module'; import { OrderDetailsSideViewComponent } from './order-details-side-view/order-details-side-view.component'; import { CustomerMainViewComponent } from './main-view/main-view.component'; import { SharedSplitscreenComponent } from '@shared/components/splitscreen'; +import { + SelectedShoppingCartResource, + SelectedRewardShoppingCartResource, +} from '@isa/checkout/data-access'; +import { + RewardSelectionService, + RewardSelectionPopUpService, +} from '@isa/checkout/shared/reward-selection-dialog'; +import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access'; @NgModule({ imports: [ @@ -29,5 +38,12 @@ import { SharedSplitscreenComponent } from '@shared/components/splitscreen'; ], exports: [CustomerSearchComponent], declarations: [CustomerSearchComponent], + providers: [ + SelectedShoppingCartResource, + SelectedRewardShoppingCartResource, + SelectedCustomerBonusCardsResource, + RewardSelectionService, + RewardSelectionPopUpService, + ], }) export class CustomerSearchModule {} diff --git a/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts b/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts index 1e357e1e5..dac2d6014 100644 --- a/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts +++ b/apps/isa-app/src/page/customer/customer-search/details-main-view/details-main-view.component.ts @@ -40,6 +40,10 @@ import { injectTab } from '@isa/core/tabs'; import { toSignal } from '@angular/core/rxjs-interop'; import { CrmTabMetadataService, Customer } from '@isa/crm/data-access'; import { CustomerAdapter } from '@isa/checkout/data-access'; +import { + NavigateAfterRewardSelection, + RewardSelectionPopUpService, +} from '@isa/checkout/shared/reward-selection-dialog'; import { NavigationStateService } from '@isa/core/navigation'; export interface CustomerDetailsViewMainState { @@ -75,6 +79,7 @@ export class CustomerDetailsViewMainComponent customerService = inject(CrmCustomerService); crmTabMetadataService = inject(CrmTabMetadataService); + private _rewardSelectionPopUpService = inject(RewardSelectionPopUpService); tab = injectTab(); // Signal to track if return URL exists @@ -97,7 +102,8 @@ export class CustomerDetailsViewMainComponent } async checkHasReturnUrl(): Promise { - const hasContext = await this._navigationState.hasPreservedContext('select-customer'); + const hasContext = + await this._navigationState.hasPreservedContext('select-customer'); this.hasReturnUrlSignal.set(hasContext); } @@ -362,10 +368,7 @@ export class CustomerDetailsViewMainComponent // #5262 Für die Auswahl des Kunden im "Prämienshop-Modus" (Getrennt vom regulären Checkout-Prozess) async continueReward() { if (this.isRewardTab()) { - this.crmTabMetadataService.setSelectedCustomerId( - this.processId, - this.customer.id, - ); + this._setSelectedCustomerIdInTab(); // Restore from preserved context (auto-scoped to current tab) and clean up const context = await this._navigationState.restoreAndClearContext<{ @@ -421,6 +424,8 @@ export class CustomerDetailsViewMainComponent this._setBuyer(); + this._setSelectedCustomerIdInTab(); + await this._updateNotifcationChannelsAsync(currentBuyer); this._setPayer(); @@ -428,17 +433,13 @@ export class CustomerDetailsViewMainComponent this._setShippingAddress(); if (this.shoppingCartHasItems) { - // Navigation zum Warenkorb - const path = this._checkoutNavigation.getCheckoutReviewPath( - this.processId, - ).path; - this._router.navigate(path); + await this.#rewardSelectionPopUpFlow(this.processId); } else { // Navigation zur Artikelsuche const path = this._catalogNavigation.getArticleSearchBasePath( this.processId, ).path; - this._router.navigate(path); + await this._router.navigate(path); } this.setIsBusy(false); @@ -608,6 +609,15 @@ export class CustomerDetailsViewMainComponent }); } + // #5307 Für den Warenkorb und den "Prämien oder Warenkorb" CTA ist es wichtig, dass die customerId im Tab hinterlegt ist + @log + _setSelectedCustomerIdInTab() { + this.crmTabMetadataService.setSelectedCustomerId( + this.processId, + this.customer.id, + ); + } + @logAsync async _updateNotifcationChannelsAsync(currentBuyer: BuyerDTO | undefined) { if (currentBuyer?.buyerNumber !== this.customer.customerNumber) { @@ -642,4 +652,44 @@ export class CustomerDetailsViewMainComponent }); } } + + async #rewardSelectionPopUpFlow(tabId: number) { + await this.#reloadShoppingCart(tabId); + const navigate: NavigateAfterRewardSelection = + await this._rewardSelectionPopUpService.popUp(); + await this.#reloadShoppingCart(tabId); + + switch (navigate) { + case NavigateAfterRewardSelection.CART: + await this.#navigateToCart(tabId); + break; + case NavigateAfterRewardSelection.REWARD: + await this.#navigateToReward(tabId); + break; + case NavigateAfterRewardSelection.CATALOG: + await this.#navigateToResultList(tabId); + break; + default: + break; + } + } + + async #reloadShoppingCart(tabId: number) { + await this._checkoutService.reloadShoppingCart({ processId: tabId }); + } + + async #navigateToReward(tabId: number) { + await this._router.navigate([`/${tabId}`, 'reward', 'cart']); + } + + async #navigateToCart(tabId: number) { + const path = this._checkoutNavigation.getCheckoutReviewPath(tabId).path; + await this._router.navigate(path); + } + + async #navigateToResultList(tabId: number) { + const path = + this._catalogNavigation.getArticleSearchResultsPath(tabId).path; + await this._router.navigate(path); + } } diff --git a/libs/catalogue/data-access/src/lib/services/catalouge-search.service.spec.ts b/libs/catalogue/data-access/src/lib/services/catalouge-search.service.spec.ts index 82d9b907d..cd5946c2f 100644 --- a/libs/catalogue/data-access/src/lib/services/catalouge-search.service.spec.ts +++ b/libs/catalogue/data-access/src/lib/services/catalouge-search.service.spec.ts @@ -36,7 +36,7 @@ describe('CatalougeSearchService', () => { }); describe('searchByEans', () => { - it('should return items when search is successful', (done) => { + it('should return items when search is successful', async () => { // Arrange const mockItems: Item[] = [ { @@ -57,21 +57,17 @@ describe('CatalougeSearchService', () => { searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse)); // Act - service.searchByEans('123456789', '987654321').subscribe({ - next: (result) => { - // Assert - expect(result).toEqual(mockItems); - expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith([ - '123456789', - '987654321', - ]); - done(); - }, - error: done.fail, - }); + const result = await service.searchByEans(['123456789', '987654321']); + + // Assert + expect(result).toEqual(mockItems); + expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith([ + '123456789', + '987654321', + ]); }); - it('should throw error when response has error', (done) => { + it('should return empty array when response has error', async () => { // Arrange const mockResponse = { error: true, @@ -80,18 +76,14 @@ describe('CatalougeSearchService', () => { searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse)); // Act - service.searchByEans('123456789').subscribe({ - next: () => done.fail('Should have thrown error'), - error: (error) => { - // Assert - expect(error).toBeInstanceOf(Error); - expect(error.message).toBe('Search failed'); - done(); - }, - }); + const result = await service.searchByEans(['123456789']); + + // Assert + expect(result).toEqual([]); + expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith(['123456789']); }); - it('should handle single EAN', (done) => { + it('should handle single EAN', async () => { // Arrange const mockItems: Item[] = [ { @@ -107,20 +99,14 @@ describe('CatalougeSearchService', () => { searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse)); // Act - service.searchByEans('123456789').subscribe({ - next: (result) => { - // Assert - expect(result).toEqual(mockItems); - expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith([ - '123456789', - ]); - done(); - }, - error: done.fail, - }); + const result = await service.searchByEans(['123456789']); + + // Assert + expect(result).toEqual(mockItems); + expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith(['123456789']); }); - it('should handle empty EAN array', (done) => { + it('should handle empty EAN array', async () => { // Arrange const mockResponse = { error: false, @@ -129,15 +115,11 @@ describe('CatalougeSearchService', () => { searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse)); // Act - service.searchByEans().subscribe({ - next: (result) => { - // Assert - expect(result).toEqual([]); - expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith([]); - done(); - }, - error: done.fail, - }); + const result = await service.searchByEans([]); + + // Assert + expect(result).toEqual([]); + expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith([]); }); }); diff --git a/libs/catalogue/data-access/src/lib/services/catalouge-search.service.ts b/libs/catalogue/data-access/src/lib/services/catalouge-search.service.ts index e8c8763e2..86e10e59d 100644 --- a/libs/catalogue/data-access/src/lib/services/catalouge-search.service.ts +++ b/libs/catalogue/data-access/src/lib/services/catalouge-search.service.ts @@ -4,7 +4,7 @@ import { ResponseArgsOfUISettingsDTO, SearchService, } from '@generated/swagger/cat-search-api'; -import { firstValueFrom, map, Observable } from 'rxjs'; +import { firstValueFrom, map } from 'rxjs'; import { catchResponseArgsErrorPipe, ResponseArgsError, @@ -26,8 +26,13 @@ export class CatalougeSearchService { #searchService = inject(SearchService); #logger = logger(() => ({ service: 'CatalougeSearchService' })); - searchByEans(...ean: string[]): Observable { - return this.#searchService.SearchByEAN(ean).pipe( + async searchByEans( + ean: string[], + abortSignal?: AbortSignal, + ): Promise { + this.#logger.info('Searching items by EANs', () => ({ count: ean.length })); + + let req$ = this.#searchService.SearchByEAN(ean).pipe( map((res) => { if (res.error) { throw new Error(res.message); @@ -36,6 +41,27 @@ export class CatalougeSearchService { return res.result as Item[]; }), ); + + if (abortSignal) { + req$ = req$.pipe(takeUntilAborted(abortSignal)); + } + + try { + const items = await firstValueFrom(req$); + this.#logger.debug('Successfully fetched items by EANs', () => ({ + count: items.length, + })); + return items; + } catch (error) { + this.#logger.error( + 'Error fetching items by EANs', + error as Error, + () => ({ + eanCount: ean.length, + }), + ); + return []; + } } async searchByTerm( diff --git a/libs/checkout/data-access/src/lib/constants.ts b/libs/checkout/data-access/src/lib/constants.ts index 70b96993e..8a0a3d5a0 100644 --- a/libs/checkout/data-access/src/lib/constants.ts +++ b/libs/checkout/data-access/src/lib/constants.ts @@ -6,3 +6,6 @@ export const CHECKOUT_SHOPPING_CART_ID_METADATA_KEY = export const CHECKOUT_REWARD_SHOPPING_CART_ID_METADATA_KEY = 'checkout-data-access.checkoutRewardShoppingCartId'; + +export const CHECKOUT_REWARD_SELECTION_POPUP_OPENED_STATE_KEY = + 'checkout-data-access.checkoutRewardSelectionPopupOpenedState'; diff --git a/libs/checkout/data-access/src/lib/facades/index.ts b/libs/checkout/data-access/src/lib/facades/index.ts index 6c3089b11..93997a74d 100644 --- a/libs/checkout/data-access/src/lib/facades/index.ts +++ b/libs/checkout/data-access/src/lib/facades/index.ts @@ -1,3 +1,4 @@ export * from './branch.facade'; export * from './purchase-options.facade'; export * from './shopping-cart.facade'; +export * from './reward-selection.facade'; diff --git a/libs/checkout/data-access/src/lib/facades/reward-selection.facade.ts b/libs/checkout/data-access/src/lib/facades/reward-selection.facade.ts new file mode 100644 index 000000000..3cd2a80cd --- /dev/null +++ b/libs/checkout/data-access/src/lib/facades/reward-selection.facade.ts @@ -0,0 +1,21 @@ +import { inject, Injectable } from '@angular/core'; +import { ShoppingCartService } from '../services'; +import { RewardSelectionItem } from '../models'; + +@Injectable() +export class RewardSelectionFacade { + #shoppingCartService = inject(ShoppingCartService); + + completeRewardSelection({ + tabId, + rewardSelectionItems, + }: { + tabId: number; + rewardSelectionItems: RewardSelectionItem[]; + }) { + return this.#shoppingCartService.completeRewardSelection({ + tabId, + rewardSelectionItems, + }); + } +} diff --git a/libs/checkout/data-access/src/lib/helpers/get-shopping-cart-item-key.helper.ts b/libs/checkout/data-access/src/lib/helpers/get-shopping-cart-item-key.helper.ts new file mode 100644 index 000000000..d35a8cb8e --- /dev/null +++ b/libs/checkout/data-access/src/lib/helpers/get-shopping-cart-item-key.helper.ts @@ -0,0 +1,15 @@ +import { + getOrderTypeFeature, + ShoppingCartItem, +} from '@isa/checkout/data-access'; + +/** + * Creates a unique key for an item based on EAN, destination, and orderItemType. + * Items are only considered identical if all three match. + */ +export const getItemKey = (item: ShoppingCartItem): string => { + const ean = item.product.ean ?? 'no-ean'; + const destinationId = item.destination?.data?.id ?? 'no-destination'; + const orderType = getOrderTypeFeature(item.features) ?? 'no-orderType'; + return `${ean}|${destinationId}|${orderType}`; +}; diff --git a/libs/checkout/data-access/src/lib/helpers/index.ts b/libs/checkout/data-access/src/lib/helpers/index.ts index 4db928777..a1807a692 100644 --- a/libs/checkout/data-access/src/lib/helpers/index.ts +++ b/libs/checkout/data-access/src/lib/helpers/index.ts @@ -2,3 +2,4 @@ export * from './get-order-type-feature.helper'; export * from './checkout-analysis.helpers'; export * from './checkout-business-logic.helpers'; export * from './checkout-data.helpers'; +export * from './get-shopping-cart-item-key.helper'; diff --git a/libs/checkout/data-access/src/lib/models/branch-type.ts b/libs/checkout/data-access/src/lib/models/branch-type.ts new file mode 100644 index 000000000..325184983 --- /dev/null +++ b/libs/checkout/data-access/src/lib/models/branch-type.ts @@ -0,0 +1,11 @@ +import { BranchType } from '@generated/swagger/checkout-api'; + +export type BranchTypeEnum = BranchType; + +export const BranchTypeEnum = { + NotSet: 0, + Store: 1, + WebStore: 2, + CallCenter: 4, + Headquarter: 8, +} as const; diff --git a/libs/checkout/data-access/src/lib/models/index.ts b/libs/checkout/data-access/src/lib/models/index.ts index 2cbc48eb7..be212c9f4 100644 --- a/libs/checkout/data-access/src/lib/models/index.ts +++ b/libs/checkout/data-access/src/lib/models/index.ts @@ -24,3 +24,5 @@ export * from './supplier'; export * from './shopping-cart'; export * from './update-shopping-cart-item'; export * from './vat-type'; +export * from './reward-selection-item'; +export * from './branch-type'; diff --git a/libs/checkout/data-access/src/lib/models/reward-selection-item.ts b/libs/checkout/data-access/src/lib/models/reward-selection-item.ts new file mode 100644 index 000000000..f70ab6421 --- /dev/null +++ b/libs/checkout/data-access/src/lib/models/reward-selection-item.ts @@ -0,0 +1,14 @@ +import { Price as CatalogPrice } from '@isa/catalogue/data-access'; +import { + ShoppingCartItem, + Price as AvailabilityPrice, +} from '@isa/checkout/data-access'; + +export interface RewardSelectionItem { + item: ShoppingCartItem; + catalogPrice: CatalogPrice | undefined; + availabilityPrice: AvailabilityPrice | undefined; + catalogRewardPoints: number | undefined; + cartQuantity: number; + rewardCartQuantity: number; +} diff --git a/libs/checkout/data-access/src/lib/models/shopping-cart.ts b/libs/checkout/data-access/src/lib/models/shopping-cart.ts index 48f4df87c..2e819494a 100644 --- a/libs/checkout/data-access/src/lib/models/shopping-cart.ts +++ b/libs/checkout/data-access/src/lib/models/shopping-cart.ts @@ -1,3 +1,7 @@ import { ShoppingCartDTO } from '@generated/swagger/checkout-api'; +import { EntityContainer } from '@isa/common/data-access'; +import { ShoppingCartItem } from './shopping-cart-item'; -export type ShoppingCart = ShoppingCartDTO; +export type ShoppingCart = Omit & { + items: EntityContainer[]; +}; diff --git a/libs/checkout/data-access/src/lib/schemas/base-schemas.ts b/libs/checkout/data-access/src/lib/schemas/base-schemas.ts index 8d9dbdaf3..4314a6721 100644 --- a/libs/checkout/data-access/src/lib/schemas/base-schemas.ts +++ b/libs/checkout/data-access/src/lib/schemas/base-schemas.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { AvailabilityType, Gender, ShippingTarget } from '../models'; import { OrderType } from '../models'; +import { BranchTypeEnum } from '../models'; import { AddressSchema, CommunicationDetailsSchema, @@ -196,7 +197,7 @@ export const BranchDTOSchema: z.ZodOptional> = z modifiedAt: z.string().optional(), address: AddressSchema.optional(), branchNumber: z.string().optional(), - branchType: z.string().optional(), // BranchType enum - treating as string for now + branchType: z.nativeEnum(BranchTypeEnum).optional(), isDefault: z.string().optional(), isOnline: z.boolean().optional(), isOrderingEnabled: z.boolean().optional(), @@ -206,7 +207,7 @@ export const BranchDTOSchema: z.ZodOptional> = z name: z.string().optional(), parent: EntityContainerSchema( z.lazy((): z.ZodOptional> => BranchDTOSchema), - ), + ).optional(), shortName: z.string().optional(), }) .optional(); diff --git a/libs/checkout/data-access/src/lib/services/checkout-metadata.service.ts b/libs/checkout/data-access/src/lib/services/checkout-metadata.service.ts index 318dee365..62e532008 100644 --- a/libs/checkout/data-access/src/lib/services/checkout-metadata.service.ts +++ b/libs/checkout/data-access/src/lib/services/checkout-metadata.service.ts @@ -1,6 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { TabService, getMetadataHelper } from '@isa/core/tabs'; import { + CHECKOUT_REWARD_SELECTION_POPUP_OPENED_STATE_KEY, CHECKOUT_REWARD_SHOPPING_CART_ID_METADATA_KEY, CHECKOUT_SHOPPING_CART_ID_METADATA_KEY, SELECTED_BRANCH_METADATA_KEY, @@ -55,4 +56,22 @@ export class CheckoutMetadataService { this.#tabService.entityMap(), ); } + + setRewardSelectionPopupOpenedState( + tabId: number, + opened: boolean | undefined, + ) { + this.#tabService.patchTabMetadata(tabId, { + [CHECKOUT_REWARD_SELECTION_POPUP_OPENED_STATE_KEY]: opened, + }); + } + + getRewardSelectionPopupOpenedState(tabId: number): boolean | undefined { + return getMetadataHelper( + tabId, + CHECKOUT_REWARD_SELECTION_POPUP_OPENED_STATE_KEY, + z.boolean().optional(), + this.#tabService.entityMap(), + ); + } } diff --git a/libs/checkout/data-access/src/lib/services/shopping-cart.service.spec.ts b/libs/checkout/data-access/src/lib/services/shopping-cart.service.spec.ts new file mode 100644 index 000000000..fa2bea5d1 --- /dev/null +++ b/libs/checkout/data-access/src/lib/services/shopping-cart.service.spec.ts @@ -0,0 +1,255 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ShoppingCartService } from './shopping-cart.service'; +import { CheckoutMetadataService } from './checkout-metadata.service'; +import { StoreCheckoutShoppingCartService } from '@generated/swagger/checkout-api'; +import { RewardSelectionItem, ShoppingCart, ShoppingCartItem } from '../models'; +import { provideLogging, LogLevel } from '@isa/core/logging'; + +describe('ShoppingCartService', () => { + let service: ShoppingCartService; + let mockStoreCheckoutShoppingCartService: any; + let mockCheckoutMetadataService: any; + + const createMockShoppingCartItem = (): ShoppingCartItem => + ({ + id: 1, + product: { + catalogProductNumber: 'PROD-123', + name: 'Test Product', + }, + quantity: 1, + destination: { id: 1 }, + availability: { + price: { value: { value: 10 } }, + }, + }) as ShoppingCartItem; + + const createMockShoppingCart = (items: any[] = []): ShoppingCart => + ({ + id: 123, + items: items.map((data) => ({ id: data.id, data })), + }) as ShoppingCart; + + const createMockRewardSelectionItem = ( + cartQuantity = 1, + rewardCartQuantity = 0, + ): RewardSelectionItem => ({ + item: createMockShoppingCartItem(), + catalogPrice: { value: { value: 10 } } as any, + availabilityPrice: { value: { value: 10 } } as any, + catalogRewardPoints: 100, + cartQuantity, + rewardCartQuantity, + }); + + beforeEach(() => { + mockStoreCheckoutShoppingCartService = { + StoreCheckoutShoppingCartCreateShoppingCart: vi.fn(), + StoreCheckoutShoppingCartGetShoppingCart: vi.fn(), + StoreCheckoutShoppingCartAddItemToShoppingCart: vi.fn(), + StoreCheckoutShoppingCartUpdateShoppingCartItem: vi.fn(), + }; + + mockCheckoutMetadataService = { + getShoppingCartId: vi.fn(), + setShoppingCartId: vi.fn(), + getRewardShoppingCartId: vi.fn(), + setRewardShoppingCartId: vi.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + ShoppingCartService, + { + provide: StoreCheckoutShoppingCartService, + useValue: mockStoreCheckoutShoppingCartService, + }, + { + provide: CheckoutMetadataService, + useValue: mockCheckoutMetadataService, + }, + provideLogging({ level: LogLevel.Off }), + ], + }); + + service = TestBed.inject(ShoppingCartService); + }); + + describe('completeRewardSelection', () => { + it('should add item to regular cart when cartQuantity > 0', async () => { + // Arrange + const tabId = 1; + const rewardSelectionItem = createMockRewardSelectionItem(2, 0); + + mockCheckoutMetadataService.getShoppingCartId.mockReturnValue(123); + mockCheckoutMetadataService.getRewardShoppingCartId.mockReturnValue(456); + + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartGetShoppingCart.mockReturnValue( + of({ result: createMockShoppingCart([]), error: null }), + ); + + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartAddItemToShoppingCart.mockReturnValue( + of({ result: createMockShoppingCart([]), error: null }), + ); + + // Act + await service.completeRewardSelection({ + tabId, + rewardSelectionItems: [rewardSelectionItem], + }); + + // Assert + const call = + mockStoreCheckoutShoppingCartService + .StoreCheckoutShoppingCartAddItemToShoppingCart.mock.calls[0][0]; + expect(call.shoppingCartId).toBe(123); + expect(call.items[0].quantity).toBe(2); + expect(call.items[0].loyalty).toBeUndefined(); + }); + + it('should add item to reward cart when rewardCartQuantity > 0', async () => { + // Arrange + const tabId = 1; + const rewardSelectionItem = createMockRewardSelectionItem(0, 3); + + mockCheckoutMetadataService.getShoppingCartId.mockReturnValue(123); + mockCheckoutMetadataService.getRewardShoppingCartId.mockReturnValue(456); + + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartGetShoppingCart.mockReturnValue( + of({ result: createMockShoppingCart([]), error: null }), + ); + + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartAddItemToShoppingCart.mockReturnValue( + of({ result: createMockShoppingCart([]), error: null }), + ); + + // Act + await service.completeRewardSelection({ + tabId, + rewardSelectionItems: [rewardSelectionItem], + }); + + // Assert + const calls = + mockStoreCheckoutShoppingCartService + .StoreCheckoutShoppingCartAddItemToShoppingCart.mock.calls; + + // Should only be called once for reward cart (not regular cart since cartQuantity is 0) + expect(calls).toHaveLength(1); + expect(calls[0][0].shoppingCartId).toBe(456); + expect(calls[0][0].items[0].quantity).toBe(3); + // Note: Zod schema validation transforms the data before the mock captures it + // The actual service logic sets loyalty, but the schema may transform it + }); + + it('should update item quantity in regular cart', async () => { + // Arrange + const tabId = 1; + const existingItem = createMockShoppingCartItem(); + const rewardSelectionItem = createMockRewardSelectionItem(5, 0); + + mockCheckoutMetadataService.getShoppingCartId.mockReturnValue(123); + mockCheckoutMetadataService.getRewardShoppingCartId.mockReturnValue(456); + + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartGetShoppingCart.mockReturnValueOnce( + of({ result: createMockShoppingCart([existingItem]), error: null }), + ).mockReturnValueOnce( + of({ result: createMockShoppingCart([]), error: null }), + ); + + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem.mockReturnValue( + of({ result: createMockShoppingCart([]), error: null }), + ); + + // Act + await service.completeRewardSelection({ + tabId, + rewardSelectionItems: [rewardSelectionItem], + }); + + // Assert + expect( + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem, + ).toHaveBeenCalledWith({ + shoppingCartId: 123, + shoppingCartItemId: 1, + values: { quantity: 5 }, + }); + }); + + it('should remove item from cart when quantity is 0', async () => { + // Arrange + const tabId = 1; + const existingItem = createMockShoppingCartItem(); + const rewardSelectionItem = createMockRewardSelectionItem(0, 0); + + mockCheckoutMetadataService.getShoppingCartId.mockReturnValue(123); + mockCheckoutMetadataService.getRewardShoppingCartId.mockReturnValue(456); + + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartGetShoppingCart.mockReturnValueOnce( + of({ result: createMockShoppingCart([existingItem]), error: null }), + ).mockReturnValueOnce( + of({ result: createMockShoppingCart([]), error: null }), + ); + + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem.mockReturnValue( + of({ result: createMockShoppingCart([]), error: null }), + ); + + // Act + await service.completeRewardSelection({ + tabId, + rewardSelectionItems: [rewardSelectionItem], + }); + + // Assert + expect( + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartUpdateShoppingCartItem, + ).toHaveBeenCalledWith({ + shoppingCartId: 123, + shoppingCartItemId: 1, + values: { quantity: 0 }, + }); + }); + + it('should create shopping cart if not exists', async () => { + // Arrange + const tabId = 1; + const rewardSelectionItem = createMockRewardSelectionItem(1, 0); + + mockCheckoutMetadataService.getShoppingCartId.mockReturnValue(null); + mockCheckoutMetadataService.getRewardShoppingCartId.mockReturnValue(null); + + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartCreateShoppingCart.mockReturnValue( + of({ result: { id: 999 }, error: null }), + ); + + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartGetShoppingCart.mockReturnValue( + of({ result: createMockShoppingCart([]), error: null }), + ); + + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartAddItemToShoppingCart.mockReturnValue( + of({ result: createMockShoppingCart([]), error: null }), + ); + + // Act + await service.completeRewardSelection({ + tabId, + rewardSelectionItems: [rewardSelectionItem], + }); + + // Assert + expect( + mockStoreCheckoutShoppingCartService.StoreCheckoutShoppingCartCreateShoppingCart, + ).toHaveBeenCalledTimes(2); + expect( + mockCheckoutMetadataService.setShoppingCartId, + ).toHaveBeenCalledWith(tabId, 999); + expect( + mockCheckoutMetadataService.setRewardShoppingCartId, + ).toHaveBeenCalledWith(tabId, 999); + }); + }); +}); diff --git a/libs/checkout/data-access/src/lib/services/shopping-cart.service.ts b/libs/checkout/data-access/src/lib/services/shopping-cart.service.ts index 3e82caa70..e981f4d5b 100644 --- a/libs/checkout/data-access/src/lib/services/shopping-cart.service.ts +++ b/libs/checkout/data-access/src/lib/services/shopping-cart.service.ts @@ -16,15 +16,18 @@ import { UpdateShoppingCartItemParams, UpdateShoppingCartItemParamsSchema, } from '../schemas'; -import { ShoppingCart } from '../models'; +import { RewardSelectionItem, ShoppingCart, ShoppingCartItem } from '../models'; import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access'; import { firstValueFrom } from 'rxjs'; import { logger } from '@isa/core/logging'; +import { CheckoutMetadataService } from './checkout-metadata.service'; +import { getItemKey } from '../helpers'; @Injectable({ providedIn: 'root' }) export class ShoppingCartService { #logger = logger(() => ({ service: 'ShoppingCartService' })); #storeCheckoutShoppingCartService = inject(StoreCheckoutShoppingCartService); + #checkoutMetadataService = inject(CheckoutMetadataService); async createShoppingCart(): Promise { const req$ = @@ -63,7 +66,7 @@ export class ShoppingCartService { this.#logger.error('Failed to fetch shopping cart', err); throw err; } - return res.result; + return res.result as ShoppingCart; } async canAddItems( @@ -163,4 +166,226 @@ export class ShoppingCartService { return res.result as ShoppingCart; } + + async completeRewardSelection({ + tabId, + rewardSelectionItems, + }: { + tabId: number; + rewardSelectionItems: RewardSelectionItem[]; + }) { + // Fetch or create both shopping cart IDs + const shoppingCartId = await this.#getOrCreateShoppingCartId(tabId); + const rewardShoppingCartId = + await this.#getOrCreateRewardShoppingCartId(tabId); + + // Get Current Carts + const currentCart = await this.getShoppingCart(shoppingCartId); + const currentRewardCart = await this.getShoppingCart(rewardShoppingCartId); + + // Get Current Items from Cart + const currentCartItems = currentCart?.items?.map((item) => item.data) ?? []; + const currentRewardCartItems = + currentRewardCart?.items?.map((item) => item.data) ?? []; + + for (const selectionItem of rewardSelectionItems) { + // Search by Key needed because of different ShoppingCartItem IDs the items have in the different carts + const selectionItemKey = getItemKey(selectionItem.item); + const currentInCart = currentCartItems.find( + (item) => item && getItemKey(item) === selectionItemKey, + ); + const currentInRewardCart = currentRewardCartItems.find( + (item) => item && getItemKey(item) === selectionItemKey, + ); + + // Handle regular cart + await this.#handleCart({ + shoppingCartId, + itemId: currentInCart?.id, + currentCartItem: currentInCart, + rewardSelectionItem: selectionItem, + }); + + // Handle reward cart + await this.#handleRewardCart({ + rewardShoppingCartId, + itemId: currentInRewardCart?.id, + currentRewardCartItem: currentInRewardCart, + rewardSelectionItem: selectionItem, + }); + } + } + + async #handleCart({ + shoppingCartId, + itemId, + currentCartItem, + rewardSelectionItem, + }: { + shoppingCartId: number; + itemId: number | undefined; + currentCartItem: ShoppingCartItem | undefined; + rewardSelectionItem: RewardSelectionItem; + }) { + const desiredCartQuantity = rewardSelectionItem.cartQuantity; + if (currentCartItem && itemId) { + const currentQuantity = currentCartItem.quantity; + if (desiredCartQuantity !== currentQuantity) { + if (desiredCartQuantity === 0) { + this.#logger.info('Removing item from regular cart', () => ({ + shoppingCartId, + itemId, + currentQuantity, + })); + await this.removeItem({ + shoppingCartId, + shoppingCartItemId: itemId, + }); + } else { + this.#logger.info('Updating item quantity in regular cart', () => ({ + shoppingCartId, + itemId, + currentQuantity, + desiredCartQuantity, + })); + await this.updateItem({ + shoppingCartId, + shoppingCartItemId: itemId, + values: { quantity: desiredCartQuantity }, + }); + } + } + } else if (desiredCartQuantity > 0) { + this.#logger.info('Adding item to regular cart', () => ({ + shoppingCartId, + itemId, + desiredCartQuantity, + productNumber: rewardSelectionItem?.item?.product?.catalogProductNumber, + })); + await this.addItem({ + shoppingCartId, + items: [ + { + destination: rewardSelectionItem.item.destination, + product: { + ...rewardSelectionItem.item.product, + catalogProductNumber: + rewardSelectionItem?.item?.product?.catalogProductNumber ?? + String(rewardSelectionItem?.item?.id), + }, + availability: { + ...rewardSelectionItem.item.availability, + price: + rewardSelectionItem?.availabilityPrice ?? + rewardSelectionItem?.catalogPrice, + }, + promotion: rewardSelectionItem?.item?.promotion, + quantity: desiredCartQuantity, + loyalty: undefined, + }, + ], + }); + } + } + + async #handleRewardCart({ + rewardShoppingCartId, + itemId, + currentRewardCartItem, + rewardSelectionItem, + }: { + rewardShoppingCartId: number; + itemId: number | undefined; + currentRewardCartItem: ShoppingCartItem | undefined; + rewardSelectionItem: RewardSelectionItem; + }) { + const desiredRewardCartQuantity = rewardSelectionItem.rewardCartQuantity; + if (currentRewardCartItem && itemId) { + const currentQuantity = currentRewardCartItem.quantity; + if (desiredRewardCartQuantity !== currentQuantity) { + if (desiredRewardCartQuantity === 0) { + this.#logger.info('Removing item from reward cart', () => ({ + rewardShoppingCartId, + itemId, + currentQuantity, + })); + await this.removeItem({ + shoppingCartId: rewardShoppingCartId, + shoppingCartItemId: itemId, + }); + } else { + this.#logger.info('Updating item quantity in reward cart', () => ({ + rewardShoppingCartId, + itemId, + currentQuantity, + desiredRewardCartQuantity, + })); + await this.updateItem({ + shoppingCartId: rewardShoppingCartId, + shoppingCartItemId: itemId, + values: { quantity: desiredRewardCartQuantity }, + }); + } + } + } else if (desiredRewardCartQuantity > 0) { + this.#logger.info('Adding item to reward cart', () => ({ + rewardShoppingCartId, + itemId, + desiredRewardCartQuantity, + rewardPoints: rewardSelectionItem.catalogRewardPoints, + productNumber: rewardSelectionItem?.item?.product?.catalogProductNumber, + })); + await this.addItem({ + shoppingCartId: rewardShoppingCartId, + items: [ + { + destination: rewardSelectionItem.item.destination, + product: { + ...rewardSelectionItem.item.product, + catalogProductNumber: + rewardSelectionItem?.item?.product?.catalogProductNumber ?? + String(rewardSelectionItem?.item?.id), + }, + quantity: desiredRewardCartQuantity, + promotion: undefined, // If loyalty is set, we need to remove promotion + loyalty: { value: rewardSelectionItem.catalogRewardPoints }, // Set loyalty points from item + availability: { + ...rewardSelectionItem.item.availability, + price: { + value: { value: 0 }, + }, + }, + }, + ], + }); + } + } + + async #getOrCreateShoppingCartId(tabId: number): Promise { + const shoppingCartId = + this.#checkoutMetadataService.getShoppingCartId(tabId); + + if (shoppingCartId) { + return shoppingCartId; + } + + const shoppingCart = await this.createShoppingCart(); + this.#checkoutMetadataService.setShoppingCartId(tabId!, shoppingCart.id); + return shoppingCart.id!; + } + + async #getOrCreateRewardShoppingCartId(tabId: number): Promise { + const rewardShoppingCartId = + this.#checkoutMetadataService.getRewardShoppingCartId(tabId); + if (rewardShoppingCartId) { + return rewardShoppingCartId; + } + + const shoppingCart = await this.createShoppingCart(); + this.#checkoutMetadataService.setRewardShoppingCartId( + tabId!, + shoppingCart.id, + ); + return shoppingCart.id!; + } } diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.html b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.html index b5fdae272..e4f46e2f0 100644 --- a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.html +++ b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.html @@ -6,9 +6,7 @@
Sie können Prämien unter folgendem Link zurück in den Warenkorb legen:

- + diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.ts b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.ts index 01f605e1f..a5bb57e0c 100644 --- a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.ts +++ b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart.component.ts @@ -1,6 +1,5 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { NavigateBackButtonComponent } from '@isa/core/tabs'; -import { TextButtonComponent } from '@isa/ui/buttons'; import { CheckoutCustomerRewardCardComponent } from './customer-reward-card/customer-reward-card.component'; import { BillingAndShippingAddressCardComponent } from './billing-and-shipping-address-card/billing-and-shipping-address-card.component'; import { RewardShoppingCartItemsComponent } from './reward-shopping-cart-items/reward-shopping-cart-items.component'; @@ -10,6 +9,7 @@ import { SelectedCustomerBonusCardsResource, } from '@isa/crm/data-access'; import { CompleteOrderButtonComponent } from './complete-order-button/complete-order-button.component'; +import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog'; @Component({ selector: 'checkout-reward-shopping-cart', @@ -18,11 +18,11 @@ import { CompleteOrderButtonComponent } from './complete-order-button/complete-o changeDetection: ChangeDetectionStrategy.OnPush, imports: [ NavigateBackButtonComponent, - TextButtonComponent, CheckoutCustomerRewardCardComponent, BillingAndShippingAddressCardComponent, RewardShoppingCartItemsComponent, CompleteOrderButtonComponent, + RewardSelectionTriggerComponent, ], providers: [ SelectedRewardShoppingCartResource, diff --git a/libs/checkout/shared/reward-selection-dialog/README.md b/libs/checkout/shared/reward-selection-dialog/README.md new file mode 100644 index 000000000..b1eda1263 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/README.md @@ -0,0 +1,214 @@ +# Reward Selection Dialog + +Angular library for managing reward selection in shopping cart context. Allows users to toggle between regular purchase and reward redemption using bonus points. + +## Features + +- 🎯 Pre-built trigger component or direct service integration +- 🔄 Automatic resource management (carts, bonus cards) +- 📊 Smart grouping by order type and branch +- 💾 NgRx Signals state management +- ✅ Full TypeScript support + +## Installation + +```typescript +import { + RewardSelectionService, + RewardSelectionPopUpService, + RewardSelectionTriggerComponent, +} from '@isa/checkout/shared/reward-selection-dialog'; +``` + +## Quick Start + +### Using the Trigger Component (Recommended) + +Simplest integration - includes all providers automatically: + +```typescript +import { Component } from '@angular/core'; +import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog'; + +@Component({ + selector: 'app-checkout', + template: ``, + imports: [RewardSelectionTriggerComponent], +}) +export class CheckoutComponent {} +``` + +### Using the Pop-Up Service + +More control over navigation flow: + +```typescript +import { Component, inject } from '@angular/core'; +import { + RewardSelectionPopUpService, + NavigateAfterRewardSelection, + RewardSelectionService, +} from '@isa/checkout/shared/reward-selection-dialog'; +import { + SelectedShoppingCartResource, + SelectedRewardShoppingCartResource, +} from '@isa/checkout/data-access'; +import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access'; + +@Component({ + selector: 'app-custom-checkout', + template: ``, + providers: [ + // Required providers + SelectedShoppingCartResource, + SelectedRewardShoppingCartResource, + SelectedCustomerBonusCardsResource, + RewardSelectionService, + RewardSelectionPopUpService, + ], +}) +export class CustomCheckoutComponent { + #popUpService = inject(RewardSelectionPopUpService); + + async openRewardSelection() { + const result = await this.#popUpService.popUp(); + + // Handle navigation: 'cart' | 'reward' | 'catalog' | undefined + if (result === NavigateAfterRewardSelection.CART) { + // Navigate to cart + } + } +} +``` + +### Using the Service Directly + +For custom UI or advanced use cases: + +```typescript +import { Component, inject } from '@angular/core'; +import { RewardSelectionService } from '@isa/checkout/shared/reward-selection-dialog'; +import { + SelectedShoppingCartResource, + SelectedRewardShoppingCartResource, +} from '@isa/checkout/data-access'; +import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access'; + +@Component({ + selector: 'app-advanced', + template: ` + @if (canOpen()) { + + } + `, + providers: [ + SelectedShoppingCartResource, + SelectedRewardShoppingCartResource, + SelectedCustomerBonusCardsResource, + RewardSelectionService, + ], +}) +export class AdvancedComponent { + #service = inject(RewardSelectionService); + + canOpen = this.#service.canOpen; + isLoading = this.#service.isLoading; + eligibleItemsCount = computed(() => this.#service.eligibleItems().length); + availablePoints = this.#service.primaryBonusCardPoints; + + async openDialog() { + const result = await this.#service.open({ closeText: 'Cancel' }); + if (result) { + // Handle result.rewardSelectionItems + await this.#service.reloadResources(); + } + } +} +``` + +## API Reference + +### RewardSelectionService + +**Key Signals:** +- `canOpen()`: `boolean` - Can dialog be opened +- `isLoading()`: `boolean` - Loading state +- `eligibleItems()`: `RewardSelectionItem[]` - Items available as rewards +- `primaryBonusCardPoints()`: `number` - Available points + +**Methods:** +- `open({ closeText }): Promise` - Opens dialog +- `reloadResources(): Promise` - Reloads all data + +### RewardSelectionPopUpService + +**Methods:** +- `popUp(): Promise` - Opens dialog with navigation flow + +**Return values:** +- `'cart'` - Navigate to shopping cart +- `'reward'` - Navigate to reward checkout +- `'catalog'` - Navigate to catalog +- `undefined` - No navigation needed + +### Types + +```typescript +interface RewardSelectionItem { + item: ShoppingCartItem; + catalogPrice: Price | undefined; + availabilityPrice: Price | undefined; + catalogRewardPoints: number | undefined; + cartQuantity: number; + rewardCartQuantity: number; +} + +type RewardSelectionDialogResult = { + rewardSelectionItems: RewardSelectionItem[]; +} | undefined; + +type NavigateAfterRewardSelection = 'cart' | 'reward' | 'catalog'; +``` + +## Required Providers + +When using `RewardSelectionService` or `RewardSelectionPopUpService` directly, provide: + +```typescript +providers: [ + SelectedShoppingCartResource, // Regular cart data + SelectedRewardShoppingCartResource, // Reward cart data + SelectedCustomerBonusCardsResource, // Customer bonus cards + RewardSelectionService, // Core service + RewardSelectionPopUpService, // Optional: only if using pop-up +] +``` + +**Note:** `RewardSelectionTriggerComponent` includes all required providers automatically. + +## Testing + +```bash +nx test reward-selection-dialog +``` + +## Architecture + +``` +reward-selection-dialog/ +├── helper/ # Pure utility functions +├── resource/ # Data resources +├── service/ # Business logic +├── store/ # NgRx Signals state +└── trigger/ # Trigger component +``` + +## Dependencies + +- `@isa/checkout/data-access` - Cart resources +- `@isa/crm/data-access` - Customer data +- `@isa/catalogue/data-access` - Product catalog +- `@isa/ui/dialog` - Dialog infrastructure +- `@ngrx/signals` - State management diff --git a/libs/checkout/shared/reward-selection-dialog/eslint.config.cjs b/libs/checkout/shared/reward-selection-dialog/eslint.config.cjs new file mode 100644 index 000000000..86877dc4b --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/eslint.config.cjs @@ -0,0 +1,34 @@ +const nx = require('@nx/eslint-plugin'); +const baseConfig = require('../../../../eslint.config.js'); + +module.exports = [ + ...baseConfig, + ...nx.configs['flat/angular'], + ...nx.configs['flat/angular-template'], + { + files: ['**/*.ts'], + rules: { + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'lib', + style: 'camelCase', + }, + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'lib', + style: 'kebab-case', + }, + ], + }, + }, + { + files: ['**/*.html'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/libs/checkout/shared/reward-selection-dialog/project.json b/libs/checkout/shared/reward-selection-dialog/project.json new file mode 100644 index 000000000..526a1ac1f --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/project.json @@ -0,0 +1,20 @@ +{ + "name": "reward-selection-dialog", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/checkout/shared/reward-selection-dialog/src", + "prefix": "lib", + "projectType": "library", + "tags": [], + "targets": { + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "../../../../coverage/libs/checkout/shared/reward-selection-dialog" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/index.ts b/libs/checkout/shared/reward-selection-dialog/src/index.ts new file mode 100644 index 000000000..4eba54f4f --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/index.ts @@ -0,0 +1,4 @@ +export * from './lib/reward-selection-dialog/service/reward-selection.service'; +export * from './lib/reward-selection-dialog/service/reward-selection-pop-up.service'; +export * from './lib/reward-selection-dialog/trigger/reward-selection-trigger.component'; +export * from './lib/reward-selection-dialog/reward-selection-dialog.component'; diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/format-address.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/format-address.helper.spec.ts new file mode 100644 index 000000000..bf05b5e71 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/format-address.helper.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { formatAddress } from './format-address.helper'; + +describe('formatAddress', () => { + it('should format complete address correctly', () => { + const address = { + street: 'Main Street', + streetNumber: '123', + zipCode: '12345', + city: 'Berlin', + }; + + const result = formatAddress(address); + + expect(result).toBe('Main Street 123 | 12345 Berlin'); + }); + + it('should handle missing street number', () => { + const address = { + street: 'Main Street', + zipCode: '12345', + city: 'Berlin', + }; + + const result = formatAddress(address); + + expect(result).toBe('Main Street | 12345 Berlin'); + }); + + it('should return undefined for undefined address', () => { + const result = formatAddress(undefined); + + expect(result).toBeUndefined(); + }); +}); diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/format-address.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/format-address.helper.ts new file mode 100644 index 000000000..e7a7d5f4e --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/format-address.helper.ts @@ -0,0 +1,13 @@ +import { Address } from '@isa/shared/address'; + +export const formatAddress = (address?: Address): string | undefined => { + if (!address) return undefined; + + const streetPart = [address.street, address.streetNumber] + .filter(Boolean) + .join(' '); + + const cityPart = [address.zipCode, address.city].filter(Boolean).join(' '); + + return [streetPart, cityPart].filter(Boolean).join(' | '); +}; diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-loyalty-points.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-loyalty-points.helper.spec.ts new file mode 100644 index 000000000..ead87b437 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-loyalty-points.helper.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { calculateLoyaltyPointsValue } from './get-loyalty-points.helper'; +import { RewardSelectionItem } from '@isa/checkout/data-access'; + +describe('calculateLoyaltyPointsValue', () => { + it('should return item loyalty points when available', () => { + const item: RewardSelectionItem = { + item: { + loyalty: { value: 100 }, + }, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: 50, + } as RewardSelectionItem; + + const result = calculateLoyaltyPointsValue(item); + + expect(result).toBe(100); + }); + + it('should return catalog loyalty points when item points not available', () => { + const item: RewardSelectionItem = { + item: {}, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: 50, + } as RewardSelectionItem; + + const result = calculateLoyaltyPointsValue(item); + + expect(result).toBe(50); + }); + + it('should return 0 when no loyalty points available', () => { + const item: RewardSelectionItem = { + item: {}, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + } as RewardSelectionItem; + + const result = calculateLoyaltyPointsValue(item); + + expect(result).toBe(0); + }); +}); diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-loyalty-points.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-loyalty-points.helper.ts new file mode 100644 index 000000000..84c868acd --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-loyalty-points.helper.ts @@ -0,0 +1,18 @@ +import { RewardSelectionItem } from '@isa/checkout/data-access'; + +export const calculateLoyaltyPointsValue = ( + rewardSelectionItem: RewardSelectionItem, +): number => { + const itemLoyaltyPoints = rewardSelectionItem.item?.loyalty?.value; + const catalogLoyaltyPoints = rewardSelectionItem?.catalogRewardPoints; + + if (itemLoyaltyPoints != null && itemLoyaltyPoints !== 0) { + return itemLoyaltyPoints; + } + + if (catalogLoyaltyPoints != null && catalogLoyaltyPoints !== 0) { + return catalogLoyaltyPoints; + } + + return 0; +}; diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-order-type-icon.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-order-type-icon.helper.spec.ts new file mode 100644 index 000000000..4653021cb --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-order-type-icon.helper.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest'; +import { getOrderTypeIcon } from './get-order-type-icon.helper'; + +describe('getOrderTypeIcon', () => { + it('should return correct icon for Versand', () => { + const result = getOrderTypeIcon('Versand'); + + expect(result).toBe('isaDeliveryVersand'); + }); + + it('should return correct icon for Abholung', () => { + const result = getOrderTypeIcon('Abholung'); + + expect(result).toBe('isaDeliveryRuecklage2'); + }); + + it('should return correct icon for Rücklage', () => { + const result = getOrderTypeIcon('Rücklage'); + + expect(result).toBe('isaDeliveryRuecklage1'); + }); + + it('should return default icon for unknown order type', () => { + const result = getOrderTypeIcon('UnknownType'); + + expect(result).toBe('isaDeliveryVersand'); + }); +}); diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-order-type-icon.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-order-type-icon.helper.ts new file mode 100644 index 000000000..eb0e97300 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-order-type-icon.helper.ts @@ -0,0 +1,14 @@ +export const ORDER_TYPE_ICONS = { + 'Versand': 'isaDeliveryVersand', + 'DIG-Versand': 'isaDeliveryVersand', + 'B2B-Versand': 'isaDeliveryB2BVersand1', + 'Abholung': 'isaDeliveryRuecklage2', + 'Rücklage': 'isaDeliveryRuecklage1', +} as const; + +export const getOrderTypeIcon = (orderType: string): string => { + return ( + ORDER_TYPE_ICONS[orderType as keyof typeof ORDER_TYPE_ICONS] ?? + 'isaDeliveryVersand' + ); +}; diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-price.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-price.helper.spec.ts new file mode 100644 index 000000000..9b80bc4dd --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-price.helper.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { calculatePriceValue } from './get-price.helper'; +import { RewardSelectionItem } from '@isa/checkout/data-access'; + +describe('calculatePriceValue', () => { + it('should return item total price when available', () => { + const item: RewardSelectionItem = { + item: { + total: { value: { value: 99.99 } }, + }, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: { value: { value: 89.99 } }, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + } as RewardSelectionItem; + + const result = calculatePriceValue(item); + + expect(result).toBe(99.99); + }); + + it('should return availability price when total price not available', () => { + const item: RewardSelectionItem = { + item: { + availability: { price: { value: { value: 79.99 } } }, + }, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: { value: { value: 89.99 } }, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + } as RewardSelectionItem; + + const result = calculatePriceValue(item); + + expect(result).toBe(79.99); + }); + + it('should return catalog price as fallback', () => { + const item: RewardSelectionItem = { + item: {}, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: { value: { value: 89.99 } }, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + } as RewardSelectionItem; + + const result = calculatePriceValue(item); + + expect(result).toBe(89.99); + }); + + it('should return 0 when no price available', () => { + const item: RewardSelectionItem = { + item: {}, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + } as RewardSelectionItem; + + const result = calculatePriceValue(item); + + expect(result).toBe(0); + }); +}); diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-price.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-price.helper.ts new file mode 100644 index 000000000..804ddb7c3 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/get-price.helper.ts @@ -0,0 +1,24 @@ +import { RewardSelectionItem } from '@isa/checkout/data-access'; + +export const calculatePriceValue = ( + rewardSelectionItem: RewardSelectionItem, +): number => { + const itemTotalPrice = rewardSelectionItem.item?.total?.value?.value; + const availabilityPrice = + rewardSelectionItem.item?.availability?.price?.value?.value; + const catalogPrice = rewardSelectionItem.catalogPrice?.value?.value; + + if (itemTotalPrice != null && itemTotalPrice !== 0) { + return itemTotalPrice; + } + + if (availabilityPrice != null && availabilityPrice !== 0) { + return availabilityPrice; + } + + if (catalogPrice != null && catalogPrice !== 0) { + return catalogPrice; + } + + return 0; +}; diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-branch.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-branch.helper.spec.ts new file mode 100644 index 000000000..126c10c3f --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-branch.helper.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from 'vitest'; +import { groupByBranch } from './group-by-branch.helper'; +import { RewardSelectionItem } from '@isa/checkout/data-access'; + +describe('groupByBranch', () => { + it('should not group items for Versand order type', () => { + const items: RewardSelectionItem[] = [ + { + item: { + product: { name: 'Product A' }, + }, + cartQuantity: 1, + rewardCartQuantity: 0, + } as RewardSelectionItem, + { + item: { + product: { name: 'Product B' }, + }, + cartQuantity: 1, + rewardCartQuantity: 0, + } as RewardSelectionItem, + ]; + + const result = groupByBranch('Versand', items); + + expect(result).toHaveLength(1); + expect(result[0].branchId).toBeUndefined(); + expect(result[0].items).toHaveLength(2); + }); + + it('should group items by branch for Abholung order type', () => { + const items: RewardSelectionItem[] = [ + { + item: { + product: { name: 'Product A' }, + destination: { + data: { + targetBranch: { + id: 1, + data: { name: 'Branch 1' }, + }, + }, + }, + }, + cartQuantity: 1, + rewardCartQuantity: 0, + } as RewardSelectionItem, + { + item: { + product: { name: 'Product B' }, + destination: { + data: { + targetBranch: { + id: 2, + data: { name: 'Branch 2' }, + }, + }, + }, + }, + cartQuantity: 1, + rewardCartQuantity: 0, + } as RewardSelectionItem, + ]; + + const result = groupByBranch('Abholung', items); + + expect(result).toHaveLength(2); + expect(result[0].branchId).toBe(1); + expect(result[1].branchId).toBe(2); + }); + + it('should sort items by product name within groups', () => { + const items: RewardSelectionItem[] = [ + { + item: { + product: { name: 'Zebra' }, + }, + cartQuantity: 1, + rewardCartQuantity: 0, + } as RewardSelectionItem, + { + item: { + product: { name: 'Apple' }, + }, + cartQuantity: 1, + rewardCartQuantity: 0, + } as RewardSelectionItem, + ]; + + const result = groupByBranch('Versand', items); + + expect(result[0].items[0].item.product.name).toBe('Apple'); + expect(result[0].items[1].item.product.name).toBe('Zebra'); + }); +}); diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-branch.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-branch.helper.ts new file mode 100644 index 000000000..e3679159b --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-branch.helper.ts @@ -0,0 +1,56 @@ +import { RewardSelectionItem } from '@isa/checkout/data-access'; +import { formatAddress } from './format-address.helper'; + +export type SubGroup = { + branchId?: number; + branchName?: string; + branchAddress?: string; + items: RewardSelectionItem[]; +}; + +const ORDER_TYPES_WITH_BRANCH_GROUPING = ['Abholung', 'Rücklage'] as const; + +const sortByProductName = (items: RewardSelectionItem[]): RewardSelectionItem[] => { + return [...items].sort((a, b) => + (a.item.product.name ?? '').localeCompare(b.item.product.name ?? ''), + ); +}; + +export const groupByBranch = ( + orderType: string, + items: RewardSelectionItem[], +): SubGroup[] => { + const needsBranchGrouping = ORDER_TYPES_WITH_BRANCH_GROUPING.includes( + orderType as (typeof ORDER_TYPES_WITH_BRANCH_GROUPING)[number], + ); + + if (!needsBranchGrouping) { + return [ + { + branchId: undefined, + branchName: undefined, + branchAddress: undefined, + items: sortByProductName(items), + }, + ]; + } + + const branchGroups = items.reduce((map, item) => { + const branchId = item.item.destination?.data?.targetBranch?.id ?? 0; + if (!map.has(branchId)) map.set(branchId, []); + map.get(branchId)!.push(item); + return map; + }, new Map()); + + return Array.from(branchGroups.entries()) + .sort(([a], [b]) => a - b) + .map(([branchId, branchItems]) => { + const branch = branchItems[0]?.item.destination?.data?.targetBranch?.data; + return { + branchId: branchId || undefined, + branchName: branch?.name, + branchAddress: formatAddress(branch?.address), + items: sortByProductName(branchItems), + }; + }); +}; diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-order-type.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-order-type.helper.spec.ts new file mode 100644 index 000000000..09671129c --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-order-type.helper.spec.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { groupByOrderType } from './group-by-order-type.helper'; +import { + RewardSelectionItem, + ShoppingCartItem, +} from '@isa/checkout/data-access'; + +describe('groupByOrderType', () => { + it('should group items by order type', () => { + const items: RewardSelectionItem[] = [ + { + item: { + features: { orderType: 'Versand' }, + product: {}, + } as unknown as ShoppingCartItem, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + }, + { + item: { + features: { orderType: 'Abholung' }, + product: {}, + } as unknown as ShoppingCartItem, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + }, + { + item: { + features: { orderType: 'Versand' }, + product: {}, + } as unknown as ShoppingCartItem, + cartQuantity: 2, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + }, + ]; + + const result = groupByOrderType(items); + + expect(result.size).toBe(2); + expect(result.get('Versand')).toHaveLength(2); + expect(result.get('Abholung')).toHaveLength(1); + }); + + it('should handle items without order type feature', () => { + const items: RewardSelectionItem[] = [ + { + item: { + features: {}, + product: {}, + } as unknown as ShoppingCartItem, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + }, + ]; + + const result = groupByOrderType(items); + + expect(result.size).toBe(1); + expect(result.has('Unbekannt')).toBe(true); + }); + + it('should return empty map for empty items array', () => { + const result = groupByOrderType([]); + + expect(result.size).toBe(0); + }); +}); diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-order-type.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-order-type.helper.ts new file mode 100644 index 000000000..6287ad968 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/group-by-order-type.helper.ts @@ -0,0 +1,15 @@ +import { + getOrderTypeFeature, + RewardSelectionItem, +} from '@isa/checkout/data-access'; + +export const groupByOrderType = ( + items: RewardSelectionItem[], +): Map => { + return items.reduce((map, item) => { + const orderType = getOrderTypeFeature(item.item.features) ?? 'Unbekannt'; + if (!map.has(orderType)) map.set(orderType, []); + map.get(orderType)!.push(item); + return map; + }, new Map()); +}; diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/item-selection-changed.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/item-selection-changed.helper.spec.ts new file mode 100644 index 000000000..ecf3ae469 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/item-selection-changed.helper.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { itemSelectionChanged } from './item-selection-changed.helper'; +import { RewardSelectionItem } from '@isa/checkout/data-access'; + +describe('itemSelectionChanged', () => { + it('should return true when result items are undefined', () => { + const inputItems: RewardSelectionItem[] = [ + { + item: { product: { ean: '123' } }, + cartQuantity: 1, + rewardCartQuantity: 0, + } as RewardSelectionItem, + ]; + + const result = itemSelectionChanged(inputItems, undefined); + + expect(result).toBe(true); + }); + + it('should return false when quantities are unchanged', () => { + const inputItems: RewardSelectionItem[] = [ + { + item: { product: { ean: '123' } }, + cartQuantity: 1, + rewardCartQuantity: 0, + } as RewardSelectionItem, + ]; + + const resultItems: RewardSelectionItem[] = [ + { + item: { product: { ean: '123' } }, + cartQuantity: 1, + rewardCartQuantity: 0, + } as RewardSelectionItem, + ]; + + const result = itemSelectionChanged(inputItems, resultItems); + + expect(result).toBe(false); + }); + + it('should return true when cart quantity changed', () => { + const inputItems: RewardSelectionItem[] = [ + { + item: { product: { ean: '123' } }, + cartQuantity: 1, + rewardCartQuantity: 0, + } as RewardSelectionItem, + ]; + + const resultItems: RewardSelectionItem[] = [ + { + item: { product: { ean: '123' } }, + cartQuantity: 2, + rewardCartQuantity: 0, + } as RewardSelectionItem, + ]; + + const result = itemSelectionChanged(inputItems, resultItems); + + expect(result).toBe(true); + }); + + it('should return true when reward cart quantity changed', () => { + const inputItems: RewardSelectionItem[] = [ + { + item: { product: { ean: '123' } }, + cartQuantity: 1, + rewardCartQuantity: 0, + } as RewardSelectionItem, + ]; + + const resultItems: RewardSelectionItem[] = [ + { + item: { product: { ean: '123' } }, + cartQuantity: 1, + rewardCartQuantity: 1, + } as RewardSelectionItem, + ]; + + const result = itemSelectionChanged(inputItems, resultItems); + + expect(result).toBe(true); + }); +}); diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/item-selection-changed.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/item-selection-changed.helper.ts new file mode 100644 index 000000000..ea87825be --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/item-selection-changed.helper.ts @@ -0,0 +1,20 @@ +import { getItemKey, RewardSelectionItem } from '@isa/checkout/data-access'; + +export const itemSelectionChanged = ( + inputItems: RewardSelectionItem[], + resultItems?: RewardSelectionItem[], +): boolean => { + if (!resultItems) return true; + + return inputItems.some((inputItem) => { + const resultItem = resultItems.find( + (i) => getItemKey(i.item) === getItemKey(inputItem.item), + ); + + return ( + !resultItem || + resultItem.cartQuantity !== inputItem.cartQuantity || + resultItem.rewardCartQuantity !== inputItem.rewardCartQuantity + ); + }); +}; diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/merge-reward-selection-items.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/merge-reward-selection-items.helper.spec.ts new file mode 100644 index 000000000..ce470d2c1 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/merge-reward-selection-items.helper.spec.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { mergeRewardSelectionItems } from './merge-reward-selection-items.helper'; +import { ShoppingCartItem } from '@isa/checkout/data-access'; + +describe('mergeRewardSelectionItems', () => { + it('should merge items from both carts', () => { + const shoppingCartItems: ShoppingCartItem[] = [ + { + product: { ean: '123' }, + quantity: 2, + } as ShoppingCartItem, + ]; + + const rewardShoppingCartItems: ShoppingCartItem[] = [ + { + product: { ean: '123' }, + quantity: 1, + } as ShoppingCartItem, + ]; + + const result = mergeRewardSelectionItems( + shoppingCartItems, + rewardShoppingCartItems, + ); + + expect(result).toHaveLength(1); + expect(result[0].cartQuantity).toBe(2); + expect(result[0].rewardCartQuantity).toBe(1); + }); + + it('should handle items only in shopping cart', () => { + const shoppingCartItems: ShoppingCartItem[] = [ + { + product: { ean: '123' }, + quantity: 2, + } as ShoppingCartItem, + ]; + + const rewardShoppingCartItems: ShoppingCartItem[] = []; + + const result = mergeRewardSelectionItems( + shoppingCartItems, + rewardShoppingCartItems, + ); + + expect(result).toHaveLength(1); + expect(result[0].cartQuantity).toBe(2); + expect(result[0].rewardCartQuantity).toBe(0); + }); + + it('should handle items only in reward cart', () => { + const shoppingCartItems: ShoppingCartItem[] = []; + + const rewardShoppingCartItems: ShoppingCartItem[] = [ + { + product: { ean: '456' }, + quantity: 1, + } as ShoppingCartItem, + ]; + + const result = mergeRewardSelectionItems( + shoppingCartItems, + rewardShoppingCartItems, + ); + + expect(result).toHaveLength(1); + expect(result[0].cartQuantity).toBe(0); + expect(result[0].rewardCartQuantity).toBe(1); + }); + + it('should handle empty arrays', () => { + const result = mergeRewardSelectionItems([], []); + + expect(result).toHaveLength(0); + }); + + it('should initialize catalog fields as undefined', () => { + const shoppingCartItems: ShoppingCartItem[] = [ + { + product: { ean: '123' }, + quantity: 1, + } as ShoppingCartItem, + ]; + + const result = mergeRewardSelectionItems(shoppingCartItems, []); + + expect(result[0].catalogPrice).toBeUndefined(); + expect(result[0].availabilityPrice).toBeUndefined(); + expect(result[0].catalogRewardPoints).toBeUndefined(); + }); +}); diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/merge-reward-selection-items.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/merge-reward-selection-items.helper.ts new file mode 100644 index 000000000..751d8ba47 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/merge-reward-selection-items.helper.ts @@ -0,0 +1,61 @@ +import { + getItemKey, + ShoppingCartItem, + RewardSelectionItem, +} from '@isa/checkout/data-access'; + +/** + * Merges items from regular shopping cart and reward shopping cart. + * Items are grouped by EAN, destination, and orderItemType. + * If an item exists in both carts with the same key, it will have both + * cartQuantity and rewardCartQuantity. + * + * @param shoppingCartItems - Items from the regular shopping cart + * @param rewardShoppingCartItems - Items from the reward shopping cart + * @returns Merged array of reward selection items + */ +export const mergeRewardSelectionItems = ( + shoppingCartItems: ShoppingCartItem[], + rewardShoppingCartItems: ShoppingCartItem[], +): RewardSelectionItem[] => { + const itemsMap = new Map(); + + // Add items from regular shopping cart + shoppingCartItems.forEach((shoppingCartItem) => { + const key = getItemKey(shoppingCartItem); + itemsMap.set(key, { + item: shoppingCartItem, + cartQuantity: shoppingCartItem.quantity ?? 0, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + }); + }); + + // Merge items from reward shopping cart + rewardShoppingCartItems.forEach((shoppingCartItem) => { + const key = getItemKey(shoppingCartItem); + const existingItem = itemsMap.get(key); + + if (existingItem) { + // Item exists in both carts with same EAN, destination, and orderItemType - merge quantities + itemsMap.set(key, { + ...existingItem, + rewardCartQuantity: shoppingCartItem.quantity ?? 0, + }); + } else { + // Item only in reward cart or different destination/orderItemType + itemsMap.set(key, { + item: shoppingCartItem, + cartQuantity: 0, + rewardCartQuantity: shoppingCartItem.quantity ?? 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + }); + } + }); + + return Array.from(itemsMap.values()); +}; diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/should-show-grouping.helper.spec.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/should-show-grouping.helper.spec.ts new file mode 100644 index 000000000..d5348153b --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/should-show-grouping.helper.spec.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import { shouldShowGrouping } from './should-show-grouping.helper'; +import { + RewardSelectionItem, + ShoppingCartItem, +} from '@isa/checkout/data-access'; + +describe('shouldShowGrouping', () => { + it('should return false for single item', () => { + const items: RewardSelectionItem[] = [ + { + item: { + features: { orderType: 'Versand' }, + product: {}, + } as unknown as ShoppingCartItem, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + }, + ]; + + const result = shouldShowGrouping(items); + + expect(result).toBe(false); + }); + + it('should return true when items have different order types', () => { + const items: RewardSelectionItem[] = [ + { + item: { + features: { orderType: 'Versand' }, + product: {}, + } as unknown as ShoppingCartItem, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + }, + { + item: { + features: { orderType: 'Abholung' }, + product: {}, + } as unknown as ShoppingCartItem, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + }, + ]; + + const result = shouldShowGrouping(items); + + expect(result).toBe(true); + }); + + it('should return false when items have same order type and branch', () => { + const items: RewardSelectionItem[] = [ + { + item: { + features: { orderType: 'Versand' }, + product: {}, + } as unknown as ShoppingCartItem, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + }, + { + item: { + features: { orderType: 'Versand' }, + product: {}, + } as unknown as ShoppingCartItem, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + }, + ]; + + const result = shouldShowGrouping(items); + + expect(result).toBe(false); + }); + + it('should return true for Abholung with different branches', () => { + const items: RewardSelectionItem[] = [ + { + item: { + features: { orderType: 'Abholung' }, + destination: { + data: { targetBranch: { id: 1 } }, + }, + product: {}, + } as unknown as ShoppingCartItem, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + }, + { + item: { + features: { orderType: 'Abholung' }, + destination: { + data: { targetBranch: { id: 2 } }, + }, + product: {}, + } as unknown as ShoppingCartItem, + cartQuantity: 1, + rewardCartQuantity: 0, + catalogPrice: undefined, + availabilityPrice: undefined, + catalogRewardPoints: undefined, + }, + ]; + + const result = shouldShowGrouping(items); + + expect(result).toBe(true); + }); +}); diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/should-show-grouping.helper.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/should-show-grouping.helper.ts new file mode 100644 index 000000000..5f88a60ca --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/helper/should-show-grouping.helper.ts @@ -0,0 +1,30 @@ +import { + getOrderTypeFeature, + RewardSelectionItem, +} from '@isa/checkout/data-access'; + +const ORDER_TYPES_WITH_BRANCH_DISTINCTION = ['Abholung', 'Rücklage'] as const; + +export const shouldShowGrouping = (items: RewardSelectionItem[]): boolean => { + if (items.length <= 1) return false; + + // Prüfe ob alle Items denselben OrderType haben + const firstOrderType = getOrderTypeFeature(items[0]?.item.features); + const hasDifferentOrderTypes = items.some( + (item) => getOrderTypeFeature(item.item.features) !== firstOrderType, + ); + + if (hasDifferentOrderTypes) return true; + + // Wenn alle denselben OrderType haben, prüfe ob verschiedene Branches existieren + // (nur relevant für Abholung/Rücklage) + if (ORDER_TYPES_WITH_BRANCH_DISTINCTION.includes(firstOrderType as any)) { + const firstBranchId = items[0]?.item.destination?.data?.targetBranch?.id; + const hasDifferentBranches = items.some( + (item) => item.item.destination?.data?.targetBranch?.id !== firstBranchId, + ); + return hasDifferentBranches; + } + + return false; +}; diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/resource/price-and-redemption-points.resource.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/resource/price-and-redemption-points.resource.ts new file mode 100644 index 000000000..0ff082166 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/resource/price-and-redemption-points.resource.ts @@ -0,0 +1,175 @@ +import { computed, inject, Injectable, resource, signal } from '@angular/core'; +import { AvailabilityService } from '@isa/availability/data-access'; +import { + CatalougeSearchService, + Price as CatalogPrice, +} from '@isa/catalogue/data-access'; +import { + OrderType, + Price as AvailabilityPrice, +} from '@isa/checkout/data-access'; +import { logger } from '@isa/core/logging'; + +/** + * Input item for availability check - contains EAN, orderType and optional branchId + */ +export interface ItemWithOrderType { + ean: string; + orderType: OrderType; + branchId?: number; +} + +/** + * Result containing price from availability and redemption points from catalog + */ +export interface PriceAndRedemptionPointsResult { + ean: string; + availabilityPrice?: AvailabilityPrice; + catalogPrice?: CatalogPrice; + redemptionPoints?: number; +} + +/** + * Resource for fetching combined price and redemption points data. + * + * This resource: + * 1. Fetches catalog items by EAN to get redemption points + * 2. Groups items by order type + * 3. Fetches availability data for each order type group + * 4. Combines catalog redemption points with availability prices + * + * @example + * ```typescript + * const resource = inject(PriceAndRedemptionPointsResource); + * + * // Load data for items + * resource.loadPriceAndRedemptionPoints([ + * { ean: '1234567890', orderType: OrderType.Delivery }, + * { ean: '0987654321', orderType: OrderType.Pickup } + * ]); + * + * // Access results + * const results = resource.priceAndRedemptionPoints(); + * ``` + */ +@Injectable({ providedIn: 'root' }) +export class PriceAndRedemptionPointsResource { + #catalogueSearchService = inject(CatalougeSearchService); + #availabilityService = inject(AvailabilityService); + #logger = logger(() => ({ resource: 'PriceAndRedemptionPoints' })); + + #items = signal(undefined); + + #priceAndRedemptionPointsResource = resource({ + params: computed(() => ({ items: this.#items() })), + loader: async ({ + params, + abortSignal, + }): Promise => { + if (!params?.items || params.items.length === 0) { + return []; + } + + // Extract unique EANs for catalog lookup + const eans = [...new Set(params.items.map((item) => item.ean))]; + + // Fetch catalog items to get redemption points + const catalogItems = await this.#catalogueSearchService.searchByEans( + eans, + abortSignal, + ); + + // Create a map for quick catalog lookup by EAN + const catalogByEan = new Map( + catalogItems.map((item) => [item.product.ean, item]), + ); + + // Fetch availability for each item individually (in parallel) + const availabilityPromises = params.items.map(async (checkItem) => { + const catalogItem = catalogByEan.get(checkItem.ean); + + // Skip items without catalog entry + if (!catalogItem?.id) { + return { ean: checkItem.ean, price: undefined }; + } + + try { + // Call getAvailability for single item + // InStore (Rücklage) has different schema: uses itemId instead of item object + const params = + checkItem.orderType === OrderType.InStore + ? { + orderType: checkItem.orderType, + branchId: checkItem.branchId, + itemId: catalogItem.id, + } + : { + orderType: checkItem.orderType, + branchId: checkItem.branchId, + item: { + itemId: catalogItem.id, + ean: checkItem.ean, + quantity: 1, + price: catalogItem.catalogAvailability?.price, + }, + }; + + const availability = await this.#availabilityService.getAvailability( + params as any, + abortSignal, + ); + + return { + ean: checkItem.ean, + price: availability?.price as AvailabilityPrice | undefined, + }; + } catch (error) { + this.#logger.error( + 'Failed to fetch availability for item', + error as Error, + () => ({ + ean: checkItem.ean, + orderType: checkItem.orderType, + branchId: checkItem.branchId, + }), + ); + return { ean: checkItem.ean, price: undefined }; + } + }); + + // Wait for all availability requests to complete + const availabilityResults = await Promise.all(availabilityPromises); + + // Build price map from results + const pricesByEan = new Map( + availabilityResults.map((result) => [result.ean, result.price]), + ); + + // Build final result: combine catalog prices, availability prices and redemption points + const results: PriceAndRedemptionPointsResult[] = eans.map((ean) => ({ + ean, + availabilityPrice: pricesByEan.get(ean), + catalogPrice: catalogByEan.get(ean)?.catalogAvailability?.price, + redemptionPoints: catalogByEan.get(ean)?.redemptionPoints, + })); + + return results; + }, + defaultValue: [], + }); + + readonly priceAndRedemptionPoints = + this.#priceAndRedemptionPointsResource.value.asReadonly(); + readonly loading = this.#priceAndRedemptionPointsResource.isLoading; + readonly error = computed( + () => this.#priceAndRedemptionPointsResource.error()?.message ?? null, + ); + + loadPriceAndRedemptionPoints(items: ItemWithOrderType[] | undefined) { + this.#items.set(items); + } + + refresh() { + this.#priceAndRedemptionPointsResource.reload(); + } +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.css b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.css new file mode 100644 index 000000000..dccd29424 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.css @@ -0,0 +1,3 @@ +:host { + @apply flex flex-row justify-end gap-2 w-full mt-6; +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.html new file mode 100644 index 000000000..795c77768 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.html @@ -0,0 +1,22 @@ + + diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.ts new file mode 100644 index 000000000..42d6abf92 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-actions/reward-selection-actions.component.ts @@ -0,0 +1,61 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + computed, + signal, +} from '@angular/core'; +import { ButtonComponent } from '@isa/ui/buttons'; +import { RewardSelectionStore } from '../store/reward-selection-dialog.store'; +import { RewardSelectionDialogComponent } from '../reward-selection-dialog.component'; +import { RewardSelectionFacade } from '@isa/checkout/data-access'; +import { injectTabId } from '@isa/core/tabs'; + +@Component({ + selector: 'lib-reward-selection-actions', + templateUrl: './reward-selection-actions.component.html', + styleUrl: './reward-selection-actions.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ButtonComponent], +}) +export class RewardSelectionActionsComponent { + host = inject(RewardSelectionDialogComponent, { host: true }); + #tabId = injectTabId(); + #store = inject(RewardSelectionStore); + #rewardSelectionFacade = inject(RewardSelectionFacade); + + insufficientLoyaltyPoints = computed( + () => + this.#store.totalLoyaltyPointsNeeded() > + this.#store.customerRewardPoints(), + ); + + completeRewardSelectionLoading = signal(false); + + onContinue() { + this.host.close(undefined); + } + + async onSave() { + const tabId = this.#tabId(); + if ( + !tabId || + this.insufficientLoyaltyPoints() || + this.completeRewardSelectionLoading() + ) { + return; + } + + this.completeRewardSelectionLoading.set(true); + const rewardSelectionItems = Object.values( + this.#store.rewardSelectionItems(), + ); + await this.#rewardSelectionFacade.completeRewardSelection({ + tabId, + rewardSelectionItems, + }); + this.completeRewardSelectionLoading.set(false); + + this.host.close({ rewardSelectionItems }); + } +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.css b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.html new file mode 100644 index 000000000..184f70f16 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.html @@ -0,0 +1,49 @@ +
+ + +
+ Zahlen mit + +
+
+ Lesepunkten + {{ + store.customerRewardPoints() + }} +
+ +
+ Warenkorb + EUR +
+
+
+ + + +
+ Gesamt + +
+
+ Lesepunkten + {{ + store.totalLoyaltyPointsNeeded() + }} +
+ +
+ Warenkorb + {{ store.totalPrice() }} EUR +
+
+
+ + +
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.ts new file mode 100644 index 000000000..d3787eaea --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-dialog.component.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { DialogContentDirective } from '@isa/ui/dialog'; +import { + RewardSelectionFacade, + RewardSelectionItem, +} from '@isa/checkout/data-access'; +import { RewardSelectionItemsComponent } from './reward-selection-items/reward-selection-items.component'; +import { RewardSelectionActionsComponent } from './reward-selection-actions/reward-selection-actions.component'; +import { RewardSelectionErrorComponent } from './reward-selection-error/reward-selection-error.component'; +import { RewardSelectionStore } from './store/reward-selection-dialog.store'; + +export type RewardSelectionDialogData = { + rewardSelectionItems: RewardSelectionItem[]; + customerRewardPoints: number; + closeText: string; +}; + +export type RewardSelectionDialogResult = + | { + rewardSelectionItems: RewardSelectionItem[]; + } + | undefined; + +@Component({ + selector: 'lib-reward-selection-dialog', + templateUrl: './reward-selection-dialog.component.html', + styleUrl: './reward-selection-dialog.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + RewardSelectionItemsComponent, + RewardSelectionActionsComponent, + RewardSelectionErrorComponent, + ], + providers: [RewardSelectionStore, RewardSelectionFacade], +}) +export class RewardSelectionDialogComponent extends DialogContentDirective< + RewardSelectionDialogData, + RewardSelectionDialogResult +> { + store = inject(RewardSelectionStore); + + constructor() { + super(); + this.store.initState(this.data); + } +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.css b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.css new file mode 100644 index 000000000..4fe16d287 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.css @@ -0,0 +1,3 @@ +:host { + @apply text-isa-accent-red isa-text-body-2-bold flex flex-row gap-2 items-center; +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.html new file mode 100644 index 000000000..201f2e798 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.html @@ -0,0 +1,8 @@ +@if (store.totalLoyaltyPointsNeeded() > store.customerRewardPoints()) { + + Lesepunkte reichen nicht für alle Artikel +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.ts new file mode 100644 index 000000000..451f02766 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-error/reward-selection-error.component.ts @@ -0,0 +1,16 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { isaOtherInfo } from '@isa/icons'; +import { RewardSelectionStore } from '../store/reward-selection-dialog.store'; + +@Component({ + selector: 'lib-reward-selection-error', + templateUrl: './reward-selection-error.component.html', + styleUrl: './reward-selection-error.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIcon], + providers: [provideIcons({ isaOtherInfo })], +}) +export class RewardSelectionErrorComponent { + store = inject(RewardSelectionStore); +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.css b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.css new file mode 100644 index 000000000..e65860fce --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.css @@ -0,0 +1,3 @@ +:host { + @apply flex flex-row gap-3; +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.html new file mode 100644 index 000000000..3fcfa451b --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.html @@ -0,0 +1,29 @@ +
+
{{ loyaltyValue() }} LP
+ + +
+ +
+
{{ priceValue() }} EUR
+ + +
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.ts new file mode 100644 index 000000000..6ee18471e --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-inputs/reward-selection-inputs.component.ts @@ -0,0 +1,74 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + computed, +} from '@angular/core'; +import { RewardSelectionItemComponent } from '../reward-selection-item.component'; +import { RewardSelectionStore } from '../../../store/reward-selection-dialog.store'; +import { getOrderTypeFeature, OrderType } from '@isa/checkout/data-access'; +import { calculatePriceValue } from '../../../helper/get-price.helper'; +import { calculateLoyaltyPointsValue } from '../../../helper/get-loyalty-points.helper'; +import { QuantityControlComponent } from '@isa/shared/quantity-control'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'lib-reward-selection-inputs', + templateUrl: './reward-selection-inputs.component.html', + styleUrl: './reward-selection-inputs.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, QuantityControlComponent], +}) +export class RewardSelectionInputsComponent { + store = inject(RewardSelectionStore); + rewardSelectionItem = inject(RewardSelectionItemComponent, { host: true }) + .rewardSelectionItem; + + loyaltyValue = computed(() => + calculateLoyaltyPointsValue(this.rewardSelectionItem()), + ); + + priceValue = computed(() => calculatePriceValue(this.rewardSelectionItem())); + + rewardCartQuantity = computed( + () => this.rewardSelectionItem()?.rewardCartQuantity ?? 0, + ); + + cartQuantity = computed(() => this.rewardSelectionItem()?.cartQuantity ?? 0); + + // Rücklage + orderTypeIsInStore = computed(() => { + const item = this.rewardSelectionItem().item; + return getOrderTypeFeature(item.features) === OrderType.InStore; + }); + + hasStock = computed(() => { + const item = this.rewardSelectionItem().item; + if (this.orderTypeIsInStore()) { + return !!item?.availability?.inStock && item.availability.inStock > 0; + } + return true; + }); + + maxQuantity = computed(() => { + const item = this.rewardSelectionItem().item; + if (this.orderTypeIsInStore()) { + return item?.availability?.inStock; + } + return undefined; + }); + + changeCartQuantity(quantity: number) { + this.store.updateItemCartQuantity( + this.rewardSelectionItem().item.id!, + quantity, + ); + } + + changeRewardCartQuantity(quantity: number) { + this.store.updateItemRewardCartQuantity( + this.rewardSelectionItem().item.id!, + quantity, + ); + } +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.css b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.css new file mode 100644 index 000000000..06ba4d420 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.css @@ -0,0 +1,3 @@ +:host { + @apply w-full flex flex-col gap-4; +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.html new file mode 100644 index 000000000..e80c67070 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.html @@ -0,0 +1,29 @@ +
+
+
+ +
+ +
+
+ {{ rewardSelectionItem().item.product.contributors }} +
+
+ {{ rewardSelectionItem().item.product.name }} +
+
+
+ + +
diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.ts new file mode 100644 index 000000000..da5891cdf --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-item/reward-selection-item.component.ts @@ -0,0 +1,20 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { ProductImageDirective } from '@isa/shared/product-image'; +import { ProductRouterLinkDirective } from '@isa/shared/product-router-link'; +import { RewardSelectionInputsComponent } from './reward-selection-inputs/reward-selection-inputs.component'; +import { RewardSelectionItem } from '@isa/checkout/data-access'; + +@Component({ + selector: 'lib-reward-selection-item', + templateUrl: './reward-selection-item.component.html', + styleUrl: './reward-selection-item.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ProductImageDirective, + ProductRouterLinkDirective, + RewardSelectionInputsComponent, + ], +}) +export class RewardSelectionItemComponent { + rewardSelectionItem = input.required(); +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.css b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.css new file mode 100644 index 000000000..ca97fc217 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.css @@ -0,0 +1,19 @@ +:host { + @apply grid grid-flow-row w-full items-center bg-isa-white max-h-96 overflow-hidden overflow-y-scroll; +} + +.item-group-header { + @apply flex flex-row items-center py-3 px-5 gap-3 bg-isa-neutral-100 rounded-t-2xl; +} + +.label { + @apply isa-text-body-1-bold text-isa-neutral-900; +} + +.branch-header { + @apply flex flex-row items-center px-5 py-3 bg-isa-neutral-100; +} + +.branch-name { + @apply isa-text-body-2-bold text-isa-neutral-900; +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.html new file mode 100644 index 000000000..0167b23b3 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.html @@ -0,0 +1,39 @@ +@if (showGrouping()) { + @for (group of groupedItems(); track group.orderType; let last = $last) { +
+ +
{{ group.orderType }}
+
+ + @for ( + subGroup of group.subGroups; + track subGroup.branchId ?? $index; + let isFirst = $first + ) { + @if (subGroup.branchName) { +
+ + {{ subGroup.branchName }} | {{ subGroup.branchAddress }} + +
+ } + + @for (item of subGroup.items; track item.item.id; let isLast = $last) { + + @if (!isLast) { +
+ } + } + } + } +} @else { + @for (item of items(); track item.item.id; let last = $last) { + + @if (!last) { +
+ } + } +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.ts new file mode 100644 index 000000000..5d1aedea3 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/reward-selection-items/reward-selection-items.component.ts @@ -0,0 +1,52 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, +} from '@angular/core'; +import { RewardSelectionItemComponent } from './reward-selection-item/reward-selection-item.component'; +import { RewardSelectionStore } from '../store/reward-selection-dialog.store'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + isaDeliveryVersand, + isaDeliveryRuecklage2, + isaDeliveryRuecklage1, + isaDeliveryB2BVersand1, +} from '@isa/icons'; +import { getOrderTypeIcon } from '../helper/get-order-type-icon.helper'; +import { groupByOrderType } from '../helper/group-by-order-type.helper'; +import { groupByBranch } from '../helper/group-by-branch.helper'; +import { shouldShowGrouping } from '../helper/should-show-grouping.helper'; + +@Component({ + selector: 'lib-reward-selection-items', + templateUrl: './reward-selection-items.component.html', + styleUrl: './reward-selection-items.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RewardSelectionItemComponent, NgIcon], + providers: [ + provideIcons({ + isaDeliveryVersand, + isaDeliveryRuecklage2, + isaDeliveryRuecklage1, + isaDeliveryB2BVersand1, + }), + ], +}) +export class RewardSelectionItemsComponent { + store = inject(RewardSelectionStore); + + items = computed(() => Object.values(this.store.rewardSelectionItems())); + showGrouping = computed(() => shouldShowGrouping(this.items())); + + groupedItems = computed(() => { + if (!this.showGrouping()) return []; + + const groups = groupByOrderType(this.items()); + return Array.from(groups.entries()).map(([orderType, items]) => ({ + orderType, + icon: getOrderTypeIcon(orderType), + subGroups: groupByBranch(orderType, items), + })); + }); +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/service/reward-selection-pop-up.service.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/service/reward-selection-pop-up.service.ts new file mode 100644 index 000000000..9cacaa9e1 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/service/reward-selection-pop-up.service.ts @@ -0,0 +1,162 @@ +import { inject, Injectable } from '@angular/core'; +import { injectConfirmationDialog, injectFeedbackDialog } from '@isa/ui/dialog'; +import { firstValueFrom } from 'rxjs'; +import { RewardSelectionService } from './reward-selection.service'; +import { CheckoutMetadataService } from '@isa/checkout/data-access'; +import { injectTabId } from '@isa/core/tabs'; + +export const NavigateAfterRewardSelection = { + CART: 'cart', + REWARD: 'reward', + CATALOG: 'catalog', +} as const; +export type NavigateAfterRewardSelection = + (typeof NavigateAfterRewardSelection)[keyof typeof NavigateAfterRewardSelection]; + +@Injectable() +export class RewardSelectionPopUpService { + #tabId = injectTabId(); + #feedbackDialog = injectFeedbackDialog(); + #confirmationDialog = injectConfirmationDialog(); + #rewardSelectionService = inject(RewardSelectionService); + #checkoutMetadataService = inject(CheckoutMetadataService); + + /** + * Displays the reward selection popup dialog if conditions are met. + * + * This method manages the complete flow of the reward selection popup: + * 1. Checks if the popup has already been shown in the current tab (prevents duplicate displays) + * 2. Reloads necessary resources for the reward selection dialog + * 3. Opens the reward selection dialog if conditions allow + * 4. Marks the popup as opened for the current tab using {@link #setPopUpOpenedState} + * 5. Processes user selections and determines navigation flow + * + * @returns A promise that resolves to: + * - `NavigateAfterRewardSelection.CART` - Navigate to the shopping cart + * - `NavigateAfterRewardSelection.REWARD` - Navigate to the reward cart + * - `NavigateAfterRewardSelection.CATALOG` - Navigate back to the catalog + * - `undefined` - Stay on the current page (e.g., when all quantities are set to 0) + * + * @example + * ```typescript + * const result = await rewardSelectionPopUpService.popUp(); + * if (result === NavigateAfterRewardSelection.CART) { + * this.router.navigate(['/cart']); + * } + * ``` + */ + async popUp(): Promise { + if (this.#popUpAlreadyOpened(this.#tabId())) { + return NavigateAfterRewardSelection.CART; + } + + await this.#rewardSelectionService.reloadResources(); + + if (this.#rewardSelectionService.canOpen()) { + const dialogResult = await this.#rewardSelectionService.open({ + closeText: 'Weiter einkaufen', + }); + + this.#setPopUpOpenedState(this.#tabId(), true); + + if (dialogResult) { + // Wenn der User alles 0 setzt, bleibt man einfach auf der aktuellen Seite + if (dialogResult?.rewardSelectionItems?.length === 0) { + await this.#feedback(); + return undefined; + } + + if (dialogResult.rewardSelectionItems?.length > 0) { + const hasRegularCartItems = dialogResult.rewardSelectionItems?.some( + (item) => item?.cartQuantity > 0, + ); + const hasRewardCartItems = dialogResult.rewardSelectionItems?.some( + (item) => item?.rewardCartQuantity > 0, + ); + + return await this.#confirmDialog( + hasRegularCartItems, + hasRewardCartItems, + ); + } + } + } + + return NavigateAfterRewardSelection.CART; + } + + async #feedback() { + this.#feedbackDialog({ + data: { message: 'Auswahl gespeichert' }, + }); + } + + async #confirmDialog( + hasRegularCartItems: boolean, + hasRewardCartItems: boolean, + ): Promise { + const title = hasRewardCartItems + ? 'Artikel wurde der Prämienausgabe hinzugefügt' + : 'Artikel wurde zum Warenkorb hinzugefügt'; + + const message = hasRegularCartItems + ? 'Bitte schließen sie erst den Warenkorb ab und dann die Prämienausgabe' + : hasRegularCartItems && !hasRewardCartItems + ? 'Bitte schließen sie den Warenkorb ab' + : ''; + + const dialogRef = this.#confirmationDialog({ + title, + data: { + message, + closeText: 'Weiter einkaufen', + confirmText: hasRegularCartItems + ? 'Zum Warenkorb' + : 'Zur Prämienausgabe', + }, + }); + + const dialogResult = await firstValueFrom(dialogRef.closed); + + if (dialogResult) { + if (dialogResult.confirmed && hasRegularCartItems) { + return NavigateAfterRewardSelection.CART; + } else if (dialogResult.confirmed) { + return NavigateAfterRewardSelection.REWARD; + } else { + return NavigateAfterRewardSelection.CATALOG; + } + } + + return undefined; + } + + #popUpAlreadyOpened(tabId: number | null): boolean | undefined { + if (tabId == null) return; + return this.#checkoutMetadataService.getRewardSelectionPopupOpenedState( + tabId, + ); + } + + /** + * Sets the opened state of the reward selection popup for a specific tab. + * + * This method persists the popup state to prevent the popup from being displayed + * multiple times within the same tab session. It's called after successfully + * opening the reward selection dialog. + * + * @param tabId - The unique identifier of the tab. If null, the method returns early without setting state. + * @param opened - The opened state to set. `true` indicates the popup has been shown, `false` or `undefined` resets the state. + * + * @remarks + * This state is typically set to `true` after the user has seen the popup to ensure + * it doesn't appear again during the same browsing session in that tab. + */ + #setPopUpOpenedState(tabId: number | null, opened: boolean | undefined) { + if (tabId == null) return; + this.#checkoutMetadataService.setRewardSelectionPopupOpenedState( + tabId, + opened, + ); + } +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/service/reward-selection.service.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/service/reward-selection.service.ts new file mode 100644 index 000000000..916046177 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/service/reward-selection.service.ts @@ -0,0 +1,204 @@ +import { computed, effect, inject, Injectable, untracked } from '@angular/core'; +import { + SelectedRewardShoppingCartResource, + SelectedShoppingCartResource, + ShoppingCartItem, + getOrderTypeFeature, +} from '@isa/checkout/data-access'; +import { injectDialog } from '@isa/ui/dialog'; +import { + RewardSelectionDialogComponent, + RewardSelectionDialogResult, +} from '../reward-selection-dialog.component'; +import { + getPrimaryBonusCard, + SelectedCustomerBonusCardsResource, +} from '@isa/crm/data-access'; +import { firstValueFrom } from 'rxjs'; +import { RewardSelectionItem } from '@isa/checkout/data-access'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { filter, first } from 'rxjs/operators'; +import { itemSelectionChanged } from '../helper/item-selection-changed.helper'; +import { mergeRewardSelectionItems } from '../helper/merge-reward-selection-items.helper'; +import { + PriceAndRedemptionPointsResource, + ItemWithOrderType, +} from '../resource/price-and-redemption-points.resource'; + +@Injectable() +export class RewardSelectionService { + priceAndRedemptionPointsResource = inject(PriceAndRedemptionPointsResource); + rewardSelectionDialog = injectDialog(RewardSelectionDialogComponent); + + #customerBonusCardsResource = inject(SelectedCustomerBonusCardsResource) + .resource; + #shoppingCartResource = inject(SelectedShoppingCartResource).resource; + #rewardShoppingCartResource = inject(SelectedRewardShoppingCartResource) + .resource; + + readonly shoppingCartResponseValue = + this.#shoppingCartResource.value.asReadonly(); + + readonly rewardShoppingCartResponseValue = + this.#rewardShoppingCartResource.value.asReadonly(); + + readonly customerCardResponseValue = + this.#customerBonusCardsResource.value.asReadonly(); + + readonly priceAndRedemptionPoints = + this.priceAndRedemptionPointsResource.priceAndRedemptionPoints; + + shoppingCartItems = computed(() => { + return ( + this.shoppingCartResponseValue() + ?.items?.map((item) => item?.data as ShoppingCartItem) + .filter((item): item is ShoppingCartItem => item != null) ?? [] + ); + }); + + rewardShoppingCartItems = computed(() => { + return ( + this.rewardShoppingCartResponseValue() + ?.items?.map((item) => item?.data as ShoppingCartItem) + .filter((item): item is ShoppingCartItem => item != null) ?? [] + ); + }); + + mergedRewardSelectionItems = computed(() => { + return mergeRewardSelectionItems( + this.shoppingCartItems(), + this.rewardShoppingCartItems(), + ); + }); + + selectionItemsWithOrderType = computed(() => { + return this.mergedRewardSelectionItems() + .map((item): ItemWithOrderType | null => { + const ean = item.item.product.ean; + const orderType = getOrderTypeFeature(item.item.features); + const branchId = item.item.destination?.data?.targetBranch?.data?.id; + + if (!ean || !orderType) { + return null; + } + + return { ean, orderType, branchId }; + }) + .filter((item): item is ItemWithOrderType => item !== null); + }); + + updatedRewardSelectionItems = computed(() => { + const rewardSelectionItems = this.mergedRewardSelectionItems(); + const priceAndRedemptionResults = this.priceAndRedemptionPoints(); + + return rewardSelectionItems.map((selectionItem) => { + const ean = selectionItem.item.product.ean; + const result = priceAndRedemptionResults?.find((r) => r.ean === ean); + + return { + ...selectionItem, + catalogPrice: result?.catalogPrice, + catalogRewardPoints: result?.redemptionPoints, + availabilityPrice: result?.availabilityPrice, + }; + }); + }); + + bonusCards = computed(() => { + return this.customerCardResponseValue() ?? []; + }); + + primaryBonusCard = computed(() => getPrimaryBonusCard(this.bonusCards())); + primaryBonusCardPoints = computed( + () => this.primaryBonusCard()?.totalPoints ?? 0, + ); + + isLoading = computed( + () => + this.#shoppingCartResource.isLoading() || + this.#rewardShoppingCartResource.isLoading() || + this.#customerBonusCardsResource.isLoading() || + this.priceAndRedemptionPointsResource.loading(), + ); + #isLoading$ = toObservable(this.isLoading); + + eligibleItems = computed(() => + this.updatedRewardSelectionItems().filter( + (selectionItem) => + (selectionItem.item.loyalty?.value != null && + selectionItem.item.loyalty.value !== 0) || + (selectionItem?.catalogRewardPoints != null && + selectionItem.catalogRewardPoints !== 0), + ), + ); + + canOpen = computed( + () => this.eligibleItems().length > 0 && !!this.primaryBonusCard(), + ); + + constructor() { + effect(() => { + const items = this.selectionItemsWithOrderType(); + + untracked(() => { + const resourceLoading = this.priceAndRedemptionPointsResource.loading(); + if (!resourceLoading && items.length > 0) { + this.priceAndRedemptionPointsResource.loadPriceAndRedemptionPoints( + items, + ); + } + }); + }); + } + + async open({ + closeText, + }: { + closeText: string; + }): Promise { + const rewardSelectionItems = this.eligibleItems(); + console.log(rewardSelectionItems); + const dialogRef = this.rewardSelectionDialog({ + title: 'Ein oder mehrere Artikel sind als Prämie verfügbar', + data: { + rewardSelectionItems, + customerRewardPoints: this.primaryBonusCardPoints(), + closeText, + }, + displayClose: true, + disableClose: false, + width: '44.5rem', + }); + const dialogResult = await firstValueFrom(dialogRef.closed); + + if ( + itemSelectionChanged( + rewardSelectionItems, + dialogResult?.rewardSelectionItems, + ) + ) { + return dialogResult; + } + + return undefined; + } + + async reloadResources(): Promise { + // Start reloading all resources + // Note: Price and redemption points will be loaded automatically by the effect + // when selectionItemsWithOrderType changes after cart resources are reloaded + await Promise.all([ + this.#shoppingCartResource.reload(), + this.#rewardShoppingCartResource.reload(), + this.#customerBonusCardsResource.reload(), + ]); + + // Wait until all resources are fully loaded (isLoading becomes false) + await firstValueFrom( + this.#isLoading$.pipe( + filter((isLoading) => !isLoading), + first(), + ), + ); + } +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/store/reward-selection-dialog.store.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/store/reward-selection-dialog.store.ts new file mode 100644 index 000000000..0828d98d4 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/store/reward-selection-dialog.store.ts @@ -0,0 +1,106 @@ +import { + patchState, + signalStore, + withComputed, + withMethods, + withState, +} from '@ngrx/signals'; +import { RewardSelectionItem } from '@isa/checkout/data-access'; +import { computed } from '@angular/core'; +import { calculatePriceValue } from '../helper/get-price.helper'; +import { calculateLoyaltyPointsValue } from '../helper/get-loyalty-points.helper'; + +interface RewardSelectionState { + // Single source of truth for all items with their allocations + rewardSelectionItems: Record; + customerRewardPoints: number; +} + +const initialState: RewardSelectionState = { + rewardSelectionItems: {}, + customerRewardPoints: 0, +}; + +export const RewardSelectionStore = signalStore( + withState(initialState), + withComputed((store) => ({ + // Calculate total loyalty points needed for all selected reward items + totalLoyaltyPointsNeeded: computed(() => + Object.values(store.rewardSelectionItems()).reduce( + (sum, item) => + sum + + calculateLoyaltyPointsValue(item) * (item?.rewardCartQuantity ?? 0), + 0, + ), + ), + + // Calculate total price for all selected items + totalPrice: computed(() => + Object.values(store.rewardSelectionItems()).reduce( + (sum, item) => + sum + calculatePriceValue(item) * (item?.cartQuantity ?? 0), + 0, + ), + ), + })), + withMethods((store) => ({ + /** + * Initialize store with cart and reward items + */ + initState({ + rewardSelectionItems, + customerRewardPoints, + }: { + rewardSelectionItems: RewardSelectionItem[]; + customerRewardPoints: number; + }) { + const rewardSelectionItemsMapped = rewardSelectionItems.reduce( + (acc, item) => { + const itemId = item.item.id; + if (itemId !== undefined) { + acc[itemId] = item; + } + return acc; + }, + {} as Record, + ); + + patchState(store, { + rewardSelectionItems: rewardSelectionItemsMapped, + customerRewardPoints, + }); + }, + + /** + * Update the quantity of an item in the regular cart + */ + updateItemCartQuantity(itemId: number, quantity: number) { + const currentItem = store.rewardSelectionItems()[itemId]; + patchState(store, { + rewardSelectionItems: { + ...store.rewardSelectionItems(), + [itemId]: { + ...currentItem, + cartQuantity: quantity, + }, + }, + }); + }, + + /** + * Update the quantity of an item in the reward cart + */ + updateItemRewardCartQuantity(itemId: number, quantity: number) { + const currentItem = store.rewardSelectionItems()[itemId]; + patchState(store, { + rewardSelectionItems: { + ...store.rewardSelectionItems(), + [itemId]: { + ...currentItem, + rewardCartQuantity: quantity, + }, + }, + }); + }, + })), +); diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/trigger/reward-selection-trigger.component.html b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/trigger/reward-selection-trigger.component.html new file mode 100644 index 000000000..898aeb790 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/trigger/reward-selection-trigger.component.html @@ -0,0 +1,12 @@ +@if (canOpen() || isLoading()) { + +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/trigger/reward-selection-trigger.component.ts b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/trigger/reward-selection-trigger.component.ts new file mode 100644 index 000000000..9077239e9 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/lib/reward-selection-dialog/trigger/reward-selection-trigger.component.ts @@ -0,0 +1,97 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { TextButtonComponent } from '@isa/ui/buttons'; +import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader'; +import { injectTabId } from '@isa/core/tabs'; +import { DomainCheckoutService } from '@domain/checkout'; +import { injectFeedbackDialog } from '@isa/ui/dialog'; +import { RewardSelectionService } from '../service/reward-selection.service'; +import { Router } from '@angular/router'; +import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access'; +import { + SelectedRewardShoppingCartResource, + SelectedShoppingCartResource, +} from '@isa/checkout/data-access'; +import { CheckoutNavigationService } from '@shared/services/navigation'; + +@Component({ + selector: 'lib-reward-selection-trigger', + templateUrl: './reward-selection-trigger.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TextButtonComponent, SkeletonLoaderDirective], + providers: [ + SelectedShoppingCartResource, + SelectedRewardShoppingCartResource, + SelectedCustomerBonusCardsResource, + RewardSelectionService, + ], +}) +export class RewardSelectionTriggerComponent { + #router = inject(Router); + #tabId = injectTabId(); + #feedbackDialog = injectFeedbackDialog(); + #rewardSelectionService = inject(RewardSelectionService); + #domainCheckoutService = inject(DomainCheckoutService); + #checkoutNavigationService = inject(CheckoutNavigationService); + + canOpen = this.#rewardSelectionService.canOpen; + isLoading = this.#rewardSelectionService.isLoading; + + async openRewardSelectionDialog() { + const tabId = this.#tabId(); + const dialogResult = await this.#rewardSelectionService.open({ + closeText: 'Abbrechen', + }); + + if (dialogResult && tabId) { + await this.#reloadShoppingCart(tabId); + + // Wenn der User alles 0 setzt, bleibt man einfach auf der aktuellen Seite + if (dialogResult.rewardSelectionItems?.length === 0) { + await this.#feedback(); + } + + if (dialogResult.rewardSelectionItems?.length > 0) { + const hasRegularCartItems = dialogResult.rewardSelectionItems?.some( + (item) => item?.cartQuantity > 0, + ); + const hasRewardCartItems = dialogResult.rewardSelectionItems?.some( + (item) => item?.rewardCartQuantity > 0, + ); + + await this.#feedback(); + + // Wenn Nutzer im Warenkorb ist und alle Items als Prämie setzt -> Navigation zum Prämien Checkout + if (!hasRegularCartItems && hasRewardCartItems) { + await this.#navigateToRewardCheckout(tabId); + } + + // Wenn Nutzer im Prämien Checkout ist und alle Items in den Warenkorb setzt -> Navigation zu Warenkorb + if (hasRegularCartItems && !hasRewardCartItems) { + await this.#navigateToCheckout(tabId); + } + } + } + } + + async #reloadShoppingCart(tabId: number) { + await this.#domainCheckoutService.reloadShoppingCart({ + processId: tabId, + }); + } + + async #feedback() { + this.#feedbackDialog({ + data: { message: 'Auswahl gespeichert' }, + }); + } + + async #navigateToRewardCheckout(tabId: number) { + await this.#router.navigate([`/${tabId}`, 'reward', 'cart']); + } + + async #navigateToCheckout(tabId: number) { + await this.#checkoutNavigationService + .getCheckoutReviewPath(tabId) + .navigate(); + } +} diff --git a/libs/checkout/shared/reward-selection-dialog/src/test-setup.ts b/libs/checkout/shared/reward-selection-dialog/src/test-setup.ts new file mode 100644 index 000000000..cebf5ae72 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/src/test-setup.ts @@ -0,0 +1,13 @@ +import '@angular/compiler'; +import '@analogjs/vitest-angular/setup-zone'; + +import { + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; +import { getTestBed } from '@angular/core/testing'; + +getTestBed().initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting(), +); diff --git a/libs/checkout/shared/reward-selection-dialog/tsconfig.json b/libs/checkout/shared/reward-selection-dialog/tsconfig.json new file mode 100644 index 000000000..06f8b89a6 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "importHelpers": true, + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "module": "preserve" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "typeCheckHostBindings": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/checkout/shared/reward-selection-dialog/tsconfig.lib.json b/libs/checkout/shared/reward-selection-dialog/tsconfig.lib.json new file mode 100644 index 000000000..9259117c2 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/tsconfig.lib.json @@ -0,0 +1,27 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/test-setup.ts", + "jest.config.ts", + "src/**/*.test.ts", + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx" + ], + "include": ["src/**/*.ts"] +} diff --git a/libs/checkout/shared/reward-selection-dialog/tsconfig.spec.json b/libs/checkout/shared/reward-selection-dialog/tsconfig.spec.json new file mode 100644 index 000000000..b2f92f3ec --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/tsconfig.spec.json @@ -0,0 +1,29 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ], + "files": ["src/test-setup.ts"] +} diff --git a/libs/checkout/shared/reward-selection-dialog/vite.config.mts b/libs/checkout/shared/reward-selection-dialog/vite.config.mts new file mode 100644 index 000000000..d5e79cbb4 --- /dev/null +++ b/libs/checkout/shared/reward-selection-dialog/vite.config.mts @@ -0,0 +1,29 @@ +/// +import { defineConfig } from 'vite'; +import angular from '@analogjs/vite-plugin-angular'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'; + +export default defineConfig(() => ({ + root: __dirname, + cacheDir: + '../../../../node_modules/.vite/libs/checkout/shared/reward-selection-dialog', + plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])], + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + test: { + watch: false, + globals: true, + environment: 'jsdom', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + setupFiles: ['src/test-setup.ts'], + reporters: ['default'], + coverage: { + reportsDirectory: + '../../../../coverage/libs/checkout/shared/reward-selection-dialog', + provider: 'v8' as const, + }, + }, +})); diff --git a/libs/oms/feature/return-process/src/lib/return-process-questions/return-process-product-question/return-process-product-question.component.ts b/libs/oms/feature/return-process/src/lib/return-process-questions/return-process-product-question/return-process-product-question.component.ts index 6d4197100..597cca99a 100644 --- a/libs/oms/feature/return-process/src/lib/return-process-questions/return-process-product-question/return-process-product-question.component.ts +++ b/libs/oms/feature/return-process/src/lib/return-process-questions/return-process-product-question/return-process-product-question.component.ts @@ -26,6 +26,7 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { filter, pipe, switchMap, tap } from 'rxjs'; import { CatalougeSearchService } from '@isa/catalogue/data-access'; import { tapResponse } from '@ngrx/operators'; +import { from } from 'rxjs'; import { ProductImageDirective } from '@isa/shared/product-image'; import { provideIcons } from '@ng-icons/core'; import { isaActionScanner } from '@isa/icons'; @@ -118,8 +119,10 @@ export class ReturnProcessProductQuestionComponent { this.status.set({ fetching: true, hasResult: undefined }); }), switchMap(() => - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.#catalogueSearchService.searchByEans(this.control.value!).pipe( + + from( + this.#catalogueSearchService.searchByEans([this.control.value!]), + ).pipe( tapResponse({ next: (value) => { if (!!value[0] && value[0].product) { diff --git a/libs/shared/quantity-control/src/lib/quantity-control.component.spec.ts b/libs/shared/quantity-control/src/lib/quantity-control.component.spec.ts index 2ac5b5b09..b4909c4e1 100644 --- a/libs/shared/quantity-control/src/lib/quantity-control.component.spec.ts +++ b/libs/shared/quantity-control/src/lib/quantity-control.component.spec.ts @@ -9,7 +9,10 @@ describe('QuantityControlComponent', () => { let component: QuantityControlComponent; let fixture: ComponentFixture; let liveAnnouncerMock: { announce: ReturnType }; - let tooltipMock: { show: ReturnType; hide: ReturnType }; + let tooltipMock: { + show: ReturnType; + hide: ReturnType; + }; beforeEach(async () => { liveAnnouncerMock = { @@ -43,16 +46,16 @@ describe('QuantityControlComponent', () => { expect(component).toBeTruthy(); }); - it('should have default value of 1', () => { - expect(component.value()).toBe(1); + it('should have default value of 0', () => { + expect(component.value()).toBe(0); }); it('should not be disabled by default', () => { expect(component.disabled()).toBe(false); }); - it('should have default min of 1', () => { - expect(component.min()).toBe(1); + it('should have default min of 0', () => { + expect(component.min()).toBe(0); }); it('should have default max of undefined', () => { @@ -68,9 +71,9 @@ describe('QuantityControlComponent', () => { it('should generate options from min to presetLimit with Edit', () => { const options = component.generatedOptions(); - expect(options).toHaveLength(11); // 1-10 + Edit - expect(options[0]).toEqual({ value: 1, label: '1' }); - expect(options[9]).toEqual({ value: 10, label: '10' }); + expect(options).toHaveLength(11); // 0-9 + Edit + expect(options[0]).toEqual({ value: 0, label: '0' }); + expect(options[9]).toEqual({ value: 9, label: '9' }); expect(options[10]).toEqual({ value: 'edit', label: 'Edit' }); }); @@ -85,6 +88,7 @@ describe('QuantityControlComponent', () => { }); it('should not show Edit option when max is less than or equal to presetLimit', () => { + fixture.componentRef.setInput('min', 1); fixture.componentRef.setInput('max', 5); fixture.componentRef.setInput('presetLimit', 10); fixture.detectChanges(); @@ -105,7 +109,10 @@ describe('QuantityControlComponent', () => { const options = component.generatedOptions(); expect(options).toHaveLength(11); // 1-10 + Edit - expect(options[options.length - 1]).toEqual({ value: 'edit', label: 'Edit' }); + expect(options[options.length - 1]).toEqual({ + value: 'edit', + label: 'Edit', + }); }); it('should generate correct range with custom min and presetLimit', () => { @@ -159,7 +166,7 @@ describe('QuantityControlComponent', () => { expect(liveAnnouncerMock.announce).toHaveBeenCalledWith( 'Quantity changed to 8', - 'polite' + 'polite', ); }); }); @@ -217,7 +224,7 @@ describe('QuantityControlComponent', () => { expect(liveAnnouncerMock.announce).toHaveBeenCalledWith( 'Edit mode activated. Type a quantity and press Enter to confirm or Escape to cancel.', - 'polite' + 'polite', ); }); @@ -241,7 +248,7 @@ describe('QuantityControlComponent', () => { expect(component.value()).toBe(1); // Clamped to min expect(liveAnnouncerMock.announce).toHaveBeenCalledWith( 'Adjusted to minimum 1', - 'polite' + 'polite', ); }); @@ -256,7 +263,7 @@ describe('QuantityControlComponent', () => { expect(component.value()).toBe(50); // Clamped to max expect(liveAnnouncerMock.announce).toHaveBeenCalledWith( 'Adjusted to maximum 50', - 'polite' + 'polite', ); }); @@ -270,7 +277,7 @@ describe('QuantityControlComponent', () => { expect(component.value()).toBe(originalValue); // Value unchanged expect(liveAnnouncerMock.announce).toHaveBeenCalledWith( 'Invalid input. Please enter a valid number.', - 'assertive' + 'assertive', ); }); @@ -291,7 +298,7 @@ describe('QuantityControlComponent', () => { expect(liveAnnouncerMock.announce).toHaveBeenCalledWith( 'Edit mode cancelled', - 'polite' + 'polite', ); }); diff --git a/libs/shared/quantity-control/src/lib/quantity-control.component.ts b/libs/shared/quantity-control/src/lib/quantity-control.component.ts index d527877c5..ca6e2382c 100644 --- a/libs/shared/quantity-control/src/lib/quantity-control.component.ts +++ b/libs/shared/quantity-control/src/lib/quantity-control.component.ts @@ -91,9 +91,9 @@ export class QuantityControlComponent implements ControlValueAccessor { /** * Current quantity value - * @default 1 + * @default 0 */ - value = model(1); + value = model(0); /** * Whether the control is disabled @@ -103,9 +103,9 @@ export class QuantityControlComponent implements ControlValueAccessor { /** * Minimum selectable value (starting point for dropdown options). - * @default 1 + * @default 0 */ - min = input(1, { + min = input(0, { transform: coerceNumberProperty, }); diff --git a/tsconfig.base.json b/tsconfig.base.json index cbc97288c..014bdf195 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -53,6 +53,9 @@ "@isa/checkout/shared/product-info": [ "libs/checkout/shared/product-info/src/index.ts" ], + "@isa/checkout/shared/reward-selection-dialog": [ + "libs/checkout/shared/reward-selection-dialog/src/index.ts" + ], "@isa/common/data-access": ["libs/common/data-access/src/index.ts"], "@isa/common/decorators": ["libs/common/decorators/src/index.ts"], "@isa/common/print": ["libs/common/print/src/index.ts"],