Merged PR 1960: feat: implement reward points system in purchase options

feat: implement reward points system in purchase options

- Add version tracking to application store for data migration support
- Integrate redemption points display in purchase options list items
- Update purchase options modal to handle reward point calculations
- Enhance shopping cart item component with reward point functionality
- Add reward point schemas and validation to checkout data access
- Update user storage provider with versioning support
- Improve logger configuration in customer guard
- Update package dependencies for reward functionality
- Fix ESLint errors for code quality compliance

Refs: #5352

Related work items: #5263, #5352, #5355
This commit is contained in:
Lorenz Hilpert
2025-09-29 10:18:13 +00:00
committed by Nino Righi
parent 2387c60228
commit c745f82f3a
18 changed files with 40077 additions and 33380 deletions

View File

@@ -112,7 +112,7 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
const auth = injector.get(AuthService);
try {
await auth.init();
} catch (error) {
} catch {
statusElement.innerHTML = 'Authentifizierung wird durchgeführt...';
const strategy = injector.get(LoginStrategy);
await strategy.login();
@@ -137,7 +137,7 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
}
// Subscribe on Store changes and save to user storage
store.pipe(debounceTime(1000)).subscribe((state) => {
userStorage.set('store', state);
userStorage.set('store', { ...state, version });
});
} catch (error) {
console.error('Error during app initialization', error);

View File

@@ -1,20 +1,21 @@
import { Injectable } from "@angular/core";
import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
Router,
RouterStateSnapshot,
} from "@angular/router";
import { ApplicationProcess, ApplicationService } from "@core/application";
import { DomainCheckoutService } from "@domain/checkout";
import { logger } from "@isa/core/logging";
import { CustomerSearchNavigation } from "@shared/services/navigation";
import { first } from "rxjs/operators";
} from '@angular/router';
import { ApplicationProcess, ApplicationService } from '@core/application';
import { DomainCheckoutService } from '@domain/checkout';
import { logger } from '@isa/core/logging';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { first } from 'rxjs/operators';
@Injectable({ providedIn: "root" })
@Injectable({ providedIn: 'root' })
export class CanActivateCustomerGuard {
#logger = logger(() => ({
context: "CanActivateCustomerGuard",
tags: ["guard", "customer", "navigation"],
module: 'isa-app',
importMetaUrl: import.meta.url,
class: 'CanActivateCustomerGuard',
}));
constructor(
@@ -28,18 +29,18 @@ export class CanActivateCustomerGuard {
route: ActivatedRouteSnapshot,
{ url }: RouterStateSnapshot,
) {
if (url.startsWith("/kunde/customer/search/")) {
if (url.startsWith('/kunde/customer/search/')) {
const processId = Date.now(); // Generate a new process ID
// Extract parts before and after the pattern
const parts = url.split("/kunde/customer/");
const parts = url.split('/kunde/customer/');
if (parts.length === 2) {
const prefix = parts[0] + "/kunde/";
const suffix = "customer/" + parts[1];
const prefix = parts[0] + '/kunde/';
const suffix = 'customer/' + parts[1];
// Construct the new URL with process ID inserted
const newUrl = `${prefix}${processId}/${suffix}`;
this.#logger.info("Redirecting to URL with process ID", () => ({
this.#logger.info('Redirecting to URL with process ID', () => ({
originalUrl: url,
newUrl,
processId,
@@ -52,26 +53,26 @@ export class CanActivateCustomerGuard {
}
const processes = await this._applicationService
.getProcesses$("customer")
.getProcesses$('customer')
.pipe(first())
.toPromise();
const lastActivatedProcessId = (
await this._applicationService
.getLastActivatedProcessWithSectionAndType$("customer", "cart")
.getLastActivatedProcessWithSectionAndType$('customer', 'cart')
.pipe(first())
.toPromise()
)?.id;
const lastActivatedCartCheckoutProcessId = (
await this._applicationService
.getLastActivatedProcessWithSectionAndType$("customer", "cart-checkout")
.getLastActivatedProcessWithSectionAndType$('customer', 'cart-checkout')
.pipe(first())
.toPromise()
)?.id;
const lastActivatedGoodsOutProcessId = (
await this._applicationService
.getLastActivatedProcessWithSectionAndType$("customer", "goods-out")
.getLastActivatedProcessWithSectionAndType$('customer', 'goods-out')
.pipe(first())
.toPromise()
)?.id;
@@ -119,9 +120,9 @@ export class CanActivateCustomerGuard {
const newProcessId = Date.now();
await this._applicationService.createProcess({
id: newProcessId,
type: "cart",
section: "customer",
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === "cart"))}`,
type: 'cart',
section: 'customer',
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`,
});
await this.navigateToDefaultRoute(newProcessId);
@@ -138,9 +139,9 @@ export class CanActivateCustomerGuard {
// Ändere type cart-checkout zu cart
this._applicationService.patchProcess(processId, {
id: processId,
type: "cart",
section: "customer",
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === "cart"))}`,
type: 'cart',
section: 'customer',
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`,
data: {},
});
@@ -167,13 +168,13 @@ export class CanActivateCustomerGuard {
? buyer.organisation?.name
: buyer.lastName
: buyer.lastName
: `Vorgang ${this.processNumber(processes.filter((process) => process.type === "cart"))}`;
: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`;
// Ändere type goods-out zu cart
this._applicationService.patchProcess(processId, {
id: processId,
type: "cart",
section: "customer",
type: 'cart',
section: 'customer',
name,
});
@@ -183,7 +184,7 @@ export class CanActivateCustomerGuard {
processNumber(processes: ApplicationProcess[]) {
const processNumbers = processes?.map((process) =>
Number(process?.name?.replace(/\D/g, "")),
Number(process?.name?.replace(/\D/g, '')),
);
return !!processNumbers && processNumbers.length > 0
? this.findMissingNumber(processNumbers)

View File

@@ -1,15 +1,24 @@
<div class="flex flex-row">
<div class="shared-purchase-options-list-item__thumbnail w-16 max-h-28">
<img class="rounded shadow-card max-w-full max-h-full" [src]="product?.ean | productImage" [alt]="product?.name" />
<img
class="rounded shadow-card max-w-full max-h-full"
[src]="product?.ean | productImage"
[alt]="product?.name"
/>
</div>
<div class="shared-purchase-options-list-item__product grow ml-4">
<div class="shared-purchase-options-list-item__contributors font-bold">
{{ product?.contributors }}
</div>
<div class="shared-purchase-options-list-item__name font-bold h-12" sharedScaleContent>
<div
class="shared-purchase-options-list-item__name font-bold h-12"
sharedScaleContent
>
{{ product?.name }}
</div>
<div class="shared-purchase-options-list-item__format flex flex-row items-center">
<div
class="shared-purchase-options-list-item__format flex flex-row items-center"
>
<shared-icon [icon]="product?.format"></shared-icon>
<span class="ml-2 font-bold">{{ product?.formatDetail }}</span>
</div>
@@ -27,7 +36,9 @@
}
{{ product?.publicationDate | date: 'dd. MMMM yyyy' }}
</div>
<div class="shared-purchase-options-list-item__availabilities mt-3 grid grid-flow-row gap-2 justify-start">
<div
class="shared-purchase-options-list-item__availabilities mt-3 grid grid-flow-row gap-2 justify-start"
>
@if ((availabilities$ | async)?.length) {
<div class="whitespace-nowrap self-center">Verfügbar als</div>
}
@@ -36,23 +47,34 @@
<div
class="shared-purchase-options-list-item__availability grid grid-flow-col gap-2 items-center"
[attr.data-option]="availability.purchaseOption"
>
>
@switch (availability.purchaseOption) {
@case ('delivery') {
<shared-icon icon="isa-truck" [size]="22"></shared-icon>
{{ availability.data.estimatedDelivery?.start | date: 'EE dd.MM.' }}
{{
availability.data.estimatedDelivery?.start | date: 'EE dd.MM.'
}}
-
{{ availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.' }}
{{
availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.'
}}
}
@case ('dig-delivery') {
<shared-icon icon="isa-truck" [size]="22"></shared-icon>
{{ availability.data.estimatedDelivery?.start | date: 'EE dd.MM.' }}
{{
availability.data.estimatedDelivery?.start | date: 'EE dd.MM.'
}}
-
{{ availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.' }}
{{
availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.'
}}
}
@case ('b2b-delivery') {
<shared-icon icon="isa-b2b-truck" [size]="24"></shared-icon>
{{ availability.data.estimatedShippingDate | date: 'dd. MMMM yyyy' }}
{{
availability.data.estimatedShippingDate
| date: 'dd. MMMM yyyy'
}}
}
@case ('pickup') {
<shared-icon
@@ -63,7 +85,10 @@
icon="isa-box-out"
[size]="18"
></shared-icon>
{{ availability.data.estimatedShippingDate | date: 'dd. MMMM yyyy' }}
{{
availability.data.estimatedShippingDate
| date: 'dd. MMMM yyyy'
}}
<ui-tooltip
#orderDeadlineTooltip
yPosition="above"
@@ -72,7 +97,7 @@
[xOffset]="4"
[warning]="true"
[closeable]="true"
>
>
<b>{{ availability.data?.orderDeadline | orderDeadline }}</b>
</ui-tooltip>
}
@@ -95,91 +120,149 @@
}
</div>
</div>
<div class="shared-purchase-options-list-item__price text-right ml-4 flex flex-col items-end">
<div class="shared-purchase-options-list-item__price-value font-bold text-xl flex flex-row items-center">
<div
class="shared-purchase-options-list-item__price text-right ml-4 flex flex-col items-end"
>
<div
class="shared-purchase-options-list-item__price-value font-bold text-xl flex flex-row items-center"
>
<div class="relative flex flex-row justify-end items-start">
@if (canEditVat$ | async) {
<ui-select
class="w-[6.5rem] min-h-[3.4375rem] p-4 rounded-card border border-solid border-[#AEB7C1] mr-4"
tabindex="-1"
[formControl]="manualVatFormControl"
[defaultLabel]="'MwSt'"
@if (showRedemptionPoints()) {
<span class="isa-text-body-2-regular text-isa-neutral-600"
>Einlösen für:</span
>
<span class="ml-2 isa-text-body-2-bold text-isa-secondary-900"
>{{
redemptionPoints() * quantityFormControl.value
}}
Lesepunkte</span
>
} @else {
@if (canEditVat$ | async) {
<ui-select
class="w-[6.5rem] min-h-[3.4375rem] p-4 rounded-card border border-solid border-[#AEB7C1] mr-4"
tabindex="-1"
[formControl]="manualVatFormControl"
[defaultLabel]="'MwSt'"
>
@for (vat of vats$ | async; track vat) {
<ui-select-option [label]="vat.name + '%'" [value]="vat.vatType"></ui-select-option>
}
</ui-select>
}
@if (canEditPrice$ | async) {
<shared-input-control
[class.ml-6]="priceFormControl?.invalid && priceFormControl?.dirty"
>
<shared-input-control-indicator>
@if (priceFormControl?.invalid && priceFormControl?.dirty) {
<shared-icon icon="mat-info"></shared-icon>
@for (vat of vats$ | async; track vat) {
<ui-select-option
[label]="vat.name + '%'"
[value]="vat.vatType"
></ui-select-option>
}
</shared-input-control-indicator>
<input
[uiOverlayTrigger]="giftCardTooltip"
triggerOn="none"
#quantityInput
#priceOverlayTrigger="uiOverlayTrigger"
sharedInputControlInput
type="string"
class="w-24"
[formControl]="priceFormControl"
placeholder="00,00"
(sharedOnInit)="onPriceInputInit(quantityInput, priceOverlayTrigger)"
sharedNumberValue
</ui-select>
}
@if (canEditPrice$ | async) {
<shared-input-control
[class.ml-6]="
priceFormControl?.invalid && priceFormControl?.dirty
"
>
<shared-input-control-indicator>
@if (priceFormControl?.invalid && priceFormControl?.dirty) {
<shared-icon icon="mat-info"></shared-icon>
}
</shared-input-control-indicator>
<input
[uiOverlayTrigger]="giftCardTooltip"
triggerOn="none"
#quantityInput
#priceOverlayTrigger="uiOverlayTrigger"
sharedInputControlInput
type="string"
class="w-24"
[formControl]="priceFormControl"
placeholder="00,00"
(sharedOnInit)="
onPriceInputInit(quantityInput, priceOverlayTrigger)
"
sharedNumberValue
/>
<shared-input-control-suffix>EUR</shared-input-control-suffix>
<shared-input-control-error error="required">Preis ist ungültig</shared-input-control-error>
<shared-input-control-error error="pattern">Preis ist ungültig</shared-input-control-error>
<shared-input-control-error error="min">Preis ist ungültig</shared-input-control-error>
<shared-input-control-error error="max">Preis ist ungültig</shared-input-control-error>
<shared-input-control-error error="required"
>Preis ist ungültig</shared-input-control-error
>
<shared-input-control-error error="pattern"
>Preis ist ungültig</shared-input-control-error
>
<shared-input-control-error error="min"
>Preis ist ungültig</shared-input-control-error
>
<shared-input-control-error error="max"
>Preis ist ungültig</shared-input-control-error
>
</shared-input-control>
} @else {
{{ priceValue$ | async | currency: 'EUR' : 'code' }}
}
<ui-tooltip [warning]="true" xPosition="after" yPosition="below" [xOffset]="-55" [yOffset]="18" [closeable]="true" #giftCardTooltip>
Tragen Sie hier den
<br />
Gutscheinbetrag ein.
</ui-tooltip>
</div>
</div>
<ui-quantity-dropdown class="mt-2" [formControl]="quantityFormControl" [range]="maxSelectableQuantity$ | async"></ui-quantity-dropdown>
<div class="pt-7">
@if ((canAddResult$ | async)?.canAdd) {
<input
class="fancy-checkbox"
[class.checked]="selectedFormControl?.value"
[formControl]="selectedFormControl"
type="checkbox"
/>
}
<ui-tooltip
[warning]="true"
xPosition="after"
yPosition="below"
[xOffset]="-55"
[yOffset]="18"
[closeable]="true"
#giftCardTooltip
>
Tragen Sie hier den
<br />
Gutscheinbetrag ein.
</ui-tooltip>
</div>
@if (canAddResult$ | async; as canAddResult) {
@if (!canAddResult.canAdd) {
<span class="inline-block font-bold text-[#BE8100] mt-[14px] max-w-[19rem]">
{{ canAddResult.message }}
</span>
}
}
@if (showMaxAvailableQuantity$ | async) {
<span class="font-bold text-[#BE8100] mt-[14px]">
{{ (availability$ | async)?.inStock }} Exemplare sofort lieferbar
</span>
}
@if (showNotAvailable$ | async) {
<span class="font-bold text-[#BE8100] mt-[14px]">Derzeit nicht bestellbar</span>
</div>
<ui-quantity-dropdown
class="mt-2"
[formControl]="quantityFormControl"
[range]="maxSelectableQuantity$ | async"
data-what="purchase-option-quantity"
[attr.data-which]="product?.ean"
></ui-quantity-dropdown>
<div class="pt-7">
@if ((canAddResult$ | async)?.canAdd) {
<input
class="fancy-checkbox"
[class.checked]="selectedFormControl?.value"
[formControl]="selectedFormControl"
type="checkbox"
data-what="purchase-option-selector"
[attr.data-which]="product?.ean"
/>
}
</div>
@if (canAddResult$ | async; as canAddResult) {
@if (!canAddResult.canAdd) {
<span
class="inline-block font-bold text-[#BE8100] mt-[14px] max-w-[19rem]"
>
{{ canAddResult.message }}
</span>
}
}
@if (showMaxAvailableQuantity$ | async) {
<span class="font-bold text-[#BE8100] mt-[14px]">
{{ (availability$ | async)?.inStock }} Exemplare sofort lieferbar
</span>
}
@if (showNotAvailable$ | async) {
<span class="font-bold text-[#BE8100] mt-[14px]"
>Derzeit nicht bestellbar</span
>
}
</div>
<div class="flex flex-row">
<div class="w-16"></div>
<div class="grow shared-purchase-options-list-item__availabilities"></div>
</div>
<div class="flex flex-row">
<div class="w-16"></div>
<div class="grow shared-purchase-options-list-item__availabilities"></div>
</div>
@if (showLowStockMessage()) {
<div
class="text-isa-accent-red isa-text-body-2-bold mt-6 flex flex-row items-center gap-2"
>
<ng-icon name="isaOtherInfo" size="1.5rem"></ng-icon>
<div>Geringer Bestand - Artikel holen vor Abschluss</div>
</div>
}

View File

@@ -1,6 +1,20 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, Input, OnInit, OnDestroy, OnChanges, SimpleChanges } from '@angular/core';
import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
OnChanges,
SimpleChanges,
computed,
input,
} from '@angular/core';
import {
FormControl,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { ProductImageModule } from '@cdn/product-image';
import { InputControlModule } from '@shared/components/input-control';
import { ElementLifecycleModule } from '@shared/directives/element-lifecycle';
@@ -10,14 +24,29 @@ import { UiSpinnerModule } from '@ui/spinner';
import { UiTooltipModule } from '@ui/tooltip';
import { combineLatest, ReplaySubject, Subscription } from 'rxjs';
import { IconComponent } from '@shared/components/icon';
import { map, take, shareReplay, startWith, switchMap, withLatestFrom } from 'rxjs/operators';
import {
map,
take,
shareReplay,
startWith,
switchMap,
withLatestFrom,
} from 'rxjs/operators';
import { GIFT_CARD_MAX_PRICE, PRICE_PATTERN } from '../constants';
import { Item, PurchaseOptionsStore, isItemDTO, isShoppingCartItemDTO } from '../store';
import {
Item,
PurchaseOptionsStore,
isItemDTO,
isShoppingCartItemDTO,
} from '../store';
import { OrderDeadlinePipeModule } from '@shared/pipes/order-deadline';
import { UiSelectModule } from '@ui/select';
import { KeyValueDTOOfStringAndString } from '@generated/swagger/cat-search-api';
import { ScaleContentComponent } from '@shared/components/scale-content';
import moment from 'moment';
import { toSignal } from '@angular/core/rxjs-interop';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaOtherInfo } from '@isa/icons';
@Component({
selector: 'shared-purchase-options-list-item',
@@ -39,24 +68,39 @@ import moment from 'moment';
UiCommonModule,
ScaleContentComponent,
OrderDeadlinePipeModule,
NgIcon,
],
host: { class: 'shared-purchase-options-list-item' },
providers: [provideIcons({ isaOtherInfo })],
})
export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnChanges {
export class PurchaseOptionsListItemComponent
implements OnInit, OnDestroy, OnChanges
{
private _subscriptions = new Subscription();
private _itemSubject = new ReplaySubject<Item>(1);
@Input() item: Item;
item = input.required<Item>();
get item$() {
return this._itemSubject.asObservable();
}
get product() {
return this.item.product;
return this.item().product;
}
redemptionPoints = computed(() => {
const item = this.item();
if (isShoppingCartItemDTO(item, this._store.type)) {
return item.loyalty?.value;
}
return item.redemptionPoints;
});
showRedemptionPoints = toSignal(this._store.useRedemptionPoints$);
quantityFormControl = new FormControl<number>(null);
private readonly _giftCardValidators = [
@@ -79,13 +123,19 @@ export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnCh
selectedFormControl = new FormControl<boolean>(false);
availabilities$ = this.item$.pipe(switchMap((item) => this._store.getAvailabilitiesForItem$(item.id)));
availabilities$ = this.item$.pipe(
switchMap((item) => this._store.getAvailabilitiesForItem$(item.id)),
);
availability$ = combineLatest([this.item$, this._store.purchaseOption$]).pipe(
switchMap(([item, purchaseOption]) => this._store.getAvailabilityWithPurchaseOption$(item.id, purchaseOption)),
switchMap(([item, purchaseOption]) =>
this._store.getAvailabilityWithPurchaseOption$(item.id, purchaseOption),
),
map((availability) => availability?.data),
);
availability = toSignal(this.availability$);
price$ = this.item$.pipe(switchMap((item) => this._store.getPrice$(item.id)));
priceValue$ = this.price$.pipe(map((price) => price?.value?.value));
@@ -97,10 +147,14 @@ export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnCh
take(2),
map((price) => {
// Logik nur beim Hinzufügen über Kaufoptionen, da über Ändern im Warenkorb die Info fehlt ob das jeweilige ShoppingCartItem ein Archivartikel ist oder nicht
const features = this.item?.features as KeyValueDTOOfStringAndString[];
const features = this.item().features as KeyValueDTOOfStringAndString[];
if (!!features && Array.isArray(features)) {
const isArchive = !!features?.find((feature) => feature?.enabled === true && feature?.key === 'ARC');
return isArchive ? !price?.value?.value || price?.vat === undefined : false;
const isArchive = !!features?.find(
(feature) => feature?.enabled === true && feature?.key === 'ARC',
);
return isArchive
? !price?.value?.value || price?.vat === undefined
: false;
}
return false;
}),
@@ -111,22 +165,36 @@ export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnCh
priceVat$ = this.price$.pipe(map((price) => price?.vat?.vatType));
canAddResult$ = this.item$.pipe(
switchMap((item) => this._store.getCanAddResultForItemAndCurrentPurchaseOption$(item.id)),
switchMap((item) =>
this._store.getCanAddResultForItemAndCurrentPurchaseOption$(item.id),
),
);
canEditPrice$ = this.item$.pipe(
switchMap((item) => combineLatest([this.canAddResult$, this._store.getCanEditPrice$(item.id)])),
switchMap((item) =>
combineLatest([
this.canAddResult$,
this._store.getCanEditPrice$(item.id),
]),
),
map(([canAddResult, canEditPrice]) => canAddResult?.canAdd && canEditPrice),
);
canEditVat$ = this.item$.pipe(
switchMap((item) => combineLatest([this.canAddResult$, this._store.getCanEditVat$(item.id)])),
switchMap((item) =>
combineLatest([this.canAddResult$, this._store.getCanEditVat$(item.id)]),
),
map(([canAddResult, canEditVat]) => canAddResult?.canAdd && canEditVat),
);
isGiftCard$ = this.item$.pipe(switchMap((item) => this._store.getIsGiftCard$(item.id)));
isGiftCard$ = this.item$.pipe(
switchMap((item) => this._store.getIsGiftCard$(item.id)),
);
maxSelectableQuantity$ = combineLatest([this._store.purchaseOption$, this.availability$]).pipe(
maxSelectableQuantity$ = combineLatest([
this._store.purchaseOption$,
this.availability$,
]).pipe(
map(([purchaseOption, availability]) => {
if (purchaseOption === 'in-store') {
return availability?.inStock;
@@ -137,9 +205,16 @@ export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnCh
startWith(999),
);
showMaxAvailableQuantity$ = combineLatest([this._store.purchaseOption$, this.availability$, this.item$]).pipe(
showMaxAvailableQuantity$ = combineLatest([
this._store.purchaseOption$,
this.availability$,
this.item$,
]).pipe(
map(([purchaseOption, availability, item]) => {
if (purchaseOption === 'pickup' && availability?.inStock < item.quantity) {
if (
purchaseOption === 'pickup' &&
availability?.inStock < item.quantity
) {
return true;
}
@@ -148,10 +223,17 @@ export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnCh
);
fetchingAvailabilities$ = this.item$
.pipe(switchMap((item) => this._store.getFetchingAvailabilitiesForItem$(item.id)))
.pipe(
switchMap((item) =>
this._store.getFetchingAvailabilitiesForItem$(item.id),
),
)
.pipe(map((fetchingAvailabilities) => fetchingAvailabilities.length > 0));
showNotAvailable$ = combineLatest([this.availabilities$, this.fetchingAvailabilities$]).pipe(
showNotAvailable$ = combineLatest([
this.availabilities$,
this.fetchingAvailabilities$,
]).pipe(
map(([availabilities, fetchingAvailabilities]) => {
if (fetchingAvailabilities) {
return false;
@@ -180,7 +262,7 @@ export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnCh
);
// #4813 Fix: Hier muss als Kriterium auf EAN statt itemId verglichen werden, denn ein ShoppingCartItemDTO (this.item) hat eine andere ItemId wie das ItemDTO (availability.itemId)
const firstDayOfSale = catalogAvailabilities?.find(
(availability) => this.item?.product?.ean === availability?.ean,
(availability) => this.item().product?.ean === availability?.ean,
)?.data?.firstDayOfSale;
return this.firstDayOfSaleBiggerThanToday(firstDayOfSale);
}
@@ -188,6 +270,22 @@ export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnCh
return undefined;
}
useRedemptionPoints = toSignal(this._store.useRedemptionPoints$);
purchaseOption = toSignal(this._store.purchaseOption$);
isReservePurchaseOption = computed(() => {
return this.purchaseOption() === 'in-store';
});
showLowStockMessage = computed(() => {
return (
this.useRedemptionPoints() &&
this.isReservePurchaseOption() &&
this.availability().inStock < 2
);
});
constructor(private _store: PurchaseOptionsStore) {}
firstDayOfSaleBiggerThanToday(firstDayOfSale: string): Date {
@@ -197,8 +295,11 @@ export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnCh
return undefined;
}
onPriceInputInit(target: HTMLElement, overlayTrigger: UiOverlayTriggerDirective) {
if (this._store.getIsGiftCard(this.item.id)) {
onPriceInputInit(
target: HTMLElement,
overlayTrigger: UiOverlayTriggerDirective,
) {
if (this._store.getIsGiftCard(this.item().id)) {
overlayTrigger.open();
}
@@ -234,7 +335,7 @@ export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnCh
ngOnChanges({ item }: SimpleChanges) {
if (item) {
this._itemSubject.next(this.item);
this._itemSubject.next(this.item());
}
}
@@ -244,13 +345,15 @@ export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnCh
}
initPriceValidatorSubscription() {
const sub = this.item$.pipe(switchMap((item) => this._store.getIsGiftCard$(item.id))).subscribe((isGiftCard) => {
if (isGiftCard) {
this.priceFormControl.setValidators(this._giftCardValidators);
} else {
this.priceFormControl.setValidators(this._defaultValidators);
}
});
const sub = this.item$
.pipe(switchMap((item) => this._store.getIsGiftCard$(item.id)))
.subscribe((isGiftCard) => {
if (isGiftCard) {
this.priceFormControl.setValidators(this._giftCardValidators);
} else {
this.priceFormControl.setValidators(this._defaultValidators);
}
});
this._subscriptions.add(sub);
}
@@ -262,49 +365,56 @@ export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnCh
}
});
const valueChangesSub = this.quantityFormControl.valueChanges.subscribe((quantity) => {
if (this.item.quantity !== quantity) {
this._store.setItemQuantity(this.item.id, quantity);
}
});
const valueChangesSub = this.quantityFormControl.valueChanges.subscribe(
(quantity) => {
if (this.item().quantity !== quantity) {
this._store.setItemQuantity(this.item().id, quantity);
}
},
);
this._subscriptions.add(sub);
this._subscriptions.add(valueChangesSub);
}
initPriceSubscription() {
const sub = combineLatest([this.canEditPrice$, this.price$]).subscribe(([canEditPrice, price]) => {
if (!canEditPrice) {
return;
}
const priceStr = this.stringifyPrice(price?.value?.value);
if (priceStr === '') return;
if (this.parsePrice(this.priceFormControl.value) !== price?.value?.value) {
this.priceFormControl.setValue(priceStr);
}
});
const valueChangesSub = combineLatest([this.canEditPrice$, this.priceFormControl.valueChanges]).subscribe(
([canEditPrice, value]) => {
const sub = combineLatest([this.canEditPrice$, this.price$]).subscribe(
([canEditPrice, price]) => {
if (!canEditPrice) {
return;
}
const price = this._store.getPrice(this.item.id);
const parsedPrice = this.parsePrice(value);
const priceStr = this.stringifyPrice(price?.value?.value);
if (priceStr === '') return;
if (!parsedPrice) {
this._store.setPrice(this.item.id, null);
return;
}
if (price[this.item.id] !== parsedPrice) {
this._store.setPrice(this.item.id, this.parsePrice(value));
if (
this.parsePrice(this.priceFormControl.value) !== price?.value?.value
) {
this.priceFormControl.setValue(priceStr);
}
},
);
const valueChangesSub = combineLatest([
this.canEditPrice$,
this.priceFormControl.valueChanges,
]).subscribe(([canEditPrice, value]) => {
if (!canEditPrice) {
return;
}
const price = this._store.getPrice(this.item().id);
const parsedPrice = this.parsePrice(value);
if (!parsedPrice) {
this._store.setPrice(this.item().id, null);
return;
}
if (price[this.item().id] !== parsedPrice) {
this._store.setPrice(this.item().id, this.parsePrice(value));
}
});
this._subscriptions.add(sub);
this._subscriptions.add(valueChangesSub);
}
@@ -313,17 +423,17 @@ export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnCh
const valueChangesSub = this.manualVatFormControl.valueChanges
.pipe(withLatestFrom(this.vats$))
.subscribe(([formVatType, vats]) => {
const price = this._store.getPrice(this.item.id);
const price = this._store.getPrice(this.item().id);
const vat = vats.find((vat) => vat?.vatType === Number(formVatType));
if (!vat) {
this._store.setVat(this.item.id, null);
this._store.setVat(this.item().id, null);
return;
}
if (price[this.item.id]?.vat?.vatType !== vat?.vatType) {
this._store.setVat(this.item.id, vat);
if (price[this.item().id]?.vat?.vatType !== vat?.vatType) {
this._store.setVat(this.item().id, vat);
}
});
this._subscriptions.add(valueChangesSub);
@@ -331,18 +441,26 @@ export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnCh
initSelectedSubscription() {
const sub = this.item$
.pipe(switchMap((item) => this._store.selectedItemIds$.pipe(map((ids) => ids.includes(item.id)))))
.pipe(
switchMap((item) =>
this._store.selectedItemIds$.pipe(
map((ids) => ids.includes(item.id)),
),
),
)
.subscribe((selected) => {
if (this.selectedFormControl.value !== selected) {
this.selectedFormControl.setValue(selected);
}
});
const valueChangesSub = this.selectedFormControl.valueChanges.subscribe((selected) => {
const current = this._store.selectedItemIds.includes(this.item.id);
if (current !== selected) {
this._store.setSelectedItem(this.item.id, selected);
}
});
const valueChangesSub = this.selectedFormControl.valueChanges.subscribe(
(selected) => {
const current = this._store.selectedItemIds.includes(this.item().id);
if (current !== selected) {
this._store.setSelectedItem(this.item().id, selected);
}
},
);
this._subscriptions.add(sub);
this._subscriptions.add(valueChangesSub);
}

View File

@@ -10,6 +10,7 @@ export interface PurchaseOptionsModalData {
tabId: number;
shoppingCartId: number;
type: ActionType;
useRedemptionPoints?: boolean;
items: Array<ItemDTO | ShoppingCartItemDTO>;
pickupBranch?: BranchDTO;
inStoreBranch?: BranchDTO;
@@ -19,6 +20,7 @@ export interface PurchaseOptionsModalData {
export interface PurchaseOptionsModalContext {
shoppingCartId: number;
type: ActionType;
useRedemptionPoints: boolean;
items: Array<ItemDTO | ShoppingCartItemDTO>;
selectedCustomer?: Customer;
selectedBranch?: BranchDTO;

View File

@@ -21,6 +21,7 @@ export class PurchaseOptionsModalService {
data: PurchaseOptionsModalData,
): Promise<UiModalRef<string, PurchaseOptionsModalData>> {
const context: PurchaseOptionsModalContext = {
useRedemptionPoints: !!data.useRedemptionPoints,
...data,
};

View File

@@ -1,9 +1,7 @@
import { PriceDTO, PriceValueDTO } from '@generated/swagger/checkout-api';
import { PriceDTO } from '@generated/swagger/checkout-api';
import {
DEFAULT_PRICE_DTO,
DEFAULT_PRICE_VALUE,
GIFT_CARD_MAX_PRICE,
GIFT_CARD_TYPE,
PURCHASE_OPTIONS,
} from '../constants';
import { isArchive, isGiftCard, isItemDTO } from './purchase-options.helpers';
@@ -22,6 +20,10 @@ export function getType(state: PurchaseOptionsState): ActionType {
return state.type;
}
export function getUseRedemptionPoints(state: PurchaseOptionsState): boolean {
return state.useRedemptionPoints;
}
export function getShoppingCartId(state: PurchaseOptionsState): number {
return state.shoppingCartId;
}
@@ -337,7 +339,7 @@ export function getAvailabilityPriceForPurchaseOption(
return (state) => {
const item = getItems(state).find((item) => item.id === itemId);
const type = getType(state);
let availabilities = getAvailabilitiesForItem(itemId)(state);
const availabilities = getAvailabilitiesForItem(itemId)(state);
let availability = availabilities.find(
(availability) => availability.purchaseOption === purchaseOption,
@@ -530,7 +532,7 @@ export function getAvailabilityWithPurchaseOption(
purchaseOption: PurchaseOption,
): (state: PurchaseOptionsState) => Availability {
return (state) => {
let availabilities = getAvailabilitiesForItem(itemId, true)(state);
const availabilities = getAvailabilitiesForItem(itemId, true)(state);
let availability = availabilities.find(
(availability) => availability.purchaseOption === purchaseOption,
@@ -597,7 +599,7 @@ export function canContinue(state: PurchaseOptionsState): boolean {
const actionType = getType(state);
for (let item of items) {
for (const item of items) {
if (isGiftCard(item, actionType)) {
const price = getPriceForPurchaseOption(item.id, purchaseOption)(state);
if (
@@ -647,7 +649,7 @@ export function canContinue(state: PurchaseOptionsState): boolean {
return false;
}
let availability = getAvailabilityWithPurchaseOption(
const availability = getAvailabilityWithPurchaseOption(
item.id,
purchaseOption,
)(state);

View File

@@ -35,4 +35,6 @@ export interface PurchaseOptionsState {
customerFeatures: Record<string, string>;
fetchingAvailabilities: Array<FetchingAvailability>;
useRedemptionPoints: boolean;
}

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@angular/core';
import { ComponentStore } from '@ngrx/component-store';
import { logger } from '@isa/core/logging';
import { PurchaseOptionsModalContext } from '../purchase-options-modal.data';
import { PurchaseOptionsService } from './purchase-options.service';
import { PurchaseOptionsState } from './purchase-options.state';
@@ -39,7 +40,7 @@ import { uniqueId } from 'lodash';
import { VATDTO } from '@generated/swagger/oms-api';
import { DomainCatalogService } from '@domain/catalog';
import { ItemDTO } from '@generated/swagger/cat-search-api';
import { OrderType } from '@isa/checkout/data-access';
import { Loyalty, OrderType, Promotion } from '@isa/checkout/data-access';
@Injectable()
export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
@@ -49,6 +50,12 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
type$ = this.select(Selectors.getType);
get useRedemptionPoints() {
return this.get(Selectors.getUseRedemptionPoints);
}
useRedemptionPoints$ = this.select(Selectors.getUseRedemptionPoints);
get shoppingCartId() {
return this.get(Selectors.getShoppingCartId);
}
@@ -149,6 +156,12 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
return this._service.getVats$();
}
#logger = logger(() => ({
class: 'PurchaseOptionsStore',
module: 'purchase-options',
importMetaUrl: import.meta.url,
}));
constructor(
private _service: PurchaseOptionsService,
private _catalogService: DomainCatalogService,
@@ -167,6 +180,7 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
canAddResults: [],
customerFeatures: {},
fetchingAvailabilities: [],
useRedemptionPoints: false,
});
}
@@ -198,6 +212,7 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
type,
inStoreBranch,
pickupBranch,
useRedemptionPoints: showRedemptionPoints,
}: PurchaseOptionsModalContext) {
const defaultBranch = await this._service.fetchDefaultBranch().toPromise();
@@ -213,6 +228,7 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
this.patchState({
type: type,
shoppingCartId,
useRedemptionPoints: showRedemptionPoints,
items: items.map((item) => ({
...item,
quantity: item['quantity'] ?? 1,
@@ -233,7 +249,7 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
private async _loadAvailabilities() {
const items = this.items;
const promises: Promise<any>[] = [];
const promises: Promise<unknown>[] = [];
this._loadCatalogueAvailability();
@@ -258,7 +274,7 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
private async loadItemAvailability(itemId: number) {
const item = this.items.find((item) => item.id === itemId);
const promises: Promise<any>[] = [];
const promises: Promise<unknown>[] = [];
const purchaseOption = this.purchaseOption;
@@ -344,7 +360,11 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
this._checkAndSetAvailability(availability);
} catch (err) {
console.error('_loadPickupAvailability', err);
this.#logger.error('Failed to load pickup availability', err, () => ({
itemId: itemData.sourceId,
quantity: itemData.quantity,
branchId: branch?.id,
}));
}
this.removeFetchingAvailability({
@@ -377,7 +397,11 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
this._checkAndSetAvailability(availability);
} catch (err) {
console.error('_loadInStoreAvailability', err);
this.#logger.error('Failed to load in-store availability', err, () => ({
itemId: itemData.sourceId,
quantity: itemData.quantity,
branchId: branch?.id,
}));
}
this.removeFetchingAvailability({
@@ -406,7 +430,10 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
this._checkAndSetAvailability(availability);
} catch (error) {
console.error('_loadDeliveryAvailability', error);
this.#logger.error('Failed to load delivery availability', error, () => ({
itemId: itemData.sourceId,
quantity: itemData.quantity,
}));
}
this.removeFetchingAvailability({
@@ -436,7 +463,14 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
this._checkAndSetAvailability(availability);
} catch (error) {
console.error('_loadDigDeliveryAvailability', error);
this.#logger.error(
'Failed to load digital delivery availability',
error,
() => ({
itemId: itemData.sourceId,
quantity: itemData.quantity,
}),
);
}
this.removeFetchingAvailability({
@@ -466,7 +500,14 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
this._checkAndSetAvailability(availability);
} catch (error) {
console.error('_loadB2bDeliveryAvailability', error);
this.#logger.error(
'Failed to load B2B delivery availability',
error,
() => ({
itemId: itemData.sourceId,
quantity: itemData.quantity,
}),
);
}
this.removeFetchingAvailability({
@@ -496,7 +537,9 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
this._checkAndSetAvailability(availability);
} catch (error) {
console.error('_loadDownloadAvailability', error);
this.#logger.error('Failed to load download availability', error, () => ({
itemId: itemData.sourceId,
}));
}
this.removeFetchingAvailability({
@@ -524,7 +567,7 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
this.patchState({ availabilities });
} else {
let availabilities = this.availabilities.filter(
const availabilities = this.availabilities.filter(
(a) =>
!(
a.itemId === availability.itemId &&
@@ -600,7 +643,7 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
}
// Get Abholung availability
let pickupAvailability = this.availabilities.find(
const pickupAvailability = this.availabilities.find(
(a) => a.itemId === item.id && a.purchaseOption === 'pickup',
);
if (pickupAvailability) {
@@ -650,7 +693,11 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
});
});
} catch (error) {
console.error('_loadCanAdd', error);
this.#logger.error('Failed to load canAdd results', error, () => ({
orderType: key,
shoppingCartId: this.shoppingCartId,
itemCount: itemPayloads.length,
}));
}
}
}
@@ -795,7 +842,14 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
item.id,
);
} catch (error) {
console.error('removeItem', error);
this.#logger.error(
'Failed to remove item from shopping cart',
error,
() => ({
shoppingCartId: this.shoppingCartId,
itemId: item.id,
}),
);
}
}
@@ -863,7 +917,7 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
return this.select(Selectors.getIsGiftCard(itemId));
}
setPrice(itemId: number, value: number, manually: boolean = false) {
setPrice(itemId: number, value: number, manually = false) {
const prices = this.prices;
let price = prices[itemId];
@@ -959,14 +1013,30 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
purchaseOption: PurchaseOption,
): AddToShoppingCartDTO {
const item = this.items.find((i) => i.id === itemId);
if (!isItemDTO(item, this.type)) {
throw new Error('Invalid item');
}
const price = this.getPriceForPurchaseOption(itemId, this.purchaseOption);
const availability = this.getAvailabilityWithPurchaseOption(
itemId,
purchaseOption,
);
if (!isItemDTO(item, this.type)) {
throw new Error('Invalid item');
let promotion: Promotion | null = { value: item.promoPoints };
let loyalty: Loyalty | null = null;
const redemptionPoints: number | null = item.redemptionPoints || null;
// "Lesepunkte einlösen" logic
// If "Lesepunkte einlösen" is checked and item has redemption points, set price to 0 and remove promotion
if (this.useRedemptionPoints) {
// If loyalty is set, we need to remove promotion
promotion = null;
// Set loyalty points from item
loyalty = { value: redemptionPoints };
// Set price to 0
price.value.value = 0;
}
let destination: EntityDTOContainerOfDestinationDTO;
@@ -990,13 +1060,8 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
catalogProductNumber:
item?.product?.catalogProductNumber ?? String(item.id),
},
promotion: { value: item.promoPoints },
// retailPrice: {
// value: price.value.value,
// currency: price.value.currency,
// vatType: price.vat.vatType,
// },
// shopItemId: item.id ?? +item.product.catalogProductNumber,
promotion,
loyalty,
};
}
@@ -1005,14 +1070,20 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
purchaseOption: PurchaseOption,
): UpdateShoppingCartItemDTO {
const item = this.items.find((i) => i.id === itemId);
if (!isShoppingCartItemDTO(item, this.type)) {
throw new Error('Invalid item');
}
const price = this.getPriceForPurchaseOption(itemId, this.purchaseOption);
const availability = this.getAvailabilityWithPurchaseOption(
itemId,
purchaseOption,
);
if (!isShoppingCartItemDTO(item, this.type)) {
throw new Error('Invalid item');
// If loyalty points is set we know it is a redemption item
// we need to make sure we don't update the price
if (this.useRedemptionPoints) {
price.value.value = 0;
}
let destination: EntityDTOContainerOfDestinationDTO;
@@ -1030,11 +1101,6 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
quantity: item.quantity,
availability: { ...availability.data, price },
destination,
// retailPrice: {
// value: price.value.value,
// currency: price.value.currency,
// vatType: price.vat.vatType,
// },
};
}
@@ -1051,7 +1117,7 @@ export class PurchaseOptionsStore extends ComponentStore<PurchaseOptionsState> {
);
await this._service.addItemToShoppingCart(this.shoppingCartId, payloads);
} else if (type === 'update') {
const payloads = this.selectedItemIds.map((itemId) =>
this.selectedItemIds.map((itemId) =>
this.getUpdateShoppingCartItemDTOForItem(itemId, purchaseOption),
);

View File

@@ -1,5 +1,8 @@
<div class="item-thumbnail">
<a [routerLink]="productSearchDetailsPath" [queryParams]="{ main_qs: item?.product?.ean }">
<a
[routerLink]="productSearchDetailsPath"
[queryParams]="{ main_qs: item?.product?.ean }"
>
@if (item?.product?.ean | productImage; as thumbnailUrl) {
<img loading="lazy" [src]="thumbnailUrl" [alt]="item?.product?.name" />
}
@@ -7,12 +10,16 @@
</div>
<div class="item-contributors">
@for (contributor of contributors$ | async; track contributor; let last = $last) {
@for (
contributor of contributors$ | async;
track contributor;
let last = $last
) {
<a
[routerLink]="productSearchResultsPath"
[queryParams]="{ main_qs: contributor, main_author: 'author' }"
(click)="$event?.stopPropagation()"
>
>
{{ contributor }}{{ last ? '' : ';' }}
</a>
}
@@ -24,8 +31,12 @@
[class.text-p1]="item?.product?.name?.length >= 50 || !isTablet"
[class.text-p2]="item?.product?.name?.length >= 60 && isTablet"
[class.text-p3]="item?.product?.name?.length >= 100"
>
<a
[routerLink]="productSearchDetailsPath"
[queryParams]="{ main_qs: item?.product?.ean }"
>{{ item?.product?.name }}</a
>
<a [routerLink]="productSearchDetailsPath" [queryParams]="{ main_qs: item?.product?.ean }">{{ item?.product?.name }}</a>
</div>
@if (item?.product?.format && item?.product?.formatDetail) {
@@ -34,14 +45,16 @@
<img
src="assets/images/Icon_{{ item?.product?.format }}.svg"
[alt]="item?.product?.formatDetail"
/>
/>
}
{{ item?.product?.formatDetail }}
</div>
}
<div class="item-info text-p2">
<div class="mb-1">{{ item?.product?.manufacturer | substr: 25 }} | {{ item?.product?.ean }}</div>
<div class="mb-1">
{{ item?.product?.manufacturer | substr: 25 }} | {{ item?.product?.ean }}
</div>
<div class="mb-1">
{{ item?.product?.volume }}
@if (item?.product?.volume && item?.product?.publicationDate) {
@@ -59,19 +72,35 @@
<shared-skeleton-loader class="w-40"></shared-skeleton-loader>
} @else {
@if (orderType === 'Abholung') {
<div class="item-date" [class.availability-changed]="estimatedShippingDateChanged$ | async">
Abholung ab {{ item?.availability?.estimatedShippingDate | date }}
</div>
}
@if (orderType === 'Versand' || orderType === 'B2B-Versand' || orderType === 'DIG-Versand') {
<div
class="item-date"
[class.availability-changed]="estimatedShippingDateChanged$ | async"
>
>
Abholung ab {{ item?.availability?.estimatedShippingDate | date }}
</div>
}
@if (
orderType === 'Versand' ||
orderType === 'B2B-Versand' ||
orderType === 'DIG-Versand'
) {
<div
class="item-date"
[class.availability-changed]="estimatedShippingDateChanged$ | async"
>
@if (item?.availability?.estimatedDelivery) {
Zustellung zwischen {{ (item?.availability?.estimatedDelivery?.start | date: 'EEE, dd.MM.')?.replace('.', '') }}
Zustellung zwischen
{{
(
item?.availability?.estimatedDelivery?.start | date: 'EEE, dd.MM.'
)?.replace('.', '')
}}
und
{{ (item?.availability?.estimatedDelivery?.stop | date: 'EEE, dd.MM.')?.replace('.', '') }}
{{
(
item?.availability?.estimatedDelivery?.stop | date: 'EEE, dd.MM.'
)?.replace('.', '')
}}
} @else {
Versand {{ item?.availability?.estimatedShippingDate | date }}
}
@@ -79,14 +108,15 @@
}
}
@if (olaError$ | async) {
<div class="item-availability-message">Artikel nicht verfügbar</div>
}
</div>
<div class="item-price-stock flex flex-col">
<div class="text-p2 font-bold">{{ item?.availability?.price?.value?.value | currency: 'EUR' : 'code' }}</div>
<div class="text-p2 font-bold">
{{ item?.availability?.price?.value?.value | currency: 'EUR' : 'code' }}
</div>
<div class="text-p2 font-normal">
@if (!(isDummy$ | async)) {
<ui-quantity-dropdown
@@ -111,18 +141,28 @@
<div class="actions">
@if (!(hasOrderType$ | async)) {
<button
[disabled]="(loadingOnQuantityChangeById$ | async) === item?.id || (loadingOnItemChangeById$ | async) === item?.id"
[disabled]="
(loadingOnQuantityChangeById$ | async) === item?.id ||
(loadingOnItemChangeById$ | async) === item?.id
"
(click)="onChangeItem()"
>
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id"
>Lieferweg auswählen</ui-spinner
>
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id">Lieferweg auswählen</ui-spinner>
</button>
}
@if (canEdit$ | async) {
<button
[disabled]="(loadingOnQuantityChangeById$ | async) === item?.id || (loadingOnItemChangeById$ | async) === item?.id"
[disabled]="
(loadingOnQuantityChangeById$ | async) === item?.id ||
(loadingOnItemChangeById$ | async) === item?.id
"
(click)="onChangeItem()"
>
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id"
>Lieferweg ändern</ui-spinner
>
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id">Lieferweg ändern</ui-spinner>
</button>
}
</div>

View File

@@ -39,12 +39,22 @@ export interface ShoppingCartItemComponentState {
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class ShoppingCartItemComponent extends ComponentStore<ShoppingCartItemComponentState> implements OnInit {
export class ShoppingCartItemComponent
extends ComponentStore<ShoppingCartItemComponentState>
implements OnInit
{
private _zone = inject(NgZone);
@Output() changeItem = new EventEmitter<{ shoppingCartItem: ShoppingCartItemDTO }>();
@Output() changeDummyItem = new EventEmitter<{ shoppingCartItem: ShoppingCartItemDTO }>();
@Output() changeQuantity = new EventEmitter<{ shoppingCartItem: ShoppingCartItemDTO; quantity: number }>();
@Output() changeItem = new EventEmitter<{
shoppingCartItem: ShoppingCartItemDTO;
}>();
@Output() changeDummyItem = new EventEmitter<{
shoppingCartItem: ShoppingCartItemDTO;
}>();
@Output() changeQuantity = new EventEmitter<{
shoppingCartItem: ShoppingCartItemDTO;
quantity: number;
}>();
@Input()
get item() {
@@ -58,9 +68,15 @@ export class ShoppingCartItemComponent extends ComponentStore<ShoppingCartItemCo
readonly item$ = this.select((s) => s.item);
readonly contributors$ = this.item$.pipe(
map((item) => item?.product?.contributors?.split(';').map((val) => val.trim())),
map((item) =>
item?.product?.contributors?.split(';').map((val) => val.trim()),
),
);
get showLoyaltyValue() {
return this.item?.loyalty?.value > 0;
}
@Input()
get orderType() {
return this.get((s) => s.orderType);
@@ -81,7 +97,9 @@ export class ShoppingCartItemComponent extends ComponentStore<ShoppingCartItemCo
this.patchState({ loadingOnItemChangeById });
}
}
readonly loadingOnItemChangeById$ = this.select((s) => s.loadingOnItemChangeById).pipe(shareReplay());
readonly loadingOnItemChangeById$ = this.select(
(s) => s.loadingOnItemChangeById,
).pipe(shareReplay());
@Input()
get loadingOnQuantityChangeById() {
@@ -92,7 +110,9 @@ export class ShoppingCartItemComponent extends ComponentStore<ShoppingCartItemCo
this.patchState({ loadingOnQuantityChangeById });
}
}
readonly loadingOnQuantityChangeById$ = this.select((s) => s.loadingOnQuantityChangeById).pipe(shareReplay());
readonly loadingOnQuantityChangeById$ = this.select(
(s) => s.loadingOnQuantityChangeById,
).pipe(shareReplay());
@Input()
quantityError: string;
@@ -106,7 +126,11 @@ export class ShoppingCartItemComponent extends ComponentStore<ShoppingCartItemCo
shareReplay(),
);
canEdit$ = combineLatest([this.isDummy$, this.hasOrderType$, this.item$]).pipe(
canEdit$ = combineLatest([
this.isDummy$,
this.hasOrderType$,
this.item$,
]).pipe(
map(([isDummy, hasOrderType, item]) => {
if (item.itemType === (66560 as ItemType)) {
return false;
@@ -116,17 +140,26 @@ export class ShoppingCartItemComponent extends ComponentStore<ShoppingCartItemCo
);
quantityRange$ = combineLatest([this.orderType$, this.item$]).pipe(
map(([orderType, item]) => (orderType === 'Rücklage' ? item.availability?.inStock : 999)),
map(([orderType, item]) =>
orderType === 'Rücklage' ? item.availability?.inStock : 999,
),
);
isDownloadAvailable$ = combineLatest([this.item$, this.orderType$]).pipe(
filter(([_, orderType]) => orderType === 'Download'),
filter(([, orderType]) => orderType === 'Download'),
switchMap(([item]) =>
this.availabilityService.getDownloadAvailability({
item: { ean: item.product.ean, price: item.availability.price, itemId: +item.product.catalogProductNumber },
item: {
ean: item.product.ean,
price: item.availability.price,
itemId: +item.product.catalogProductNumber,
},
}),
),
map((availability) => availability && this.availabilityService.isAvailable({ availability })),
map(
(availability) =>
availability && this.availabilityService.isAvailable({ availability }),
),
);
olaError$ = this.checkoutService
@@ -134,7 +167,9 @@ export class ShoppingCartItemComponent extends ComponentStore<ShoppingCartItemCo
.pipe(map((ids) => ids?.find((id) => id === this.item.id)));
get productSearchResultsPath() {
return this._productNavigationService.getArticleSearchResultsPath(this.application.activatedProcessId).path;
return this._productNavigationService.getArticleSearchResultsPath(
this.application.activatedProcessId,
).path;
}
get productSearchDetailsPath() {
@@ -152,7 +187,9 @@ export class ShoppingCartItemComponent extends ComponentStore<ShoppingCartItemCo
sscChanged$ = this.select((s) => s.sscChanged || s.sscTextChanged);
estimatedShippingDateChanged$ = this.select((s) => s.estimatedShippingDateChanged);
estimatedShippingDateChanged$ = this.select(
(s) => s.estimatedShippingDateChanged,
);
notAvailable$ = this.item$.pipe(
map((item) => {
@@ -188,13 +225,17 @@ export class ShoppingCartItemComponent extends ComponentStore<ShoppingCartItemCo
});
}
ngOnInit() {}
ngOnInit() {
// Component initialization
}
async onChangeItem() {
const isDummy = await this.isDummy$.pipe(first()).toPromise();
isDummy
? this.changeDummyItem.emit({ shoppingCartItem: this.item })
: this.changeItem.emit({ shoppingCartItem: this.item });
if (isDummy) {
this.changeDummyItem.emit({ shoppingCartItem: this.item });
} else {
this.changeItem.emit({ shoppingCartItem: this.item });
}
}
onChangeQuantity(quantity: number) {
@@ -225,7 +266,9 @@ export class ShoppingCartItemComponent extends ComponentStore<ShoppingCartItemCo
) {
this.estimatedShippingDateChanged();
}
} catch (error) {}
} catch {
// Error handling for availability refresh
}
this.patchRefreshingAvailability(false);
this._cdr.markForCheck();

View File

@@ -25,8 +25,9 @@ import {
combineLatest,
isObservable,
} from 'rxjs';
import { map, switchMap, tap } from 'rxjs/operators';
import { map, tap } from 'rxjs/operators';
import { TabService } from '@isa/core/tabs';
import { CheckoutMetadataService } from '@isa/checkout/data-access';
@Component({
selector: 'shell-process-bar-item',
@@ -39,9 +40,22 @@ export class ShellProcessBarItemComponent
implements OnInit, OnDestroy, OnChanges
{
#tabService = inject(TabService);
#checkoutMetadataService = inject(CheckoutMetadataService);
tab = computed(() => this.#tabService.entityMap()[this.process().id]);
tabEffect = effect(() => {
console.log('tabEffect', this.tab());
});
shoppingCartId = computed(() => {
return this.#checkoutMetadataService.getShoppingCartId(this.process().id);
});
shoppingCartIdEffect = effect(() => {
console.log('shoppingCartIdEffect', this.shoppingCartId());
});
private _process$ = new BehaviorSubject<ApplicationProcess>(undefined);
process$ = this._process$.asObservable();
@@ -91,7 +105,7 @@ export class ShellProcessBarItemComponent
latestBreadcrumb$: Observable<Breadcrumb> = NEVER;
routerLink$: Observable<string[] | any[]> = NEVER;
routerLink$: Observable<string[] | unknown[]> = NEVER;
queryParams$: Observable<object> = NEVER;

View File

@@ -1,6 +1,5 @@
import { z } from 'zod';
import {
EntityContainerSchema,
AvailabilityDTOSchema,
CampaignDTOSchema,
LoyaltyDTOSchema,
@@ -9,14 +8,14 @@ import {
PriceSchema,
EntityDTOContainerOfDestinationDTOSchema,
ItemTypeSchema,
PriceValueSchema,
} from './base-schemas';
const AddToShoppingCartDTOSchema = z.object({
const AddToShoppingCartDefaultSchema = z.object({
availability: AvailabilityDTOSchema,
campaign: CampaignDTOSchema,
destination: EntityDTOContainerOfDestinationDTOSchema,
itemType: ItemTypeSchema,
loyalty: LoyaltyDTOSchema,
product: ProductDTOSchema,
promotion: PromotionDTOSchema,
quantity: z.number().int().positive(),
@@ -24,9 +23,34 @@ const AddToShoppingCartDTOSchema = z.object({
shopItemId: z.number().int().positive().optional(),
});
// When loyalty points are used the price value must be 0 and promotion is not allowed
// and availability must not contain a price
// and loyalty must be present
const AddToShoppingCartWithRedemptionPointsSchema =
AddToShoppingCartDefaultSchema.omit({
availability: true,
promotion: true,
}).extend({
availability: AvailabilityDTOSchema.unwrap()
.omit({ price: true })
.extend({
price: PriceSchema.unwrap()
.omit({ value: true })
.extend({
value: PriceValueSchema.unwrap().extend({ value: z.literal(0) }),
}),
}),
loyalty: LoyaltyDTOSchema,
});
const AddToShoppingCartSchema = z.union([
AddToShoppingCartDefaultSchema,
AddToShoppingCartWithRedemptionPointsSchema,
]);
export const AddItemToShoppingCartParamsSchema = z.object({
shoppingCartId: z.number().int().positive(),
items: z.array(AddToShoppingCartDTOSchema).min(1),
items: z.array(AddToShoppingCartSchema).min(1),
});
export type AddItemToShoppingCartParams = z.infer<

View File

@@ -1,6 +1,5 @@
import { z } from 'zod';
import {
EntityContainerSchema,
AvailabilityDTOSchema,
CampaignDTOSchema,
LoyaltyDTOSchema,
@@ -10,7 +9,7 @@ import {
} from './base-schemas';
import { UpdateShoppingCartItem } from '../models';
const UpdateShoppingCartItemParamsValueSchema = z.object({
const UpdateShoppingCartItemParamsValueDefaultSchema = z.object({
availability: AvailabilityDTOSchema,
buyerComment: z.string().optional(),
campaign: CampaignDTOSchema,
@@ -22,6 +21,34 @@ const UpdateShoppingCartItemParamsValueSchema = z.object({
specialComment: z.string().optional(),
});
// When loyalty points are used the price value must be 0 and promotion is not allowed
// and availability must not contain a price
// and loyalty must be present
const UpdateShoppingCartItemParamsValueWithRedemptionPointsSchema =
UpdateShoppingCartItemParamsValueDefaultSchema.omit({
availability: true,
promotion: true,
}).extend({
availability: AvailabilityDTOSchema.unwrap()
.omit({ price: true })
.extend({
price: PriceSchema.unwrap()
.omit({ value: true })
.extend({
value: z.object({
value: z.literal(0),
currency: z.string(),
}),
}),
}),
loyalty: LoyaltyDTOSchema,
});
const UpdateShoppingCartItemParamsValueSchema = z.union([
UpdateShoppingCartItemParamsValueDefaultSchema,
UpdateShoppingCartItemParamsValueWithRedemptionPointsSchema,
]);
export const UpdateShoppingCartItemParamsSchema = z.object({
shoppingCartId: z.number().int().positive(),
shoppingCartItemId: z.number().int().positive(),

View File

@@ -28,7 +28,7 @@ export class CheckoutMetadataService {
setShoppingCartId(tabId: number, shoppingCartId: number | undefined) {
this.#tabService.patchTabMetadata(tabId, {
CHECKOUT_SHOPPING_CART_ID_METADATA_KEY: shoppingCartId,
[CHECKOUT_SHOPPING_CART_ID_METADATA_KEY]: shoppingCartId,
});
}

View File

@@ -80,8 +80,9 @@ export class UserStorageProvider implements StorageProvider {
@ValidateParam(0, z.string().min(1))
get(key: string): unknown {
console.log('Getting user state key:', key);
return structuredClone(this.#state[key]);
const data = structuredClone(this.#state[key]);
console.log('Got user state data:', key, data);
return data;
}
@ValidateParam(0, z.string().min(1))

18747
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -123,16 +123,8 @@
"vite": "6.3.5",
"vitest": "^3.1.1"
},
"optionalDependencies": {
"@esbuild/linux-x64": "^0.25.5"
},
"engines": {
"node": ">=22.00.0",
"npm": ">=10.0.0"
},
"overrides": {
"jest-environment-jsdom": {
"jsdom": "26.0.0"
}
}
}