mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
committed by
Lorenz Hilpert
parent
596ae1da1b
commit
b5c8dc4776
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -13,8 +13,12 @@
|
||||
keinen Artikel hinzugefügt.
|
||||
</p>
|
||||
<div class="btn-wrapper">
|
||||
<a class="cta-primary" [routerLink]="productSearchBasePath">Artikel suchen</a>
|
||||
<button class="cta-secondary" (click)="openDummyModal({})">Neuanlage</button>
|
||||
<a class="cta-primary" [routerLink]="productSearchBasePath"
|
||||
>Artikel suchen</a
|
||||
>
|
||||
<button class="cta-secondary" (click)="openDummyModal({})">
|
||||
Neuanlage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24,11 +28,22 @@
|
||||
<div class="cta-print-wrapper">
|
||||
<button class="cta-print" (click)="openPrintModal()">Drucken</button>
|
||||
</div>
|
||||
<h1 class="header">Warenkorb</h1>
|
||||
<div class="header-container">
|
||||
<h1 class="header">Warenkorb</h1>
|
||||
@if (orderTypesExist$ | async) {
|
||||
<lib-reward-selection-trigger
|
||||
class="pb-2 desktop-large:pb-0"
|
||||
></lib-reward-selection-trigger>
|
||||
}
|
||||
</div>
|
||||
@if (!(isDesktop$ | async)) {
|
||||
<page-checkout-review-details></page-checkout-review-details>
|
||||
}
|
||||
@for (group of groupedItems$ | async; track trackByGroupedItems($index, group); let lastGroup = $last) {
|
||||
@for (
|
||||
group of groupedItems$ | async;
|
||||
track trackByGroupedItems($index, group);
|
||||
let lastGroup = $last
|
||||
) {
|
||||
@if (group?.orderType !== undefined) {
|
||||
<hr />
|
||||
<div class="row item-group-header bg-[#F5F7FA]">
|
||||
@@ -40,20 +55,31 @@
|
||||
></shared-icon>
|
||||
}
|
||||
<div class="label" [class.dummy]="group.orderType === 'Dummy'">
|
||||
{{ group.orderType !== 'Dummy' ? group.orderType : 'Manuelle Anlage / Dummy Bestellung' }}
|
||||
{{
|
||||
group.orderType !== 'Dummy'
|
||||
? group.orderType
|
||||
: 'Manuelle Anlage / Dummy Bestellung'
|
||||
}}
|
||||
@if (group.orderType === 'Dummy') {
|
||||
<button
|
||||
class="text-brand border-none font-bold text-p1 outline-none pl-4"
|
||||
(click)="openDummyModal({ changeDataFromCart: true })"
|
||||
>
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
@if (group.orderType !== 'Download' && group.orderType !== 'Dummy') {
|
||||
@if (
|
||||
group.orderType !== 'Download' && group.orderType !== 'Dummy'
|
||||
) {
|
||||
<div class="pl-4">
|
||||
<button class="cta-edit" (click)="showPurchasingListModal(group.items)">Ändern</button>
|
||||
<button
|
||||
class="cta-edit"
|
||||
(click)="showPurchasingListModal(group.items)"
|
||||
>
|
||||
Ändern
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -62,20 +88,44 @@
|
||||
group.orderType === 'Versand' ||
|
||||
group.orderType === 'B2B-Versand' ||
|
||||
group.orderType === 'DIG-Versand'
|
||||
) {
|
||||
<hr
|
||||
/>
|
||||
) {
|
||||
<hr />
|
||||
}
|
||||
}
|
||||
@for (item of group.items; track trackByItemId(i, item); let lastItem = $last; let i = $index) {
|
||||
@if (group?.orderType !== undefined && (item.features?.orderType === 'Abholung' || item.features?.orderType === 'Rücklage')) {
|
||||
@for (
|
||||
item of group.items;
|
||||
track trackByItemId(i, item);
|
||||
let lastItem = $last;
|
||||
let i = $index
|
||||
) {
|
||||
@if (
|
||||
group?.orderType !== undefined &&
|
||||
(item.features?.orderType === 'Abholung' ||
|
||||
item.features?.orderType === 'Rücklage')
|
||||
) {
|
||||
@if (item?.destination?.data?.targetBranch?.data; as targetBranch) {
|
||||
@if (i === 0 || checkIfMultipleDestinationsForOrderTypeExist(targetBranch, group, i)) {
|
||||
@if (
|
||||
i === 0 ||
|
||||
checkIfMultipleDestinationsForOrderTypeExist(
|
||||
targetBranch,
|
||||
group,
|
||||
i
|
||||
)
|
||||
) {
|
||||
<div
|
||||
class="flex flex-row items-center px-5 pt-0 pb-[0.875rem] -mt-2 bg-[#F5F7FA]"
|
||||
[class.multiple-destinations]="checkIfMultipleDestinationsForOrderTypeExist(targetBranch, group, i)"
|
||||
[class.multiple-destinations]="
|
||||
checkIfMultipleDestinationsForOrderTypeExist(
|
||||
targetBranch,
|
||||
group,
|
||||
i
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="branch-name"
|
||||
>{{ targetBranch?.name }} |
|
||||
{{ targetBranch | branchAddress }}</span
|
||||
>
|
||||
<span class="branch-name">{{ targetBranch?.name }} | {{ targetBranch | branchAddress }}</span>
|
||||
</div>
|
||||
<hr />
|
||||
}
|
||||
@@ -85,7 +135,9 @@
|
||||
(changeItem)="changeItem($event)"
|
||||
(changeDummyItem)="changeDummyItem($event)"
|
||||
(changeQuantity)="updateItemQuantity($event)"
|
||||
[quantityError]="(quantityError$ | async)[item.product.catalogProductNumber]"
|
||||
[quantityError]="
|
||||
(quantityError$ | async)[item.product.catalogProductNumber]
|
||||
"
|
||||
[item]="item"
|
||||
[orderType]="group?.orderType"
|
||||
[loadingOnItemChangeById]="loadingOnItemChangeById$ | async"
|
||||
@@ -109,7 +161,11 @@
|
||||
}
|
||||
<div class="flex flex-col w-full">
|
||||
<strong class="total-value">
|
||||
Zwischensumme {{ shoppingCart?.total?.value | currency: shoppingCart?.total?.currency : 'code' }}
|
||||
Zwischensumme
|
||||
{{
|
||||
shoppingCart?.total?.value
|
||||
| currency: shoppingCart?.total?.currency : 'code'
|
||||
}}
|
||||
</strong>
|
||||
<span class="shipping-cost-info">ohne Versandkosten</span>
|
||||
</div>
|
||||
@@ -119,11 +175,13 @@
|
||||
(click)="order()"
|
||||
[disabled]="
|
||||
showOrderButtonSpinner ||
|
||||
((primaryCtaLabel$ | async) === 'Bestellen' && !(checkNotificationChannelControl$ | async)) ||
|
||||
((primaryCtaLabel$ | async) === 'Bestellen' &&
|
||||
!(checkNotificationChannelControl$ | async)) ||
|
||||
notificationsControl?.invalid ||
|
||||
((primaryCtaLabel$ | async) === 'Bestellen' && ((checkingOla$ | async) || (checkoutIsInValid$ | async)))
|
||||
((primaryCtaLabel$ | async) === 'Bestellen' &&
|
||||
((checkingOla$ | async) || (checkoutIsInValid$ | async)))
|
||||
"
|
||||
>
|
||||
>
|
||||
<ui-spinner [show]="showOrderButtonSpinner">
|
||||
{{ primaryCtaLabel$ | async }}
|
||||
</ui-spinner>
|
||||
@@ -137,4 +195,3 @@
|
||||
<ui-spinner [show]="true"></ui-spinner>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -40,6 +40,10 @@ import { injectTab } from '@isa/core/tabs';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { CrmTabMetadataService, Customer } from '@isa/crm/data-access';
|
||||
import { CustomerAdapter } from '@isa/checkout/data-access';
|
||||
import {
|
||||
NavigateAfterRewardSelection,
|
||||
RewardSelectionPopUpService,
|
||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
||||
import { NavigationStateService } from '@isa/core/navigation';
|
||||
|
||||
export interface CustomerDetailsViewMainState {
|
||||
@@ -75,6 +79,7 @@ export class CustomerDetailsViewMainComponent
|
||||
|
||||
customerService = inject(CrmCustomerService);
|
||||
crmTabMetadataService = inject(CrmTabMetadataService);
|
||||
private _rewardSelectionPopUpService = inject(RewardSelectionPopUpService);
|
||||
tab = injectTab();
|
||||
|
||||
// Signal to track if return URL exists
|
||||
@@ -97,7 +102,8 @@ export class CustomerDetailsViewMainComponent
|
||||
}
|
||||
|
||||
async checkHasReturnUrl(): Promise<void> {
|
||||
const hasContext = await this._navigationState.hasPreservedContext('select-customer');
|
||||
const hasContext =
|
||||
await this._navigationState.hasPreservedContext('select-customer');
|
||||
this.hasReturnUrlSignal.set(hasContext);
|
||||
}
|
||||
|
||||
@@ -362,10 +368,7 @@ export class CustomerDetailsViewMainComponent
|
||||
// #5262 Für die Auswahl des Kunden im "Prämienshop-Modus" (Getrennt vom regulären Checkout-Prozess)
|
||||
async continueReward() {
|
||||
if (this.isRewardTab()) {
|
||||
this.crmTabMetadataService.setSelectedCustomerId(
|
||||
this.processId,
|
||||
this.customer.id,
|
||||
);
|
||||
this._setSelectedCustomerIdInTab();
|
||||
|
||||
// Restore from preserved context (auto-scoped to current tab) and clean up
|
||||
const context = await this._navigationState.restoreAndClearContext<{
|
||||
@@ -421,6 +424,8 @@ export class CustomerDetailsViewMainComponent
|
||||
|
||||
this._setBuyer();
|
||||
|
||||
this._setSelectedCustomerIdInTab();
|
||||
|
||||
await this._updateNotifcationChannelsAsync(currentBuyer);
|
||||
|
||||
this._setPayer();
|
||||
@@ -428,17 +433,13 @@ export class CustomerDetailsViewMainComponent
|
||||
this._setShippingAddress();
|
||||
|
||||
if (this.shoppingCartHasItems) {
|
||||
// Navigation zum Warenkorb
|
||||
const path = this._checkoutNavigation.getCheckoutReviewPath(
|
||||
this.processId,
|
||||
).path;
|
||||
this._router.navigate(path);
|
||||
await this.#rewardSelectionPopUpFlow(this.processId);
|
||||
} else {
|
||||
// Navigation zur Artikelsuche
|
||||
const path = this._catalogNavigation.getArticleSearchBasePath(
|
||||
this.processId,
|
||||
).path;
|
||||
this._router.navigate(path);
|
||||
await this._router.navigate(path);
|
||||
}
|
||||
|
||||
this.setIsBusy(false);
|
||||
@@ -608,6 +609,15 @@ export class CustomerDetailsViewMainComponent
|
||||
});
|
||||
}
|
||||
|
||||
// #5307 Für den Warenkorb und den "Prämien oder Warenkorb" CTA ist es wichtig, dass die customerId im Tab hinterlegt ist
|
||||
@log
|
||||
_setSelectedCustomerIdInTab() {
|
||||
this.crmTabMetadataService.setSelectedCustomerId(
|
||||
this.processId,
|
||||
this.customer.id,
|
||||
);
|
||||
}
|
||||
|
||||
@logAsync
|
||||
async _updateNotifcationChannelsAsync(currentBuyer: BuyerDTO | undefined) {
|
||||
if (currentBuyer?.buyerNumber !== this.customer.customerNumber) {
|
||||
@@ -642,4 +652,44 @@ export class CustomerDetailsViewMainComponent
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async #rewardSelectionPopUpFlow(tabId: number) {
|
||||
await this.#reloadShoppingCart(tabId);
|
||||
const navigate: NavigateAfterRewardSelection =
|
||||
await this._rewardSelectionPopUpService.popUp();
|
||||
await this.#reloadShoppingCart(tabId);
|
||||
|
||||
switch (navigate) {
|
||||
case NavigateAfterRewardSelection.CART:
|
||||
await this.#navigateToCart(tabId);
|
||||
break;
|
||||
case NavigateAfterRewardSelection.REWARD:
|
||||
await this.#navigateToReward(tabId);
|
||||
break;
|
||||
case NavigateAfterRewardSelection.CATALOG:
|
||||
await this.#navigateToResultList(tabId);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async #reloadShoppingCart(tabId: number) {
|
||||
await this._checkoutService.reloadShoppingCart({ processId: tabId });
|
||||
}
|
||||
|
||||
async #navigateToReward(tabId: number) {
|
||||
await this._router.navigate([`/${tabId}`, 'reward', 'cart']);
|
||||
}
|
||||
|
||||
async #navigateToCart(tabId: number) {
|
||||
const path = this._checkoutNavigation.getCheckoutReviewPath(tabId).path;
|
||||
await this._router.navigate(path);
|
||||
}
|
||||
|
||||
async #navigateToResultList(tabId: number) {
|
||||
const path =
|
||||
this._catalogNavigation.getArticleSearchResultsPath(tabId).path;
|
||||
await this._router.navigate(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ResponseArgsOfUISettingsDTO,
|
||||
SearchService,
|
||||
} from '@generated/swagger/cat-search-api';
|
||||
import { firstValueFrom, map, Observable } from 'rxjs';
|
||||
import { firstValueFrom, map } from 'rxjs';
|
||||
import {
|
||||
catchResponseArgsErrorPipe,
|
||||
ResponseArgsError,
|
||||
@@ -26,8 +26,13 @@ export class CatalougeSearchService {
|
||||
#searchService = inject(SearchService);
|
||||
#logger = logger(() => ({ service: 'CatalougeSearchService' }));
|
||||
|
||||
searchByEans(...ean: string[]): Observable<Item[]> {
|
||||
return this.#searchService.SearchByEAN(ean).pipe(
|
||||
async searchByEans(
|
||||
ean: string[],
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<Item[]> {
|
||||
this.#logger.info('Searching items by EANs', () => ({ count: ean.length }));
|
||||
|
||||
let req$ = this.#searchService.SearchByEAN(ean).pipe(
|
||||
map((res) => {
|
||||
if (res.error) {
|
||||
throw new Error(res.message);
|
||||
@@ -36,6 +41,27 @@ export class CatalougeSearchService {
|
||||
return res.result as Item[];
|
||||
}),
|
||||
);
|
||||
|
||||
if (abortSignal) {
|
||||
req$ = req$.pipe(takeUntilAborted(abortSignal));
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await firstValueFrom(req$);
|
||||
this.#logger.debug('Successfully fetched items by EANs', () => ({
|
||||
count: items.length,
|
||||
}));
|
||||
return items;
|
||||
} catch (error) {
|
||||
this.#logger.error(
|
||||
'Error fetching items by EANs',
|
||||
error as Error,
|
||||
() => ({
|
||||
eanCount: ean.length,
|
||||
}),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async searchByTerm(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './branch.facade';
|
||||
export * from './purchase-options.facade';
|
||||
export * from './shopping-cart.facade';
|
||||
export * from './reward-selection.facade';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
11
libs/checkout/data-access/src/lib/models/branch-type.ts
Normal file
11
libs/checkout/data-access/src/lib/models/branch-type.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { BranchType } from '@generated/swagger/checkout-api';
|
||||
|
||||
export type BranchTypeEnum = BranchType;
|
||||
|
||||
export const BranchTypeEnum = {
|
||||
NotSet: 0,
|
||||
Store: 1,
|
||||
WebStore: 2,
|
||||
CallCenter: 4,
|
||||
Headquarter: 8,
|
||||
} as const;
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { ShoppingCartDTO } from '@generated/swagger/checkout-api';
|
||||
import { EntityContainer } from '@isa/common/data-access';
|
||||
import { ShoppingCartItem } from './shopping-cart-item';
|
||||
|
||||
export type ShoppingCart = ShoppingCartDTO;
|
||||
export type ShoppingCart = Omit<ShoppingCartDTO, 'items'> & {
|
||||
items: EntityContainer<ShoppingCartItem>[];
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { AvailabilityType, Gender, ShippingTarget } from '../models';
|
||||
import { OrderType } from '../models';
|
||||
import { BranchTypeEnum } from '../models';
|
||||
import {
|
||||
AddressSchema,
|
||||
CommunicationDetailsSchema,
|
||||
@@ -196,7 +197,7 @@ export const BranchDTOSchema: z.ZodOptional<z.ZodObject<any>> = z
|
||||
modifiedAt: z.string().optional(),
|
||||
address: AddressSchema.optional(),
|
||||
branchNumber: z.string().optional(),
|
||||
branchType: z.string().optional(), // BranchType enum - treating as string for now
|
||||
branchType: z.nativeEnum(BranchTypeEnum).optional(),
|
||||
isDefault: z.string().optional(),
|
||||
isOnline: z.boolean().optional(),
|
||||
isOrderingEnabled: z.boolean().optional(),
|
||||
@@ -206,7 +207,7 @@ export const BranchDTOSchema: z.ZodOptional<z.ZodObject<any>> = z
|
||||
name: z.string().optional(),
|
||||
parent: EntityContainerSchema(
|
||||
z.lazy((): z.ZodOptional<z.ZodObject<any>> => BranchDTOSchema),
|
||||
),
|
||||
).optional(),
|
||||
shortName: z.string().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,15 +16,18 @@ import {
|
||||
UpdateShoppingCartItemParams,
|
||||
UpdateShoppingCartItemParamsSchema,
|
||||
} from '../schemas';
|
||||
import { ShoppingCart } from '../models';
|
||||
import { RewardSelectionItem, ShoppingCart, ShoppingCartItem } from '../models';
|
||||
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { logger } from '@isa/core/logging';
|
||||
import { CheckoutMetadataService } from './checkout-metadata.service';
|
||||
import { getItemKey } from '../helpers';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ShoppingCartService {
|
||||
#logger = logger(() => ({ service: 'ShoppingCartService' }));
|
||||
#storeCheckoutShoppingCartService = inject(StoreCheckoutShoppingCartService);
|
||||
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||
|
||||
async createShoppingCart(): Promise<ShoppingCart> {
|
||||
const req$ =
|
||||
@@ -63,7 +66,7 @@ export class ShoppingCartService {
|
||||
this.#logger.error('Failed to fetch shopping cart', err);
|
||||
throw err;
|
||||
}
|
||||
return res.result;
|
||||
return res.result as ShoppingCart;
|
||||
}
|
||||
|
||||
async canAddItems(
|
||||
@@ -163,4 +166,226 @@ export class ShoppingCartService {
|
||||
|
||||
return res.result as ShoppingCart;
|
||||
}
|
||||
|
||||
async completeRewardSelection({
|
||||
tabId,
|
||||
rewardSelectionItems,
|
||||
}: {
|
||||
tabId: number;
|
||||
rewardSelectionItems: RewardSelectionItem[];
|
||||
}) {
|
||||
// Fetch or create both shopping cart IDs
|
||||
const shoppingCartId = await this.#getOrCreateShoppingCartId(tabId);
|
||||
const rewardShoppingCartId =
|
||||
await this.#getOrCreateRewardShoppingCartId(tabId);
|
||||
|
||||
// Get Current Carts
|
||||
const currentCart = await this.getShoppingCart(shoppingCartId);
|
||||
const currentRewardCart = await this.getShoppingCart(rewardShoppingCartId);
|
||||
|
||||
// Get Current Items from Cart
|
||||
const currentCartItems = currentCart?.items?.map((item) => item.data) ?? [];
|
||||
const currentRewardCartItems =
|
||||
currentRewardCart?.items?.map((item) => item.data) ?? [];
|
||||
|
||||
for (const selectionItem of rewardSelectionItems) {
|
||||
// Search by Key needed because of different ShoppingCartItem IDs the items have in the different carts
|
||||
const selectionItemKey = getItemKey(selectionItem.item);
|
||||
const currentInCart = currentCartItems.find(
|
||||
(item) => item && getItemKey(item) === selectionItemKey,
|
||||
);
|
||||
const currentInRewardCart = currentRewardCartItems.find(
|
||||
(item) => item && getItemKey(item) === selectionItemKey,
|
||||
);
|
||||
|
||||
// Handle regular cart
|
||||
await this.#handleCart({
|
||||
shoppingCartId,
|
||||
itemId: currentInCart?.id,
|
||||
currentCartItem: currentInCart,
|
||||
rewardSelectionItem: selectionItem,
|
||||
});
|
||||
|
||||
// Handle reward cart
|
||||
await this.#handleRewardCart({
|
||||
rewardShoppingCartId,
|
||||
itemId: currentInRewardCart?.id,
|
||||
currentRewardCartItem: currentInRewardCart,
|
||||
rewardSelectionItem: selectionItem,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async #handleCart({
|
||||
shoppingCartId,
|
||||
itemId,
|
||||
currentCartItem,
|
||||
rewardSelectionItem,
|
||||
}: {
|
||||
shoppingCartId: number;
|
||||
itemId: number | undefined;
|
||||
currentCartItem: ShoppingCartItem | undefined;
|
||||
rewardSelectionItem: RewardSelectionItem;
|
||||
}) {
|
||||
const desiredCartQuantity = rewardSelectionItem.cartQuantity;
|
||||
if (currentCartItem && itemId) {
|
||||
const currentQuantity = currentCartItem.quantity;
|
||||
if (desiredCartQuantity !== currentQuantity) {
|
||||
if (desiredCartQuantity === 0) {
|
||||
this.#logger.info('Removing item from regular cart', () => ({
|
||||
shoppingCartId,
|
||||
itemId,
|
||||
currentQuantity,
|
||||
}));
|
||||
await this.removeItem({
|
||||
shoppingCartId,
|
||||
shoppingCartItemId: itemId,
|
||||
});
|
||||
} else {
|
||||
this.#logger.info('Updating item quantity in regular cart', () => ({
|
||||
shoppingCartId,
|
||||
itemId,
|
||||
currentQuantity,
|
||||
desiredCartQuantity,
|
||||
}));
|
||||
await this.updateItem({
|
||||
shoppingCartId,
|
||||
shoppingCartItemId: itemId,
|
||||
values: { quantity: desiredCartQuantity },
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (desiredCartQuantity > 0) {
|
||||
this.#logger.info('Adding item to regular cart', () => ({
|
||||
shoppingCartId,
|
||||
itemId,
|
||||
desiredCartQuantity,
|
||||
productNumber: rewardSelectionItem?.item?.product?.catalogProductNumber,
|
||||
}));
|
||||
await this.addItem({
|
||||
shoppingCartId,
|
||||
items: [
|
||||
{
|
||||
destination: rewardSelectionItem.item.destination,
|
||||
product: {
|
||||
...rewardSelectionItem.item.product,
|
||||
catalogProductNumber:
|
||||
rewardSelectionItem?.item?.product?.catalogProductNumber ??
|
||||
String(rewardSelectionItem?.item?.id),
|
||||
},
|
||||
availability: {
|
||||
...rewardSelectionItem.item.availability,
|
||||
price:
|
||||
rewardSelectionItem?.availabilityPrice ??
|
||||
rewardSelectionItem?.catalogPrice,
|
||||
},
|
||||
promotion: rewardSelectionItem?.item?.promotion,
|
||||
quantity: desiredCartQuantity,
|
||||
loyalty: undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async #handleRewardCart({
|
||||
rewardShoppingCartId,
|
||||
itemId,
|
||||
currentRewardCartItem,
|
||||
rewardSelectionItem,
|
||||
}: {
|
||||
rewardShoppingCartId: number;
|
||||
itemId: number | undefined;
|
||||
currentRewardCartItem: ShoppingCartItem | undefined;
|
||||
rewardSelectionItem: RewardSelectionItem;
|
||||
}) {
|
||||
const desiredRewardCartQuantity = rewardSelectionItem.rewardCartQuantity;
|
||||
if (currentRewardCartItem && itemId) {
|
||||
const currentQuantity = currentRewardCartItem.quantity;
|
||||
if (desiredRewardCartQuantity !== currentQuantity) {
|
||||
if (desiredRewardCartQuantity === 0) {
|
||||
this.#logger.info('Removing item from reward cart', () => ({
|
||||
rewardShoppingCartId,
|
||||
itemId,
|
||||
currentQuantity,
|
||||
}));
|
||||
await this.removeItem({
|
||||
shoppingCartId: rewardShoppingCartId,
|
||||
shoppingCartItemId: itemId,
|
||||
});
|
||||
} else {
|
||||
this.#logger.info('Updating item quantity in reward cart', () => ({
|
||||
rewardShoppingCartId,
|
||||
itemId,
|
||||
currentQuantity,
|
||||
desiredRewardCartQuantity,
|
||||
}));
|
||||
await this.updateItem({
|
||||
shoppingCartId: rewardShoppingCartId,
|
||||
shoppingCartItemId: itemId,
|
||||
values: { quantity: desiredRewardCartQuantity },
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (desiredRewardCartQuantity > 0) {
|
||||
this.#logger.info('Adding item to reward cart', () => ({
|
||||
rewardShoppingCartId,
|
||||
itemId,
|
||||
desiredRewardCartQuantity,
|
||||
rewardPoints: rewardSelectionItem.catalogRewardPoints,
|
||||
productNumber: rewardSelectionItem?.item?.product?.catalogProductNumber,
|
||||
}));
|
||||
await this.addItem({
|
||||
shoppingCartId: rewardShoppingCartId,
|
||||
items: [
|
||||
{
|
||||
destination: rewardSelectionItem.item.destination,
|
||||
product: {
|
||||
...rewardSelectionItem.item.product,
|
||||
catalogProductNumber:
|
||||
rewardSelectionItem?.item?.product?.catalogProductNumber ??
|
||||
String(rewardSelectionItem?.item?.id),
|
||||
},
|
||||
quantity: desiredRewardCartQuantity,
|
||||
promotion: undefined, // If loyalty is set, we need to remove promotion
|
||||
loyalty: { value: rewardSelectionItem.catalogRewardPoints }, // Set loyalty points from item
|
||||
availability: {
|
||||
...rewardSelectionItem.item.availability,
|
||||
price: {
|
||||
value: { value: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async #getOrCreateShoppingCartId(tabId: number): Promise<number> {
|
||||
const shoppingCartId =
|
||||
this.#checkoutMetadataService.getShoppingCartId(tabId);
|
||||
|
||||
if (shoppingCartId) {
|
||||
return shoppingCartId;
|
||||
}
|
||||
|
||||
const shoppingCart = await this.createShoppingCart();
|
||||
this.#checkoutMetadataService.setShoppingCartId(tabId!, shoppingCart.id);
|
||||
return shoppingCart.id!;
|
||||
}
|
||||
|
||||
async #getOrCreateRewardShoppingCartId(tabId: number): Promise<number> {
|
||||
const rewardShoppingCartId =
|
||||
this.#checkoutMetadataService.getRewardShoppingCartId(tabId);
|
||||
if (rewardShoppingCartId) {
|
||||
return rewardShoppingCartId;
|
||||
}
|
||||
|
||||
const shoppingCart = await this.createShoppingCart();
|
||||
this.#checkoutMetadataService.setRewardShoppingCartId(
|
||||
tabId!,
|
||||
shoppingCart.id,
|
||||
);
|
||||
return shoppingCart.id!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
<br />
|
||||
Sie können Prämien unter folgendem Link zurück in den Warenkorb legen:
|
||||
</p>
|
||||
<button class="-mt-2" uiTextButton color="strong">
|
||||
Prämie oder Warenkorb
|
||||
</button>
|
||||
<lib-reward-selection-trigger></lib-reward-selection-trigger>
|
||||
</div>
|
||||
<checkout-customer-reward-card></checkout-customer-reward-card>
|
||||
<checkout-billing-and-shipping-address-card></checkout-billing-and-shipping-address-card>
|
||||
|
||||
@@ -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,
|
||||
|
||||
214
libs/checkout/shared/reward-selection-dialog/README.md
Normal file
214
libs/checkout/shared/reward-selection-dialog/README.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Reward Selection Dialog
|
||||
|
||||
Angular library for managing reward selection in shopping cart context. Allows users to toggle between regular purchase and reward redemption using bonus points.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎯 Pre-built trigger component or direct service integration
|
||||
- 🔄 Automatic resource management (carts, bonus cards)
|
||||
- 📊 Smart grouping by order type and branch
|
||||
- 💾 NgRx Signals state management
|
||||
- ✅ Full TypeScript support
|
||||
|
||||
## Installation
|
||||
|
||||
```typescript
|
||||
import {
|
||||
RewardSelectionService,
|
||||
RewardSelectionPopUpService,
|
||||
RewardSelectionTriggerComponent,
|
||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Using the Trigger Component (Recommended)
|
||||
|
||||
Simplest integration - includes all providers automatically:
|
||||
|
||||
```typescript
|
||||
import { Component } from '@angular/core';
|
||||
import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-checkout',
|
||||
template: `<lib-reward-selection-trigger />`,
|
||||
imports: [RewardSelectionTriggerComponent],
|
||||
})
|
||||
export class CheckoutComponent {}
|
||||
```
|
||||
|
||||
### Using the Pop-Up Service
|
||||
|
||||
More control over navigation flow:
|
||||
|
||||
```typescript
|
||||
import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
RewardSelectionPopUpService,
|
||||
NavigateAfterRewardSelection,
|
||||
RewardSelectionService,
|
||||
} from '@isa/checkout/shared/reward-selection-dialog';
|
||||
import {
|
||||
SelectedShoppingCartResource,
|
||||
SelectedRewardShoppingCartResource,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-custom-checkout',
|
||||
template: `<button (click)="openRewardSelection()">Select Rewards</button>`,
|
||||
providers: [
|
||||
// Required providers
|
||||
SelectedShoppingCartResource,
|
||||
SelectedRewardShoppingCartResource,
|
||||
SelectedCustomerBonusCardsResource,
|
||||
RewardSelectionService,
|
||||
RewardSelectionPopUpService,
|
||||
],
|
||||
})
|
||||
export class CustomCheckoutComponent {
|
||||
#popUpService = inject(RewardSelectionPopUpService);
|
||||
|
||||
async openRewardSelection() {
|
||||
const result = await this.#popUpService.popUp();
|
||||
|
||||
// Handle navigation: 'cart' | 'reward' | 'catalog' | undefined
|
||||
if (result === NavigateAfterRewardSelection.CART) {
|
||||
// Navigate to cart
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using the Service Directly
|
||||
|
||||
For custom UI or advanced use cases:
|
||||
|
||||
```typescript
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { RewardSelectionService } from '@isa/checkout/shared/reward-selection-dialog';
|
||||
import {
|
||||
SelectedShoppingCartResource,
|
||||
SelectedRewardShoppingCartResource,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { SelectedCustomerBonusCardsResource } from '@isa/crm/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'app-advanced',
|
||||
template: `
|
||||
@if (canOpen()) {
|
||||
<button (click)="openDialog()" [disabled]="isLoading()">
|
||||
{{ eligibleItemsCount() }} items as rewards ({{ availablePoints() }} points)
|
||||
</button>
|
||||
}
|
||||
`,
|
||||
providers: [
|
||||
SelectedShoppingCartResource,
|
||||
SelectedRewardShoppingCartResource,
|
||||
SelectedCustomerBonusCardsResource,
|
||||
RewardSelectionService,
|
||||
],
|
||||
})
|
||||
export class AdvancedComponent {
|
||||
#service = inject(RewardSelectionService);
|
||||
|
||||
canOpen = this.#service.canOpen;
|
||||
isLoading = this.#service.isLoading;
|
||||
eligibleItemsCount = computed(() => this.#service.eligibleItems().length);
|
||||
availablePoints = this.#service.primaryBonusCardPoints;
|
||||
|
||||
async openDialog() {
|
||||
const result = await this.#service.open({ closeText: 'Cancel' });
|
||||
if (result) {
|
||||
// Handle result.rewardSelectionItems
|
||||
await this.#service.reloadResources();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### RewardSelectionService
|
||||
|
||||
**Key Signals:**
|
||||
- `canOpen()`: `boolean` - Can dialog be opened
|
||||
- `isLoading()`: `boolean` - Loading state
|
||||
- `eligibleItems()`: `RewardSelectionItem[]` - Items available as rewards
|
||||
- `primaryBonusCardPoints()`: `number` - Available points
|
||||
|
||||
**Methods:**
|
||||
- `open({ closeText }): Promise<RewardSelectionDialogResult>` - Opens dialog
|
||||
- `reloadResources(): Promise<void>` - Reloads all data
|
||||
|
||||
### RewardSelectionPopUpService
|
||||
|
||||
**Methods:**
|
||||
- `popUp(): Promise<NavigateAfterRewardSelection | undefined>` - Opens dialog with navigation flow
|
||||
|
||||
**Return values:**
|
||||
- `'cart'` - Navigate to shopping cart
|
||||
- `'reward'` - Navigate to reward checkout
|
||||
- `'catalog'` - Navigate to catalog
|
||||
- `undefined` - No navigation needed
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
interface RewardSelectionItem {
|
||||
item: ShoppingCartItem;
|
||||
catalogPrice: Price | undefined;
|
||||
availabilityPrice: Price | undefined;
|
||||
catalogRewardPoints: number | undefined;
|
||||
cartQuantity: number;
|
||||
rewardCartQuantity: number;
|
||||
}
|
||||
|
||||
type RewardSelectionDialogResult = {
|
||||
rewardSelectionItems: RewardSelectionItem[];
|
||||
} | undefined;
|
||||
|
||||
type NavigateAfterRewardSelection = 'cart' | 'reward' | 'catalog';
|
||||
```
|
||||
|
||||
## Required Providers
|
||||
|
||||
When using `RewardSelectionService` or `RewardSelectionPopUpService` directly, provide:
|
||||
|
||||
```typescript
|
||||
providers: [
|
||||
SelectedShoppingCartResource, // Regular cart data
|
||||
SelectedRewardShoppingCartResource, // Reward cart data
|
||||
SelectedCustomerBonusCardsResource, // Customer bonus cards
|
||||
RewardSelectionService, // Core service
|
||||
RewardSelectionPopUpService, // Optional: only if using pop-up
|
||||
]
|
||||
```
|
||||
|
||||
**Note:** `RewardSelectionTriggerComponent` includes all required providers automatically.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
nx test reward-selection-dialog
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
reward-selection-dialog/
|
||||
├── helper/ # Pure utility functions
|
||||
├── resource/ # Data resources
|
||||
├── service/ # Business logic
|
||||
├── store/ # NgRx Signals state
|
||||
└── trigger/ # Trigger component
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@isa/checkout/data-access` - Cart resources
|
||||
- `@isa/crm/data-access` - Customer data
|
||||
- `@isa/catalogue/data-access` - Product catalog
|
||||
- `@isa/ui/dialog` - Dialog infrastructure
|
||||
- `@ngrx/signals` - State management
|
||||
@@ -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: {},
|
||||
},
|
||||
];
|
||||
20
libs/checkout/shared/reward-selection-dialog/project.json
Normal file
20
libs/checkout/shared/reward-selection-dialog/project.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "reward-selection-dialog",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/checkout/shared/reward-selection-dialog/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "@nx/vite:test",
|
||||
"outputs": ["{options.reportsDirectory}"],
|
||||
"options": {
|
||||
"reportsDirectory": "../../../../coverage/libs/checkout/shared/reward-selection-dialog"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(' | ');
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { RewardSelectionItem } from '@isa/checkout/data-access';
|
||||
import { formatAddress } from './format-address.helper';
|
||||
|
||||
export type SubGroup = {
|
||||
branchId?: number;
|
||||
branchName?: string;
|
||||
branchAddress?: string;
|
||||
items: RewardSelectionItem[];
|
||||
};
|
||||
|
||||
const ORDER_TYPES_WITH_BRANCH_GROUPING = ['Abholung', 'Rücklage'] as const;
|
||||
|
||||
const sortByProductName = (items: RewardSelectionItem[]): RewardSelectionItem[] => {
|
||||
return [...items].sort((a, b) =>
|
||||
(a.item.product.name ?? '').localeCompare(b.item.product.name ?? ''),
|
||||
);
|
||||
};
|
||||
|
||||
export const groupByBranch = (
|
||||
orderType: string,
|
||||
items: RewardSelectionItem[],
|
||||
): SubGroup[] => {
|
||||
const needsBranchGrouping = ORDER_TYPES_WITH_BRANCH_GROUPING.includes(
|
||||
orderType as (typeof ORDER_TYPES_WITH_BRANCH_GROUPING)[number],
|
||||
);
|
||||
|
||||
if (!needsBranchGrouping) {
|
||||
return [
|
||||
{
|
||||
branchId: undefined,
|
||||
branchName: undefined,
|
||||
branchAddress: undefined,
|
||||
items: sortByProductName(items),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const branchGroups = items.reduce((map, item) => {
|
||||
const branchId = item.item.destination?.data?.targetBranch?.id ?? 0;
|
||||
if (!map.has(branchId)) map.set(branchId, []);
|
||||
map.get(branchId)!.push(item);
|
||||
return map;
|
||||
}, new Map<number, RewardSelectionItem[]>());
|
||||
|
||||
return Array.from(branchGroups.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([branchId, branchItems]) => {
|
||||
const branch = branchItems[0]?.item.destination?.data?.targetBranch?.data;
|
||||
return {
|
||||
branchId: branchId || undefined,
|
||||
branchName: branch?.name,
|
||||
branchAddress: formatAddress(branch?.address),
|
||||
items: sortByProductName(branchItems),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
getOrderTypeFeature,
|
||||
RewardSelectionItem,
|
||||
} from '@isa/checkout/data-access';
|
||||
|
||||
export const groupByOrderType = (
|
||||
items: RewardSelectionItem[],
|
||||
): Map<string, RewardSelectionItem[]> => {
|
||||
return items.reduce((map, item) => {
|
||||
const orderType = getOrderTypeFeature(item.item.features) ?? 'Unbekannt';
|
||||
if (!map.has(orderType)) map.set(orderType, []);
|
||||
map.get(orderType)!.push(item);
|
||||
return map;
|
||||
}, new Map<string, RewardSelectionItem[]>());
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
getItemKey,
|
||||
ShoppingCartItem,
|
||||
RewardSelectionItem,
|
||||
} from '@isa/checkout/data-access';
|
||||
|
||||
/**
|
||||
* Merges items from regular shopping cart and reward shopping cart.
|
||||
* Items are grouped by EAN, destination, and orderItemType.
|
||||
* If an item exists in both carts with the same key, it will have both
|
||||
* cartQuantity and rewardCartQuantity.
|
||||
*
|
||||
* @param shoppingCartItems - Items from the regular shopping cart
|
||||
* @param rewardShoppingCartItems - Items from the reward shopping cart
|
||||
* @returns Merged array of reward selection items
|
||||
*/
|
||||
export const mergeRewardSelectionItems = (
|
||||
shoppingCartItems: ShoppingCartItem[],
|
||||
rewardShoppingCartItems: ShoppingCartItem[],
|
||||
): RewardSelectionItem[] => {
|
||||
const itemsMap = new Map<string, RewardSelectionItem>();
|
||||
|
||||
// Add items from regular shopping cart
|
||||
shoppingCartItems.forEach((shoppingCartItem) => {
|
||||
const key = getItemKey(shoppingCartItem);
|
||||
itemsMap.set(key, {
|
||||
item: shoppingCartItem,
|
||||
cartQuantity: shoppingCartItem.quantity ?? 0,
|
||||
rewardCartQuantity: 0,
|
||||
catalogPrice: undefined,
|
||||
availabilityPrice: undefined,
|
||||
catalogRewardPoints: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Merge items from reward shopping cart
|
||||
rewardShoppingCartItems.forEach((shoppingCartItem) => {
|
||||
const key = getItemKey(shoppingCartItem);
|
||||
const existingItem = itemsMap.get(key);
|
||||
|
||||
if (existingItem) {
|
||||
// Item exists in both carts with same EAN, destination, and orderItemType - merge quantities
|
||||
itemsMap.set(key, {
|
||||
...existingItem,
|
||||
rewardCartQuantity: shoppingCartItem.quantity ?? 0,
|
||||
});
|
||||
} else {
|
||||
// Item only in reward cart or different destination/orderItemType
|
||||
itemsMap.set(key, {
|
||||
item: shoppingCartItem,
|
||||
cartQuantity: 0,
|
||||
rewardCartQuantity: shoppingCartItem.quantity ?? 0,
|
||||
catalogPrice: undefined,
|
||||
availabilityPrice: undefined,
|
||||
catalogRewardPoints: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(itemsMap.values());
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import { computed, inject, Injectable, resource, signal } from '@angular/core';
|
||||
import { AvailabilityService } from '@isa/availability/data-access';
|
||||
import {
|
||||
CatalougeSearchService,
|
||||
Price as CatalogPrice,
|
||||
} from '@isa/catalogue/data-access';
|
||||
import {
|
||||
OrderType,
|
||||
Price as AvailabilityPrice,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { logger } from '@isa/core/logging';
|
||||
|
||||
/**
|
||||
* Input item for availability check - contains EAN, orderType and optional branchId
|
||||
*/
|
||||
export interface ItemWithOrderType {
|
||||
ean: string;
|
||||
orderType: OrderType;
|
||||
branchId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result containing price from availability and redemption points from catalog
|
||||
*/
|
||||
export interface PriceAndRedemptionPointsResult {
|
||||
ean: string;
|
||||
availabilityPrice?: AvailabilityPrice;
|
||||
catalogPrice?: CatalogPrice;
|
||||
redemptionPoints?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource for fetching combined price and redemption points data.
|
||||
*
|
||||
* This resource:
|
||||
* 1. Fetches catalog items by EAN to get redemption points
|
||||
* 2. Groups items by order type
|
||||
* 3. Fetches availability data for each order type group
|
||||
* 4. Combines catalog redemption points with availability prices
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const resource = inject(PriceAndRedemptionPointsResource);
|
||||
*
|
||||
* // Load data for items
|
||||
* resource.loadPriceAndRedemptionPoints([
|
||||
* { ean: '1234567890', orderType: OrderType.Delivery },
|
||||
* { ean: '0987654321', orderType: OrderType.Pickup }
|
||||
* ]);
|
||||
*
|
||||
* // Access results
|
||||
* const results = resource.priceAndRedemptionPoints();
|
||||
* ```
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PriceAndRedemptionPointsResource {
|
||||
#catalogueSearchService = inject(CatalougeSearchService);
|
||||
#availabilityService = inject(AvailabilityService);
|
||||
#logger = logger(() => ({ resource: 'PriceAndRedemptionPoints' }));
|
||||
|
||||
#items = signal<ItemWithOrderType[] | undefined>(undefined);
|
||||
|
||||
#priceAndRedemptionPointsResource = resource({
|
||||
params: computed(() => ({ items: this.#items() })),
|
||||
loader: async ({
|
||||
params,
|
||||
abortSignal,
|
||||
}): Promise<PriceAndRedemptionPointsResult[]> => {
|
||||
if (!params?.items || params.items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract unique EANs for catalog lookup
|
||||
const eans = [...new Set(params.items.map((item) => item.ean))];
|
||||
|
||||
// Fetch catalog items to get redemption points
|
||||
const catalogItems = await this.#catalogueSearchService.searchByEans(
|
||||
eans,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
// Create a map for quick catalog lookup by EAN
|
||||
const catalogByEan = new Map(
|
||||
catalogItems.map((item) => [item.product.ean, item]),
|
||||
);
|
||||
|
||||
// Fetch availability for each item individually (in parallel)
|
||||
const availabilityPromises = params.items.map(async (checkItem) => {
|
||||
const catalogItem = catalogByEan.get(checkItem.ean);
|
||||
|
||||
// Skip items without catalog entry
|
||||
if (!catalogItem?.id) {
|
||||
return { ean: checkItem.ean, price: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
// Call getAvailability for single item
|
||||
// InStore (Rücklage) has different schema: uses itemId instead of item object
|
||||
const params =
|
||||
checkItem.orderType === OrderType.InStore
|
||||
? {
|
||||
orderType: checkItem.orderType,
|
||||
branchId: checkItem.branchId,
|
||||
itemId: catalogItem.id,
|
||||
}
|
||||
: {
|
||||
orderType: checkItem.orderType,
|
||||
branchId: checkItem.branchId,
|
||||
item: {
|
||||
itemId: catalogItem.id,
|
||||
ean: checkItem.ean,
|
||||
quantity: 1,
|
||||
price: catalogItem.catalogAvailability?.price,
|
||||
},
|
||||
};
|
||||
|
||||
const availability = await this.#availabilityService.getAvailability(
|
||||
params as any,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
return {
|
||||
ean: checkItem.ean,
|
||||
price: availability?.price as AvailabilityPrice | undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
this.#logger.error(
|
||||
'Failed to fetch availability for item',
|
||||
error as Error,
|
||||
() => ({
|
||||
ean: checkItem.ean,
|
||||
orderType: checkItem.orderType,
|
||||
branchId: checkItem.branchId,
|
||||
}),
|
||||
);
|
||||
return { ean: checkItem.ean, price: undefined };
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all availability requests to complete
|
||||
const availabilityResults = await Promise.all(availabilityPromises);
|
||||
|
||||
// Build price map from results
|
||||
const pricesByEan = new Map(
|
||||
availabilityResults.map((result) => [result.ean, result.price]),
|
||||
);
|
||||
|
||||
// Build final result: combine catalog prices, availability prices and redemption points
|
||||
const results: PriceAndRedemptionPointsResult[] = eans.map((ean) => ({
|
||||
ean,
|
||||
availabilityPrice: pricesByEan.get(ean),
|
||||
catalogPrice: catalogByEan.get(ean)?.catalogAvailability?.price,
|
||||
redemptionPoints: catalogByEan.get(ean)?.redemptionPoints,
|
||||
}));
|
||||
|
||||
return results;
|
||||
},
|
||||
defaultValue: [],
|
||||
});
|
||||
|
||||
readonly priceAndRedemptionPoints =
|
||||
this.#priceAndRedemptionPointsResource.value.asReadonly();
|
||||
readonly loading = this.#priceAndRedemptionPointsResource.isLoading;
|
||||
readonly error = computed(
|
||||
() => this.#priceAndRedemptionPointsResource.error()?.message ?? null,
|
||||
);
|
||||
|
||||
loadPriceAndRedemptionPoints(items: ItemWithOrderType[] | undefined) {
|
||||
this.#items.set(items);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.#priceAndRedemptionPointsResource.reload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply flex flex-row justify-end gap-2 w-full mt-6;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<button
|
||||
uiButton
|
||||
(click)="onContinue()"
|
||||
color="secondary"
|
||||
data-what="button"
|
||||
data-which="continue-shopping"
|
||||
type="button"
|
||||
>
|
||||
{{ host.data.closeText }}
|
||||
</button>
|
||||
<button
|
||||
uiButton
|
||||
(click)="onSave()"
|
||||
color="primary"
|
||||
data-what="button"
|
||||
data-which="save"
|
||||
[disabled]="insufficientLoyaltyPoints() || completeRewardSelectionLoading()"
|
||||
[pending]="completeRewardSelectionLoading()"
|
||||
type="button"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<div class="flex flex-col gap-6 text-isa-neutral-900">
|
||||
<lib-reward-selection-error></lib-reward-selection-error>
|
||||
|
||||
<div
|
||||
class="flex flex-row py-3 pl-6 bg-isa-neutral-400 rounded-2xl justify-between"
|
||||
>
|
||||
<span class="isa-text-body-1-regular">Zahlen mit</span>
|
||||
|
||||
<div class="flex flex-row gap-3">
|
||||
<div class="flex flex-col items-center justify-center w-[10.5rem]">
|
||||
<span class="isa-text-body-1-regular">Lesepunkten</span>
|
||||
<span class="isa-text-subtitle-2-bold">{{
|
||||
store.customerRewardPoints()
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-center w-[10.5rem]">
|
||||
<span class="isa-text-body-1-regular">Warenkorb</span>
|
||||
<span class="isa-text-subtitle-2-bold">EUR</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<lib-reward-selection-items></lib-reward-selection-items>
|
||||
|
||||
<div
|
||||
class="flex flex-row py-3 pl-6 bg-isa-neutral-400 rounded-2xl justify-between"
|
||||
>
|
||||
<span class="isa-text-body-1-regular">Gesamt</span>
|
||||
|
||||
<div class="flex flex-row gap-3">
|
||||
<div class="flex flex-col items-center justify-center w-[10.5rem]">
|
||||
<span class="isa-text-body-1-regular">Lesepunkten</span>
|
||||
<span class="isa-text-subtitle-2-bold">{{
|
||||
store.totalLoyaltyPointsNeeded()
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center justify-center w-[10.5rem]">
|
||||
<span class="isa-text-body-1-regular">Warenkorb</span>
|
||||
<span class="isa-text-subtitle-2-bold"
|
||||
>{{ store.totalPrice() }} EUR</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<lib-reward-selection-actions></lib-reward-selection-actions>
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply text-isa-accent-red isa-text-body-2-bold flex flex-row gap-2 items-center;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@if (store.totalLoyaltyPointsNeeded() > store.customerRewardPoints()) {
|
||||
<ng-icon
|
||||
class="w-6 h-6 inline-flex items-center justify-center"
|
||||
size="1.5rem"
|
||||
name="isaOtherInfo"
|
||||
></ng-icon>
|
||||
<span>Lesepunkte reichen nicht für alle Artikel</span>
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply flex flex-row gap-3;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<div
|
||||
class="h-14 w-[10.5rem] pl-4 grid grid-flow-col border border-isa-neutral-400 rounded-lg items-center"
|
||||
>
|
||||
<div class="isa-text-body-1-bold">{{ loyaltyValue() }} LP</div>
|
||||
|
||||
<shared-quantity-control
|
||||
[ngModel]="rewardCartQuantity()"
|
||||
(ngModelChange)="changeRewardCartQuantity($event)"
|
||||
[max]="maxQuantity()"
|
||||
data-what="dropdown"
|
||||
data-which="loyalty-quantity"
|
||||
[disabled]="!hasStock()"
|
||||
></shared-quantity-control>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="h-14 w-[10.5rem] pl-4 grid grid-flow-col border border-isa-neutral-400 rounded-lg items-center"
|
||||
>
|
||||
<div class="isa-text-body-1-bold">{{ priceValue() }} EUR</div>
|
||||
|
||||
<shared-quantity-control
|
||||
[ngModel]="cartQuantity()"
|
||||
(ngModelChange)="changeCartQuantity($event)"
|
||||
[max]="maxQuantity()"
|
||||
data-what="dropdown"
|
||||
data-which="price-quantity"
|
||||
[disabled]="!hasStock()"
|
||||
></shared-quantity-control>
|
||||
</div>
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply w-full flex flex-col gap-4;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<div class="flex flex-row justify-between">
|
||||
<div class="grid grid-cols-[auto,1fr] gap-6">
|
||||
<div>
|
||||
<img
|
||||
sharedProductRouterLink
|
||||
sharedProductImage
|
||||
[ean]="rewardSelectionItem().item.product.ean"
|
||||
[alt]="rewardSelectionItem().item.product.name"
|
||||
class="w-14"
|
||||
data-what="product-image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-2 max-w-40">
|
||||
<div
|
||||
class="isa-text-body-2-bold text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ rewardSelectionItem().item.product.contributors }}
|
||||
</div>
|
||||
<div
|
||||
class="isa-text-body-2-regular text-ellipsis whitespace-nowrap overflow-hidden"
|
||||
>
|
||||
{{ rewardSelectionItem().item.product.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<lib-reward-selection-inputs></lib-reward-selection-inputs>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import { ProductImageDirective } from '@isa/shared/product-image';
|
||||
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
|
||||
import { RewardSelectionInputsComponent } from './reward-selection-inputs/reward-selection-inputs.component';
|
||||
import { RewardSelectionItem } from '@isa/checkout/data-access';
|
||||
|
||||
@Component({
|
||||
selector: 'lib-reward-selection-item',
|
||||
templateUrl: './reward-selection-item.component.html',
|
||||
styleUrl: './reward-selection-item.component.css',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ProductImageDirective,
|
||||
ProductRouterLinkDirective,
|
||||
RewardSelectionInputsComponent,
|
||||
],
|
||||
})
|
||||
export class RewardSelectionItemComponent {
|
||||
rewardSelectionItem = input.required<RewardSelectionItem>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
@if (showGrouping()) {
|
||||
@for (group of groupedItems(); track group.orderType; let last = $last) {
|
||||
<div class="item-group-header">
|
||||
<ng-icon
|
||||
[name]="group.icon"
|
||||
[size]="group.orderType === 'B2B-Versand' ? '36' : '24'"
|
||||
/>
|
||||
<div class="label">{{ group.orderType }}</div>
|
||||
</div>
|
||||
|
||||
@for (
|
||||
subGroup of group.subGroups;
|
||||
track subGroup.branchId ?? $index;
|
||||
let isFirst = $first
|
||||
) {
|
||||
@if (subGroup.branchName) {
|
||||
<div class="branch-header rounded-2xl" [class.rounded-t-none]="isFirst">
|
||||
<span class="branch-name">
|
||||
{{ subGroup.branchName }} | {{ subGroup.branchAddress }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (item of subGroup.items; track item.item.id; let isLast = $last) {
|
||||
<lib-reward-selection-item class="py-4" [rewardSelectionItem]="item" />
|
||||
@if (!isLast) {
|
||||
<hr />
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
@for (item of items(); track item.item.id; let last = $last) {
|
||||
<lib-reward-selection-item class="py-4" [rewardSelectionItem]="item" />
|
||||
@if (!last) {
|
||||
<hr />
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { injectConfirmationDialog, injectFeedbackDialog } from '@isa/ui/dialog';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { RewardSelectionService } from './reward-selection.service';
|
||||
import { CheckoutMetadataService } from '@isa/checkout/data-access';
|
||||
import { injectTabId } from '@isa/core/tabs';
|
||||
|
||||
export const NavigateAfterRewardSelection = {
|
||||
CART: 'cart',
|
||||
REWARD: 'reward',
|
||||
CATALOG: 'catalog',
|
||||
} as const;
|
||||
export type NavigateAfterRewardSelection =
|
||||
(typeof NavigateAfterRewardSelection)[keyof typeof NavigateAfterRewardSelection];
|
||||
|
||||
@Injectable()
|
||||
export class RewardSelectionPopUpService {
|
||||
#tabId = injectTabId();
|
||||
#feedbackDialog = injectFeedbackDialog();
|
||||
#confirmationDialog = injectConfirmationDialog();
|
||||
#rewardSelectionService = inject(RewardSelectionService);
|
||||
#checkoutMetadataService = inject(CheckoutMetadataService);
|
||||
|
||||
/**
|
||||
* Displays the reward selection popup dialog if conditions are met.
|
||||
*
|
||||
* This method manages the complete flow of the reward selection popup:
|
||||
* 1. Checks if the popup has already been shown in the current tab (prevents duplicate displays)
|
||||
* 2. Reloads necessary resources for the reward selection dialog
|
||||
* 3. Opens the reward selection dialog if conditions allow
|
||||
* 4. Marks the popup as opened for the current tab using {@link #setPopUpOpenedState}
|
||||
* 5. Processes user selections and determines navigation flow
|
||||
*
|
||||
* @returns A promise that resolves to:
|
||||
* - `NavigateAfterRewardSelection.CART` - Navigate to the shopping cart
|
||||
* - `NavigateAfterRewardSelection.REWARD` - Navigate to the reward cart
|
||||
* - `NavigateAfterRewardSelection.CATALOG` - Navigate back to the catalog
|
||||
* - `undefined` - Stay on the current page (e.g., when all quantities are set to 0)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await rewardSelectionPopUpService.popUp();
|
||||
* if (result === NavigateAfterRewardSelection.CART) {
|
||||
* this.router.navigate(['/cart']);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
async popUp(): Promise<NavigateAfterRewardSelection | undefined> {
|
||||
if (this.#popUpAlreadyOpened(this.#tabId())) {
|
||||
return NavigateAfterRewardSelection.CART;
|
||||
}
|
||||
|
||||
await this.#rewardSelectionService.reloadResources();
|
||||
|
||||
if (this.#rewardSelectionService.canOpen()) {
|
||||
const dialogResult = await this.#rewardSelectionService.open({
|
||||
closeText: 'Weiter einkaufen',
|
||||
});
|
||||
|
||||
this.#setPopUpOpenedState(this.#tabId(), true);
|
||||
|
||||
if (dialogResult) {
|
||||
// Wenn der User alles 0 setzt, bleibt man einfach auf der aktuellen Seite
|
||||
if (dialogResult?.rewardSelectionItems?.length === 0) {
|
||||
await this.#feedback();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (dialogResult.rewardSelectionItems?.length > 0) {
|
||||
const hasRegularCartItems = dialogResult.rewardSelectionItems?.some(
|
||||
(item) => item?.cartQuantity > 0,
|
||||
);
|
||||
const hasRewardCartItems = dialogResult.rewardSelectionItems?.some(
|
||||
(item) => item?.rewardCartQuantity > 0,
|
||||
);
|
||||
|
||||
return await this.#confirmDialog(
|
||||
hasRegularCartItems,
|
||||
hasRewardCartItems,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NavigateAfterRewardSelection.CART;
|
||||
}
|
||||
|
||||
async #feedback() {
|
||||
this.#feedbackDialog({
|
||||
data: { message: 'Auswahl gespeichert' },
|
||||
});
|
||||
}
|
||||
|
||||
async #confirmDialog(
|
||||
hasRegularCartItems: boolean,
|
||||
hasRewardCartItems: boolean,
|
||||
): Promise<NavigateAfterRewardSelection | undefined> {
|
||||
const title = hasRewardCartItems
|
||||
? 'Artikel wurde der Prämienausgabe hinzugefügt'
|
||||
: 'Artikel wurde zum Warenkorb hinzugefügt';
|
||||
|
||||
const message = hasRegularCartItems
|
||||
? 'Bitte schließen sie erst den Warenkorb ab und dann die Prämienausgabe'
|
||||
: hasRegularCartItems && !hasRewardCartItems
|
||||
? 'Bitte schließen sie den Warenkorb ab'
|
||||
: '';
|
||||
|
||||
const dialogRef = this.#confirmationDialog({
|
||||
title,
|
||||
data: {
|
||||
message,
|
||||
closeText: 'Weiter einkaufen',
|
||||
confirmText: hasRegularCartItems
|
||||
? 'Zum Warenkorb'
|
||||
: 'Zur Prämienausgabe',
|
||||
},
|
||||
});
|
||||
|
||||
const dialogResult = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (dialogResult) {
|
||||
if (dialogResult.confirmed && hasRegularCartItems) {
|
||||
return NavigateAfterRewardSelection.CART;
|
||||
} else if (dialogResult.confirmed) {
|
||||
return NavigateAfterRewardSelection.REWARD;
|
||||
} else {
|
||||
return NavigateAfterRewardSelection.CATALOG;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
#popUpAlreadyOpened(tabId: number | null): boolean | undefined {
|
||||
if (tabId == null) return;
|
||||
return this.#checkoutMetadataService.getRewardSelectionPopupOpenedState(
|
||||
tabId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the opened state of the reward selection popup for a specific tab.
|
||||
*
|
||||
* This method persists the popup state to prevent the popup from being displayed
|
||||
* multiple times within the same tab session. It's called after successfully
|
||||
* opening the reward selection dialog.
|
||||
*
|
||||
* @param tabId - The unique identifier of the tab. If null, the method returns early without setting state.
|
||||
* @param opened - The opened state to set. `true` indicates the popup has been shown, `false` or `undefined` resets the state.
|
||||
*
|
||||
* @remarks
|
||||
* This state is typically set to `true` after the user has seen the popup to ensure
|
||||
* it doesn't appear again during the same browsing session in that tab.
|
||||
*/
|
||||
#setPopUpOpenedState(tabId: number | null, opened: boolean | undefined) {
|
||||
if (tabId == null) return;
|
||||
this.#checkoutMetadataService.setRewardSelectionPopupOpenedState(
|
||||
tabId,
|
||||
opened,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { computed, effect, inject, Injectable, untracked } from '@angular/core';
|
||||
import {
|
||||
SelectedRewardShoppingCartResource,
|
||||
SelectedShoppingCartResource,
|
||||
ShoppingCartItem,
|
||||
getOrderTypeFeature,
|
||||
} from '@isa/checkout/data-access';
|
||||
import { injectDialog } from '@isa/ui/dialog';
|
||||
import {
|
||||
RewardSelectionDialogComponent,
|
||||
RewardSelectionDialogResult,
|
||||
} from '../reward-selection-dialog.component';
|
||||
import {
|
||||
getPrimaryBonusCard,
|
||||
SelectedCustomerBonusCardsResource,
|
||||
} from '@isa/crm/data-access';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { RewardSelectionItem } from '@isa/checkout/data-access';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { filter, first } from 'rxjs/operators';
|
||||
import { itemSelectionChanged } from '../helper/item-selection-changed.helper';
|
||||
import { mergeRewardSelectionItems } from '../helper/merge-reward-selection-items.helper';
|
||||
import {
|
||||
PriceAndRedemptionPointsResource,
|
||||
ItemWithOrderType,
|
||||
} from '../resource/price-and-redemption-points.resource';
|
||||
|
||||
@Injectable()
|
||||
export class RewardSelectionService {
|
||||
priceAndRedemptionPointsResource = inject(PriceAndRedemptionPointsResource);
|
||||
rewardSelectionDialog = injectDialog(RewardSelectionDialogComponent);
|
||||
|
||||
#customerBonusCardsResource = inject(SelectedCustomerBonusCardsResource)
|
||||
.resource;
|
||||
#shoppingCartResource = inject(SelectedShoppingCartResource).resource;
|
||||
#rewardShoppingCartResource = inject(SelectedRewardShoppingCartResource)
|
||||
.resource;
|
||||
|
||||
readonly shoppingCartResponseValue =
|
||||
this.#shoppingCartResource.value.asReadonly();
|
||||
|
||||
readonly rewardShoppingCartResponseValue =
|
||||
this.#rewardShoppingCartResource.value.asReadonly();
|
||||
|
||||
readonly customerCardResponseValue =
|
||||
this.#customerBonusCardsResource.value.asReadonly();
|
||||
|
||||
readonly priceAndRedemptionPoints =
|
||||
this.priceAndRedemptionPointsResource.priceAndRedemptionPoints;
|
||||
|
||||
shoppingCartItems = computed(() => {
|
||||
return (
|
||||
this.shoppingCartResponseValue()
|
||||
?.items?.map((item) => item?.data as ShoppingCartItem)
|
||||
.filter((item): item is ShoppingCartItem => item != null) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
rewardShoppingCartItems = computed(() => {
|
||||
return (
|
||||
this.rewardShoppingCartResponseValue()
|
||||
?.items?.map((item) => item?.data as ShoppingCartItem)
|
||||
.filter((item): item is ShoppingCartItem => item != null) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
mergedRewardSelectionItems = computed<RewardSelectionItem[]>(() => {
|
||||
return mergeRewardSelectionItems(
|
||||
this.shoppingCartItems(),
|
||||
this.rewardShoppingCartItems(),
|
||||
);
|
||||
});
|
||||
|
||||
selectionItemsWithOrderType = computed<ItemWithOrderType[]>(() => {
|
||||
return this.mergedRewardSelectionItems()
|
||||
.map((item): ItemWithOrderType | null => {
|
||||
const ean = item.item.product.ean;
|
||||
const orderType = getOrderTypeFeature(item.item.features);
|
||||
const branchId = item.item.destination?.data?.targetBranch?.data?.id;
|
||||
|
||||
if (!ean || !orderType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { ean, orderType, branchId };
|
||||
})
|
||||
.filter((item): item is ItemWithOrderType => item !== null);
|
||||
});
|
||||
|
||||
updatedRewardSelectionItems = computed<RewardSelectionItem[]>(() => {
|
||||
const rewardSelectionItems = this.mergedRewardSelectionItems();
|
||||
const priceAndRedemptionResults = this.priceAndRedemptionPoints();
|
||||
|
||||
return rewardSelectionItems.map((selectionItem) => {
|
||||
const ean = selectionItem.item.product.ean;
|
||||
const result = priceAndRedemptionResults?.find((r) => r.ean === ean);
|
||||
|
||||
return {
|
||||
...selectionItem,
|
||||
catalogPrice: result?.catalogPrice,
|
||||
catalogRewardPoints: result?.redemptionPoints,
|
||||
availabilityPrice: result?.availabilityPrice,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
bonusCards = computed(() => {
|
||||
return this.customerCardResponseValue() ?? [];
|
||||
});
|
||||
|
||||
primaryBonusCard = computed(() => getPrimaryBonusCard(this.bonusCards()));
|
||||
primaryBonusCardPoints = computed(
|
||||
() => this.primaryBonusCard()?.totalPoints ?? 0,
|
||||
);
|
||||
|
||||
isLoading = computed(
|
||||
() =>
|
||||
this.#shoppingCartResource.isLoading() ||
|
||||
this.#rewardShoppingCartResource.isLoading() ||
|
||||
this.#customerBonusCardsResource.isLoading() ||
|
||||
this.priceAndRedemptionPointsResource.loading(),
|
||||
);
|
||||
#isLoading$ = toObservable(this.isLoading);
|
||||
|
||||
eligibleItems = computed(() =>
|
||||
this.updatedRewardSelectionItems().filter(
|
||||
(selectionItem) =>
|
||||
(selectionItem.item.loyalty?.value != null &&
|
||||
selectionItem.item.loyalty.value !== 0) ||
|
||||
(selectionItem?.catalogRewardPoints != null &&
|
||||
selectionItem.catalogRewardPoints !== 0),
|
||||
),
|
||||
);
|
||||
|
||||
canOpen = computed(
|
||||
() => this.eligibleItems().length > 0 && !!this.primaryBonusCard(),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const items = this.selectionItemsWithOrderType();
|
||||
|
||||
untracked(() => {
|
||||
const resourceLoading = this.priceAndRedemptionPointsResource.loading();
|
||||
if (!resourceLoading && items.length > 0) {
|
||||
this.priceAndRedemptionPointsResource.loadPriceAndRedemptionPoints(
|
||||
items,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async open({
|
||||
closeText,
|
||||
}: {
|
||||
closeText: string;
|
||||
}): Promise<RewardSelectionDialogResult> {
|
||||
const rewardSelectionItems = this.eligibleItems();
|
||||
console.log(rewardSelectionItems);
|
||||
const dialogRef = this.rewardSelectionDialog({
|
||||
title: 'Ein oder mehrere Artikel sind als Prämie verfügbar',
|
||||
data: {
|
||||
rewardSelectionItems,
|
||||
customerRewardPoints: this.primaryBonusCardPoints(),
|
||||
closeText,
|
||||
},
|
||||
displayClose: true,
|
||||
disableClose: false,
|
||||
width: '44.5rem',
|
||||
});
|
||||
const dialogResult = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (
|
||||
itemSelectionChanged(
|
||||
rewardSelectionItems,
|
||||
dialogResult?.rewardSelectionItems,
|
||||
)
|
||||
) {
|
||||
return dialogResult;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async reloadResources(): Promise<void> {
|
||||
// Start reloading all resources
|
||||
// Note: Price and redemption points will be loaded automatically by the effect
|
||||
// when selectionItemsWithOrderType changes after cart resources are reloaded
|
||||
await Promise.all([
|
||||
this.#shoppingCartResource.reload(),
|
||||
this.#rewardShoppingCartResource.reload(),
|
||||
this.#customerBonusCardsResource.reload(),
|
||||
]);
|
||||
|
||||
// Wait until all resources are fully loaded (isLoading becomes false)
|
||||
await firstValueFrom(
|
||||
this.#isLoading$.pipe(
|
||||
filter((isLoading) => !isLoading),
|
||||
first(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
patchState,
|
||||
signalStore,
|
||||
withComputed,
|
||||
withMethods,
|
||||
withState,
|
||||
} from '@ngrx/signals';
|
||||
import { RewardSelectionItem } from '@isa/checkout/data-access';
|
||||
import { computed } from '@angular/core';
|
||||
import { calculatePriceValue } from '../helper/get-price.helper';
|
||||
import { calculateLoyaltyPointsValue } from '../helper/get-loyalty-points.helper';
|
||||
|
||||
interface RewardSelectionState {
|
||||
// Single source of truth for all items with their allocations
|
||||
rewardSelectionItems: Record<number, RewardSelectionItem>;
|
||||
customerRewardPoints: number;
|
||||
}
|
||||
|
||||
const initialState: RewardSelectionState = {
|
||||
rewardSelectionItems: {},
|
||||
customerRewardPoints: 0,
|
||||
};
|
||||
|
||||
export const RewardSelectionStore = signalStore(
|
||||
withState(initialState),
|
||||
withComputed((store) => ({
|
||||
// Calculate total loyalty points needed for all selected reward items
|
||||
totalLoyaltyPointsNeeded: computed(() =>
|
||||
Object.values(store.rewardSelectionItems()).reduce(
|
||||
(sum, item) =>
|
||||
sum +
|
||||
calculateLoyaltyPointsValue(item) * (item?.rewardCartQuantity ?? 0),
|
||||
0,
|
||||
),
|
||||
),
|
||||
|
||||
// Calculate total price for all selected items
|
||||
totalPrice: computed(() =>
|
||||
Object.values(store.rewardSelectionItems()).reduce(
|
||||
(sum, item) =>
|
||||
sum + calculatePriceValue(item) * (item?.cartQuantity ?? 0),
|
||||
0,
|
||||
),
|
||||
),
|
||||
})),
|
||||
withMethods((store) => ({
|
||||
/**
|
||||
* Initialize store with cart and reward items
|
||||
*/
|
||||
initState({
|
||||
rewardSelectionItems,
|
||||
customerRewardPoints,
|
||||
}: {
|
||||
rewardSelectionItems: RewardSelectionItem[];
|
||||
customerRewardPoints: number;
|
||||
}) {
|
||||
const rewardSelectionItemsMapped = rewardSelectionItems.reduce(
|
||||
(acc, item) => {
|
||||
const itemId = item.item.id;
|
||||
if (itemId !== undefined) {
|
||||
acc[itemId] = item;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, RewardSelectionItem>,
|
||||
);
|
||||
|
||||
patchState(store, {
|
||||
rewardSelectionItems: rewardSelectionItemsMapped,
|
||||
customerRewardPoints,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the quantity of an item in the regular cart
|
||||
*/
|
||||
updateItemCartQuantity(itemId: number, quantity: number) {
|
||||
const currentItem = store.rewardSelectionItems()[itemId];
|
||||
patchState(store, {
|
||||
rewardSelectionItems: {
|
||||
...store.rewardSelectionItems(),
|
||||
[itemId]: {
|
||||
...currentItem,
|
||||
cartQuantity: quantity,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the quantity of an item in the reward cart
|
||||
*/
|
||||
updateItemRewardCartQuantity(itemId: number, quantity: number) {
|
||||
const currentItem = store.rewardSelectionItems()[itemId];
|
||||
patchState(store, {
|
||||
rewardSelectionItems: {
|
||||
...store.rewardSelectionItems(),
|
||||
[itemId]: {
|
||||
...currentItem,
|
||||
rewardCartQuantity: quantity,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
})),
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
@if (canOpen() || isLoading()) {
|
||||
<button
|
||||
*uiSkeletonLoader="isLoading(); width: '12.5rem'; height: '1.85rem'"
|
||||
type="button"
|
||||
(click)="openRewardSelectionDialog()"
|
||||
uiTextButton
|
||||
color="strong"
|
||||
[disabled]="isLoading()"
|
||||
>
|
||||
Prämie oder Warenkorb
|
||||
</button>
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
30
libs/checkout/shared/reward-selection-dialog/tsconfig.json
Normal file
30
libs/checkout/shared/reward-selection-dialog/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"importHelpers": true,
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"typeCheckHostBindings": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
29
libs/checkout/shared/reward-selection-dialog/vite.config.mts
Normal file
29
libs/checkout/shared/reward-selection-dialog/vite.config.mts
Normal file
@@ -0,0 +1,29 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import angular from '@analogjs/vite-plugin-angular';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir:
|
||||
'../../../../node_modules/.vite/libs/checkout/shared/reward-selection-dialog',
|
||||
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory:
|
||||
'../../../../coverage/libs/checkout/shared/reward-selection-dialog',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -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) {
|
||||
|
||||
@@ -9,7 +9,10 @@ describe('QuantityControlComponent', () => {
|
||||
let component: QuantityControlComponent;
|
||||
let fixture: ComponentFixture<QuantityControlComponent>;
|
||||
let liveAnnouncerMock: { announce: ReturnType<typeof vi.fn> };
|
||||
let tooltipMock: { show: ReturnType<typeof vi.fn>; hide: ReturnType<typeof vi.fn> };
|
||||
let tooltipMock: {
|
||||
show: ReturnType<typeof vi.fn>;
|
||||
hide: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
liveAnnouncerMock = {
|
||||
@@ -43,16 +46,16 @@ describe('QuantityControlComponent', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have default value of 1', () => {
|
||||
expect(component.value()).toBe(1);
|
||||
it('should have default value of 0', () => {
|
||||
expect(component.value()).toBe(0);
|
||||
});
|
||||
|
||||
it('should not be disabled by default', () => {
|
||||
expect(component.disabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should have default min of 1', () => {
|
||||
expect(component.min()).toBe(1);
|
||||
it('should have default min of 0', () => {
|
||||
expect(component.min()).toBe(0);
|
||||
});
|
||||
|
||||
it('should have default max of undefined', () => {
|
||||
@@ -68,9 +71,9 @@ describe('QuantityControlComponent', () => {
|
||||
it('should generate options from min to presetLimit with Edit', () => {
|
||||
const options = component.generatedOptions();
|
||||
|
||||
expect(options).toHaveLength(11); // 1-10 + Edit
|
||||
expect(options[0]).toEqual({ value: 1, label: '1' });
|
||||
expect(options[9]).toEqual({ value: 10, label: '10' });
|
||||
expect(options).toHaveLength(11); // 0-9 + Edit
|
||||
expect(options[0]).toEqual({ value: 0, label: '0' });
|
||||
expect(options[9]).toEqual({ value: 9, label: '9' });
|
||||
expect(options[10]).toEqual({ value: 'edit', label: 'Edit' });
|
||||
});
|
||||
|
||||
@@ -85,6 +88,7 @@ describe('QuantityControlComponent', () => {
|
||||
});
|
||||
|
||||
it('should not show Edit option when max is less than or equal to presetLimit', () => {
|
||||
fixture.componentRef.setInput('min', 1);
|
||||
fixture.componentRef.setInput('max', 5);
|
||||
fixture.componentRef.setInput('presetLimit', 10);
|
||||
fixture.detectChanges();
|
||||
@@ -105,7 +109,10 @@ describe('QuantityControlComponent', () => {
|
||||
const options = component.generatedOptions();
|
||||
|
||||
expect(options).toHaveLength(11); // 1-10 + Edit
|
||||
expect(options[options.length - 1]).toEqual({ value: 'edit', label: 'Edit' });
|
||||
expect(options[options.length - 1]).toEqual({
|
||||
value: 'edit',
|
||||
label: 'Edit',
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate correct range with custom min and presetLimit', () => {
|
||||
@@ -159,7 +166,7 @@ describe('QuantityControlComponent', () => {
|
||||
|
||||
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
|
||||
'Quantity changed to 8',
|
||||
'polite'
|
||||
'polite',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -217,7 +224,7 @@ describe('QuantityControlComponent', () => {
|
||||
|
||||
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
|
||||
'Edit mode activated. Type a quantity and press Enter to confirm or Escape to cancel.',
|
||||
'polite'
|
||||
'polite',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -241,7 +248,7 @@ describe('QuantityControlComponent', () => {
|
||||
expect(component.value()).toBe(1); // Clamped to min
|
||||
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
|
||||
'Adjusted to minimum 1',
|
||||
'polite'
|
||||
'polite',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -256,7 +263,7 @@ describe('QuantityControlComponent', () => {
|
||||
expect(component.value()).toBe(50); // Clamped to max
|
||||
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
|
||||
'Adjusted to maximum 50',
|
||||
'polite'
|
||||
'polite',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -270,7 +277,7 @@ describe('QuantityControlComponent', () => {
|
||||
expect(component.value()).toBe(originalValue); // Value unchanged
|
||||
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
|
||||
'Invalid input. Please enter a valid number.',
|
||||
'assertive'
|
||||
'assertive',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -291,7 +298,7 @@ describe('QuantityControlComponent', () => {
|
||||
|
||||
expect(liveAnnouncerMock.announce).toHaveBeenCalledWith(
|
||||
'Edit mode cancelled',
|
||||
'polite'
|
||||
'polite',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -91,9 +91,9 @@ export class QuantityControlComponent implements ControlValueAccessor {
|
||||
|
||||
/**
|
||||
* Current quantity value
|
||||
* @default 1
|
||||
* @default 0
|
||||
*/
|
||||
value = model<number>(1);
|
||||
value = model<number>(0);
|
||||
|
||||
/**
|
||||
* Whether the control is disabled
|
||||
@@ -103,9 +103,9 @@ export class QuantityControlComponent implements ControlValueAccessor {
|
||||
|
||||
/**
|
||||
* Minimum selectable value (starting point for dropdown options).
|
||||
* @default 1
|
||||
* @default 0
|
||||
*/
|
||||
min = input<number, unknown>(1, {
|
||||
min = input<number, unknown>(0, {
|
||||
transform: coerceNumberProperty,
|
||||
});
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user