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

View File

@@ -1,205 +1,206 @@
import { Injectable } from "@angular/core"; import { Injectable } from '@angular/core';
import { import {
ActivatedRouteSnapshot, ActivatedRouteSnapshot,
Router, Router,
RouterStateSnapshot, RouterStateSnapshot,
} from "@angular/router"; } from '@angular/router';
import { ApplicationProcess, ApplicationService } from "@core/application"; import { ApplicationProcess, ApplicationService } from '@core/application';
import { DomainCheckoutService } from "@domain/checkout"; import { DomainCheckoutService } from '@domain/checkout';
import { logger } from "@isa/core/logging"; import { logger } from '@isa/core/logging';
import { CustomerSearchNavigation } from "@shared/services/navigation"; import { CustomerSearchNavigation } from '@shared/services/navigation';
import { first } from "rxjs/operators"; import { first } from 'rxjs/operators';
@Injectable({ providedIn: "root" }) @Injectable({ providedIn: 'root' })
export class CanActivateCustomerGuard { export class CanActivateCustomerGuard {
#logger = logger(() => ({ #logger = logger(() => ({
context: "CanActivateCustomerGuard", module: 'isa-app',
tags: ["guard", "customer", "navigation"], importMetaUrl: import.meta.url,
})); class: 'CanActivateCustomerGuard',
}));
constructor(
private readonly _applicationService: ApplicationService, constructor(
private readonly _checkoutService: DomainCheckoutService, private readonly _applicationService: ApplicationService,
private readonly _router: Router, private readonly _checkoutService: DomainCheckoutService,
private readonly _navigation: CustomerSearchNavigation, private readonly _router: Router,
) {} private readonly _navigation: CustomerSearchNavigation,
) {}
async canActivate(
route: ActivatedRouteSnapshot, async canActivate(
{ url }: RouterStateSnapshot, route: ActivatedRouteSnapshot,
) { { url }: RouterStateSnapshot,
if (url.startsWith("/kunde/customer/search/")) { ) {
const processId = Date.now(); // Generate a new process ID if (url.startsWith('/kunde/customer/search/')) {
// Extract parts before and after the pattern const processId = Date.now(); // Generate a new process ID
const parts = url.split("/kunde/customer/"); // Extract parts before and after the pattern
if (parts.length === 2) { const parts = url.split('/kunde/customer/');
const prefix = parts[0] + "/kunde/"; if (parts.length === 2) {
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}`; // Construct the new URL with process ID inserted
const newUrl = `${prefix}${processId}/${suffix}`;
this.#logger.info("Redirecting to URL with process ID", () => ({
originalUrl: url, this.#logger.info('Redirecting to URL with process ID', () => ({
newUrl, originalUrl: url,
processId, newUrl,
})); processId,
}));
// Navigate to the new URL and prevent original navigation
this._router.navigateByUrl(newUrl); // Navigate to the new URL and prevent original navigation
return false; this._router.navigateByUrl(newUrl);
} return false;
} }
}
const processes = await this._applicationService
.getProcesses$("customer") const processes = await this._applicationService
.pipe(first()) .getProcesses$('customer')
.toPromise(); .pipe(first())
const lastActivatedProcessId = ( .toPromise();
await this._applicationService const lastActivatedProcessId = (
.getLastActivatedProcessWithSectionAndType$("customer", "cart") await this._applicationService
.pipe(first()) .getLastActivatedProcessWithSectionAndType$('customer', 'cart')
.toPromise() .pipe(first())
)?.id; .toPromise()
)?.id;
const lastActivatedCartCheckoutProcessId = (
await this._applicationService const lastActivatedCartCheckoutProcessId = (
.getLastActivatedProcessWithSectionAndType$("customer", "cart-checkout") await this._applicationService
.pipe(first()) .getLastActivatedProcessWithSectionAndType$('customer', 'cart-checkout')
.toPromise() .pipe(first())
)?.id; .toPromise()
)?.id;
const lastActivatedGoodsOutProcessId = (
await this._applicationService const lastActivatedGoodsOutProcessId = (
.getLastActivatedProcessWithSectionAndType$("customer", "goods-out") await this._applicationService
.pipe(first()) .getLastActivatedProcessWithSectionAndType$('customer', 'goods-out')
.toPromise() .pipe(first())
)?.id; .toPromise()
)?.id;
const activatedProcessId = await this._applicationService
.getActivatedProcessId$() const activatedProcessId = await this._applicationService
.pipe(first()) .getActivatedProcessId$()
.toPromise(); .pipe(first())
.toPromise();
// Darf nur reinkommen wenn der aktuell aktive Tab ein Bestellabschluss Tab ist
if ( // Darf nur reinkommen wenn der aktuell aktive Tab ein Bestellabschluss Tab ist
!!lastActivatedCartCheckoutProcessId && if (
lastActivatedCartCheckoutProcessId === activatedProcessId !!lastActivatedCartCheckoutProcessId &&
) { lastActivatedCartCheckoutProcessId === activatedProcessId
await this.fromCartCheckoutProcess( ) {
processes, await this.fromCartCheckoutProcess(
lastActivatedCartCheckoutProcessId, processes,
); lastActivatedCartCheckoutProcessId,
return false; );
} else if ( return false;
!!lastActivatedGoodsOutProcessId && } else if (
lastActivatedGoodsOutProcessId === activatedProcessId !!lastActivatedGoodsOutProcessId &&
) { lastActivatedGoodsOutProcessId === activatedProcessId
await this.fromGoodsOutProcess(processes, lastActivatedGoodsOutProcessId); ) {
return false; await this.fromGoodsOutProcess(processes, lastActivatedGoodsOutProcessId);
} return false;
}
if (!lastActivatedProcessId) {
await this.fromCartProcess(processes); if (!lastActivatedProcessId) {
return false; await this.fromCartProcess(processes);
} else { return false;
await this.navigateToDefaultRoute(lastActivatedProcessId); } else {
} await this.navigateToDefaultRoute(lastActivatedProcessId);
return false; }
} return false;
}
async navigateToDefaultRoute(processId: number) {
const route = this._navigation.defaultRoute({ processId }); async navigateToDefaultRoute(processId: number) {
const route = this._navigation.defaultRoute({ processId });
await this._router.navigate(route.path, { queryParams: route.queryParams });
} await this._router.navigate(route.path, { queryParams: route.queryParams });
}
// Bei offener Artikelsuche/Kundensuche und Klick auf Footer Kundensuche
async fromCartProcess(processes: ApplicationProcess[]) { // Bei offener Artikelsuche/Kundensuche und Klick auf Footer Kundensuche
const newProcessId = Date.now(); async fromCartProcess(processes: ApplicationProcess[]) {
await this._applicationService.createProcess({ const newProcessId = Date.now();
id: newProcessId, await this._applicationService.createProcess({
type: "cart", id: newProcessId,
section: "customer", type: 'cart',
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === "cart"))}`, section: 'customer',
}); name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`,
});
await this.navigateToDefaultRoute(newProcessId);
} await this.navigateToDefaultRoute(newProcessId);
}
// Bei offener Bestellbestätigung und Klick auf Footer Kundensuche
async fromCartCheckoutProcess( // Bei offener Bestellbestätigung und Klick auf Footer Kundensuche
processes: ApplicationProcess[], async fromCartCheckoutProcess(
processId: number, processes: ApplicationProcess[],
) { processId: number,
// Um alle Checkout Daten zu resetten die mit dem Prozess assoziiert sind ) {
this._checkoutService.removeProcess({ processId }); // Um alle Checkout Daten zu resetten die mit dem Prozess assoziiert sind
this._checkoutService.removeProcess({ processId });
// Ändere type cart-checkout zu cart
this._applicationService.patchProcess(processId, { // Ändere type cart-checkout zu cart
id: processId, this._applicationService.patchProcess(processId, {
type: "cart", id: processId,
section: "customer", type: 'cart',
name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === "cart"))}`, section: 'customer',
data: {}, name: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`,
}); data: {},
});
// Navigation
await this.navigateToDefaultRoute(processId); // Navigation
} await this.navigateToDefaultRoute(processId);
}
// Bei offener Warenausgabe und Klick auf Footer Kundensuche
async fromGoodsOutProcess( // Bei offener Warenausgabe und Klick auf Footer Kundensuche
processes: ApplicationProcess[], async fromGoodsOutProcess(
processId: number, processes: ApplicationProcess[],
) { processId: number,
const buyer = await this._checkoutService ) {
.getBuyer({ processId }) const buyer = await this._checkoutService
.pipe(first()) .getBuyer({ processId })
.toPromise(); .pipe(first())
const customerFeatures = await this._checkoutService .toPromise();
.getCustomerFeatures({ processId }) const customerFeatures = await this._checkoutService
.pipe(first()) .getCustomerFeatures({ processId })
.toPromise(); .pipe(first())
const name = buyer .toPromise();
? customerFeatures?.b2b const name = buyer
? buyer.organisation?.name ? customerFeatures?.b2b
? buyer.organisation?.name ? buyer.organisation?.name
: buyer.lastName ? buyer.organisation?.name
: buyer.lastName : buyer.lastName
: `Vorgang ${this.processNumber(processes.filter((process) => process.type === "cart"))}`; : buyer.lastName
: `Vorgang ${this.processNumber(processes.filter((process) => process.type === 'cart'))}`;
// Ändere type goods-out zu cart
this._applicationService.patchProcess(processId, { // Ändere type goods-out zu cart
id: processId, this._applicationService.patchProcess(processId, {
type: "cart", id: processId,
section: "customer", type: 'cart',
name, section: 'customer',
}); name,
});
// Navigation
await this.navigateToDefaultRoute(processId); // Navigation
} await this.navigateToDefaultRoute(processId);
}
processNumber(processes: ApplicationProcess[]) {
const processNumbers = processes?.map((process) => processNumber(processes: ApplicationProcess[]) {
Number(process?.name?.replace(/\D/g, "")), const processNumbers = processes?.map((process) =>
); Number(process?.name?.replace(/\D/g, '')),
return !!processNumbers && processNumbers.length > 0 );
? this.findMissingNumber(processNumbers) return !!processNumbers && processNumbers.length > 0
: 1; ? this.findMissingNumber(processNumbers)
} : 1;
}
findMissingNumber(processNumbers: number[]) {
for ( findMissingNumber(processNumbers: number[]) {
let missingNumber = 1; for (
missingNumber < Math.max(...processNumbers); let missingNumber = 1;
missingNumber++ missingNumber < Math.max(...processNumbers);
) { missingNumber++
if (!processNumbers.find((number) => number === missingNumber)) { ) {
return missingNumber; if (!processNumbers.find((number) => number === missingNumber)) {
} return missingNumber;
} }
return Math.max(...processNumbers) + 1; }
} return Math.max(...processNumbers) + 1;
} }
}

View File

@@ -1,185 +1,268 @@
<div class="flex flex-row"> <div class="flex flex-row">
<div class="shared-purchase-options-list-item__thumbnail w-16 max-h-28"> <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
</div> class="rounded shadow-card max-w-full max-h-full"
<div class="shared-purchase-options-list-item__product grow ml-4"> [src]="product?.ean | productImage"
<div class="shared-purchase-options-list-item__contributors font-bold"> [alt]="product?.name"
{{ product?.contributors }} />
</div> </div>
<div class="shared-purchase-options-list-item__name font-bold h-12" sharedScaleContent> <div class="shared-purchase-options-list-item__product grow ml-4">
{{ product?.name }} <div class="shared-purchase-options-list-item__contributors font-bold">
</div> {{ product?.contributors }}
<div class="shared-purchase-options-list-item__format flex flex-row items-center"> </div>
<shared-icon [icon]="product?.format"></shared-icon> <div
<span class="ml-2 font-bold">{{ product?.formatDetail }}</span> class="shared-purchase-options-list-item__name font-bold h-12"
</div> sharedScaleContent
<div class="shared-purchase-options-list-item__manufacturer-and-ean"> >
{{ product?.manufacturer }} {{ product?.name }}
@if (product?.manufacturer && product?.ean) { </div>
<span>|</span> <div
} class="shared-purchase-options-list-item__format flex flex-row items-center"
{{ product?.ean }} >
</div> <shared-icon [icon]="product?.format"></shared-icon>
<div class="shared-purchase-options-list-item__volume-and-publication-date"> <span class="ml-2 font-bold">{{ product?.formatDetail }}</span>
{{ product?.volume }} </div>
@if (product?.volume && product?.publicationDate) { <div class="shared-purchase-options-list-item__manufacturer-and-ean">
<span>|</span> {{ product?.manufacturer }}
} @if (product?.manufacturer && product?.ean) {
{{ product?.publicationDate | date: 'dd. MMMM yyyy' }} <span>|</span>
</div> }
<div class="shared-purchase-options-list-item__availabilities mt-3 grid grid-flow-row gap-2 justify-start"> {{ product?.ean }}
@if ((availabilities$ | async)?.length) { </div>
<div class="whitespace-nowrap self-center">Verfügbar als</div> <div class="shared-purchase-options-list-item__volume-and-publication-date">
} {{ product?.volume }}
@for (availability of availabilities$ | async; track availability) { @if (product?.volume && product?.publicationDate) {
<div class="grid grid-flow-col gap-4 justify-start"> <span>|</span>
<div }
class="shared-purchase-options-list-item__availability grid grid-flow-col gap-2 items-center" {{ product?.publicationDate | date: 'dd. MMMM yyyy' }}
[attr.data-option]="availability.purchaseOption" </div>
> <div
@switch (availability.purchaseOption) { class="shared-purchase-options-list-item__availabilities mt-3 grid grid-flow-row gap-2 justify-start"
@case ('delivery') { >
<shared-icon icon="isa-truck" [size]="22"></shared-icon> @if ((availabilities$ | async)?.length) {
{{ availability.data.estimatedDelivery?.start | date: 'EE dd.MM.' }} <div class="whitespace-nowrap self-center">Verfügbar als</div>
- }
{{ availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.' }} @for (availability of availabilities$ | async; track availability) {
} <div class="grid grid-flow-col gap-4 justify-start">
@case ('dig-delivery') { <div
<shared-icon icon="isa-truck" [size]="22"></shared-icon> class="shared-purchase-options-list-item__availability grid grid-flow-col gap-2 items-center"
{{ availability.data.estimatedDelivery?.start | date: 'EE dd.MM.' }} [attr.data-option]="availability.purchaseOption"
- >
{{ availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.' }} @switch (availability.purchaseOption) {
} @case ('delivery') {
@case ('b2b-delivery') { <shared-icon icon="isa-truck" [size]="22"></shared-icon>
<shared-icon icon="isa-b2b-truck" [size]="24"></shared-icon> {{
{{ availability.data.estimatedShippingDate | date: 'dd. MMMM yyyy' }} availability.data.estimatedDelivery?.start | date: 'EE dd.MM.'
} }}
@case ('pickup') { -
<shared-icon {{
class="cursor-pointer" availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.'
#uiOverlayTrigger="uiOverlayTrigger" }}
[uiOverlayTrigger]="orderDeadlineTooltip" }
[class.tooltip-active]="uiOverlayTrigger.opened" @case ('dig-delivery') {
icon="isa-box-out" <shared-icon icon="isa-truck" [size]="22"></shared-icon>
[size]="18" {{
></shared-icon> availability.data.estimatedDelivery?.start | date: 'EE dd.MM.'
{{ availability.data.estimatedShippingDate | date: 'dd. MMMM yyyy' }} }}
<ui-tooltip -
#orderDeadlineTooltip {{
yPosition="above" availability.data.estimatedDelivery?.stop | date: 'EE dd.MM.'
xPosition="after" }}
[yOffset]="-12" }
[xOffset]="4" @case ('b2b-delivery') {
[warning]="true" <shared-icon icon="isa-b2b-truck" [size]="24"></shared-icon>
[closeable]="true" {{
> availability.data.estimatedShippingDate
<b>{{ availability.data?.orderDeadline | orderDeadline }}</b> | date: 'dd. MMMM yyyy'
</ui-tooltip> }}
} }
@case ('in-store') { @case ('pickup') {
<shared-icon icon="isa-shopping-bag" [size]="18"></shared-icon> <shared-icon
{{ availability.data.inStock }}x class="cursor-pointer"
@if (isEVT) { #uiOverlayTrigger="uiOverlayTrigger"
ab {{ isEVT | date: 'dd. MMMM yyyy' }} [uiOverlayTrigger]="orderDeadlineTooltip"
} @else { [class.tooltip-active]="uiOverlayTrigger.opened"
ab sofort icon="isa-box-out"
} [size]="18"
} ></shared-icon>
@case ('download') { {{
<shared-icon icon="isa-download" [size]="22"></shared-icon> availability.data.estimatedShippingDate
Download | date: 'dd. MMMM yyyy'
} }}
} <ui-tooltip
</div> #orderDeadlineTooltip
</div> yPosition="above"
} xPosition="after"
</div> [yOffset]="-12"
</div> [xOffset]="4"
<div class="shared-purchase-options-list-item__price text-right ml-4 flex flex-col items-end"> [warning]="true"
<div class="shared-purchase-options-list-item__price-value font-bold text-xl flex flex-row items-center"> [closeable]="true"
<div class="relative flex flex-row justify-end items-start"> >
@if (canEditVat$ | async) { <b>{{ availability.data?.orderDeadline | orderDeadline }}</b>
<ui-select </ui-tooltip>
class="w-[6.5rem] min-h-[3.4375rem] p-4 rounded-card border border-solid border-[#AEB7C1] mr-4" }
tabindex="-1" @case ('in-store') {
[formControl]="manualVatFormControl" <shared-icon icon="isa-shopping-bag" [size]="18"></shared-icon>
[defaultLabel]="'MwSt'" {{ availability.data.inStock }}x
> @if (isEVT) {
@for (vat of vats$ | async; track vat) { ab {{ isEVT | date: 'dd. MMMM yyyy' }}
<ui-select-option [label]="vat.name + '%'" [value]="vat.vatType"></ui-select-option> } @else {
} ab sofort
</ui-select> }
} }
@if (canEditPrice$ | async) { @case ('download') {
<shared-input-control <shared-icon icon="isa-download" [size]="22"></shared-icon>
[class.ml-6]="priceFormControl?.invalid && priceFormControl?.dirty" Download
> }
<shared-input-control-indicator> }
@if (priceFormControl?.invalid && priceFormControl?.dirty) { </div>
<shared-icon icon="mat-info"></shared-icon> </div>
} }
</shared-input-control-indicator> </div>
<input </div>
[uiOverlayTrigger]="giftCardTooltip" <div
triggerOn="none" class="shared-purchase-options-list-item__price text-right ml-4 flex flex-col items-end"
#quantityInput >
#priceOverlayTrigger="uiOverlayTrigger" <div
sharedInputControlInput class="shared-purchase-options-list-item__price-value font-bold text-xl flex flex-row items-center"
type="string" >
class="w-24" <div class="relative flex flex-row justify-end items-start">
[formControl]="priceFormControl" @if (showRedemptionPoints()) {
placeholder="00,00" <span class="isa-text-body-2-regular text-isa-neutral-600"
(sharedOnInit)="onPriceInputInit(quantityInput, priceOverlayTrigger)" >Einlösen für:</span
sharedNumberValue >
/> <span class="ml-2 isa-text-body-2-bold text-isa-secondary-900"
<shared-input-control-suffix>EUR</shared-input-control-suffix> >{{
<shared-input-control-error error="required">Preis ist ungültig</shared-input-control-error> redemptionPoints() * quantityFormControl.value
<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> Lesepunkte</span
<shared-input-control-error error="max">Preis ist ungültig</shared-input-control-error> >
</shared-input-control> } @else {
} @else { @if (canEditVat$ | async) {
{{ priceValue$ | async | currency: 'EUR' : 'code' }} <ui-select
} class="w-[6.5rem] min-h-[3.4375rem] p-4 rounded-card border border-solid border-[#AEB7C1] mr-4"
tabindex="-1"
<ui-tooltip [warning]="true" xPosition="after" yPosition="below" [xOffset]="-55" [yOffset]="18" [closeable]="true" #giftCardTooltip> [formControl]="manualVatFormControl"
Tragen Sie hier den [defaultLabel]="'MwSt'"
<br /> >
Gutscheinbetrag ein. @for (vat of vats$ | async; track vat) {
</ui-tooltip> <ui-select-option
</div> [label]="vat.name + '%'"
</div> [value]="vat.vatType"
<ui-quantity-dropdown class="mt-2" [formControl]="quantityFormControl" [range]="maxSelectableQuantity$ | async"></ui-quantity-dropdown> ></ui-select-option>
<div class="pt-7"> }
@if ((canAddResult$ | async)?.canAdd) { </ui-select>
<input }
class="fancy-checkbox" @if (canEditPrice$ | async) {
[class.checked]="selectedFormControl?.value" <shared-input-control
[formControl]="selectedFormControl" [class.ml-6]="
type="checkbox" priceFormControl?.invalid && priceFormControl?.dirty
/> "
} >
</div> <shared-input-control-indicator>
@if (priceFormControl?.invalid && priceFormControl?.dirty) {
@if (canAddResult$ | async; as canAddResult) { <shared-icon icon="mat-info"></shared-icon>
@if (!canAddResult.canAdd) { }
<span class="inline-block font-bold text-[#BE8100] mt-[14px] max-w-[19rem]"> </shared-input-control-indicator>
{{ canAddResult.message }} <input
</span> [uiOverlayTrigger]="giftCardTooltip"
} triggerOn="none"
} #quantityInput
#priceOverlayTrigger="uiOverlayTrigger"
@if (showMaxAvailableQuantity$ | async) { sharedInputControlInput
<span class="font-bold text-[#BE8100] mt-[14px]"> type="string"
{{ (availability$ | async)?.inStock }} Exemplare sofort lieferbar class="w-24"
</span> [formControl]="priceFormControl"
} placeholder="00,00"
@if (showNotAvailable$ | async) { (sharedOnInit)="
<span class="font-bold text-[#BE8100] mt-[14px]">Derzeit nicht bestellbar</span> onPriceInputInit(quantityInput, priceOverlayTrigger)
} "
</div> sharedNumberValue
</div> />
<div class="flex flex-row"> <shared-input-control-suffix>EUR</shared-input-control-suffix>
<div class="w-16"></div> <shared-input-control-error error="required"
<div class="grow shared-purchase-options-list-item__availabilities"></div> >Preis ist ungültig</shared-input-control-error
</div> >
<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"
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>
<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,349 +1,467 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, Input, OnInit, OnDestroy, OnChanges, SimpleChanges } from '@angular/core'; import {
import { FormControl, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; Component,
import { ProductImageModule } from '@cdn/product-image'; ChangeDetectionStrategy,
import { InputControlModule } from '@shared/components/input-control'; OnInit,
import { ElementLifecycleModule } from '@shared/directives/element-lifecycle'; OnDestroy,
import { UiCommonModule, UiOverlayTriggerDirective } from '@ui/common'; OnChanges,
import { UiQuantityDropdownModule } from '@ui/quantity-dropdown'; SimpleChanges,
import { UiSpinnerModule } from '@ui/spinner'; computed,
import { UiTooltipModule } from '@ui/tooltip'; input,
import { combineLatest, ReplaySubject, Subscription } from 'rxjs'; } from '@angular/core';
import { IconComponent } from '@shared/components/icon'; import {
import { map, take, shareReplay, startWith, switchMap, withLatestFrom } from 'rxjs/operators'; FormControl,
import { GIFT_CARD_MAX_PRICE, PRICE_PATTERN } from '../constants'; FormsModule,
import { Item, PurchaseOptionsStore, isItemDTO, isShoppingCartItemDTO } from '../store'; ReactiveFormsModule,
import { OrderDeadlinePipeModule } from '@shared/pipes/order-deadline'; Validators,
import { UiSelectModule } from '@ui/select'; } from '@angular/forms';
import { KeyValueDTOOfStringAndString } from '@generated/swagger/cat-search-api'; import { ProductImageModule } from '@cdn/product-image';
import { ScaleContentComponent } from '@shared/components/scale-content'; import { InputControlModule } from '@shared/components/input-control';
import moment from 'moment'; import { ElementLifecycleModule } from '@shared/directives/element-lifecycle';
import { UiCommonModule, UiOverlayTriggerDirective } from '@ui/common';
@Component({ import { UiQuantityDropdownModule } from '@ui/quantity-dropdown';
selector: 'shared-purchase-options-list-item', import { UiSpinnerModule } from '@ui/spinner';
templateUrl: 'purchase-options-list-item.component.html', import { UiTooltipModule } from '@ui/tooltip';
styleUrls: ['purchase-options-list-item.component.css'], import { combineLatest, ReplaySubject, Subscription } from 'rxjs';
changeDetection: ChangeDetectionStrategy.OnPush, import { IconComponent } from '@shared/components/icon';
imports: [ import {
CommonModule, map,
UiQuantityDropdownModule, take,
UiSelectModule, shareReplay,
ProductImageModule, startWith,
IconComponent, switchMap,
UiSpinnerModule, withLatestFrom,
ReactiveFormsModule, } from 'rxjs/operators';
InputControlModule, import { GIFT_CARD_MAX_PRICE, PRICE_PATTERN } from '../constants';
FormsModule, import {
ElementLifecycleModule, Item,
UiTooltipModule, PurchaseOptionsStore,
UiCommonModule, isItemDTO,
ScaleContentComponent, isShoppingCartItemDTO,
OrderDeadlinePipeModule, } from '../store';
], import { OrderDeadlinePipeModule } from '@shared/pipes/order-deadline';
host: { class: 'shared-purchase-options-list-item' }, import { UiSelectModule } from '@ui/select';
}) import { KeyValueDTOOfStringAndString } from '@generated/swagger/cat-search-api';
export class PurchaseOptionsListItemComponent implements OnInit, OnDestroy, OnChanges { import { ScaleContentComponent } from '@shared/components/scale-content';
private _subscriptions = new Subscription(); import moment from 'moment';
import { toSignal } from '@angular/core/rxjs-interop';
private _itemSubject = new ReplaySubject<Item>(1); import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaOtherInfo } from '@isa/icons';
@Input() item: Item;
@Component({
get item$() { selector: 'shared-purchase-options-list-item',
return this._itemSubject.asObservable(); templateUrl: 'purchase-options-list-item.component.html',
} styleUrls: ['purchase-options-list-item.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
get product() { imports: [
return this.item.product; CommonModule,
} UiQuantityDropdownModule,
UiSelectModule,
quantityFormControl = new FormControl<number>(null); ProductImageModule,
IconComponent,
private readonly _giftCardValidators = [ UiSpinnerModule,
Validators.required, ReactiveFormsModule,
Validators.min(1), InputControlModule,
Validators.max(GIFT_CARD_MAX_PRICE), FormsModule,
Validators.pattern(PRICE_PATTERN), ElementLifecycleModule,
]; UiTooltipModule,
UiCommonModule,
private readonly _defaultValidators = [ ScaleContentComponent,
Validators.required, OrderDeadlinePipeModule,
Validators.min(0.01), NgIcon,
Validators.max(999.99), ],
Validators.pattern(PRICE_PATTERN), host: { class: 'shared-purchase-options-list-item' },
]; providers: [provideIcons({ isaOtherInfo })],
})
priceFormControl = new FormControl<string>(null); export class PurchaseOptionsListItemComponent
implements OnInit, OnDestroy, OnChanges
manualVatFormControl = new FormControl<string>('', [Validators.required]); {
private _subscriptions = new Subscription();
selectedFormControl = new FormControl<boolean>(false);
private _itemSubject = new ReplaySubject<Item>(1);
availabilities$ = this.item$.pipe(switchMap((item) => this._store.getAvailabilitiesForItem$(item.id)));
item = input.required<Item>();
availability$ = combineLatest([this.item$, this._store.purchaseOption$]).pipe(
switchMap(([item, purchaseOption]) => this._store.getAvailabilityWithPurchaseOption$(item.id, purchaseOption)), get item$() {
map((availability) => availability?.data), return this._itemSubject.asObservable();
); }
price$ = this.item$.pipe(switchMap((item) => this._store.getPrice$(item.id))); get product() {
return this.item().product;
priceValue$ = this.price$.pipe(map((price) => price?.value?.value)); }
// Ticket #4074 analog zu Ticket #2244 redemptionPoints = computed(() => {
// take(2) um die Response des Katalogpreises und danach um die Response der OLAs abzuwarten const item = this.item();
// Logik gilt ausschließlich für Archivartikel if (isShoppingCartItemDTO(item, this._store.type)) {
setManualPrice$ = this.price$.pipe( return item.loyalty?.value;
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 return item.redemptionPoints;
const features = this.item?.features as KeyValueDTOOfStringAndString[]; });
if (!!features && Array.isArray(features)) {
const isArchive = !!features?.find((feature) => feature?.enabled === true && feature?.key === 'ARC'); showRedemptionPoints = toSignal(this._store.useRedemptionPoints$);
return isArchive ? !price?.value?.value || price?.vat === undefined : false;
} quantityFormControl = new FormControl<number>(null);
return false;
}), private readonly _giftCardValidators = [
); Validators.required,
Validators.min(1),
vats$ = this._store.vats$.pipe(shareReplay()); Validators.max(GIFT_CARD_MAX_PRICE),
Validators.pattern(PRICE_PATTERN),
priceVat$ = this.price$.pipe(map((price) => price?.vat?.vatType)); ];
canAddResult$ = this.item$.pipe( private readonly _defaultValidators = [
switchMap((item) => this._store.getCanAddResultForItemAndCurrentPurchaseOption$(item.id)), Validators.required,
); Validators.min(0.01),
Validators.max(999.99),
canEditPrice$ = this.item$.pipe( Validators.pattern(PRICE_PATTERN),
switchMap((item) => combineLatest([this.canAddResult$, this._store.getCanEditPrice$(item.id)])), ];
map(([canAddResult, canEditPrice]) => canAddResult?.canAdd && canEditPrice),
); priceFormControl = new FormControl<string>(null);
canEditVat$ = this.item$.pipe( manualVatFormControl = new FormControl<string>('', [Validators.required]);
switchMap((item) => combineLatest([this.canAddResult$, this._store.getCanEditVat$(item.id)])),
map(([canAddResult, canEditVat]) => canAddResult?.canAdd && canEditVat), selectedFormControl = new FormControl<boolean>(false);
);
availabilities$ = this.item$.pipe(
isGiftCard$ = this.item$.pipe(switchMap((item) => this._store.getIsGiftCard$(item.id))); switchMap((item) => this._store.getAvailabilitiesForItem$(item.id)),
);
maxSelectableQuantity$ = combineLatest([this._store.purchaseOption$, this.availability$]).pipe(
map(([purchaseOption, availability]) => { availability$ = combineLatest([this.item$, this._store.purchaseOption$]).pipe(
if (purchaseOption === 'in-store') { switchMap(([item, purchaseOption]) =>
return availability?.inStock; this._store.getAvailabilityWithPurchaseOption$(item.id, purchaseOption),
} ),
map((availability) => availability?.data),
return 999; );
}),
startWith(999), availability = toSignal(this.availability$);
);
price$ = this.item$.pipe(switchMap((item) => this._store.getPrice$(item.id)));
showMaxAvailableQuantity$ = combineLatest([this._store.purchaseOption$, this.availability$, this.item$]).pipe(
map(([purchaseOption, availability, item]) => { priceValue$ = this.price$.pipe(map((price) => price?.value?.value));
if (purchaseOption === 'pickup' && availability?.inStock < item.quantity) {
return true; // Ticket #4074 analog zu Ticket #2244
} // take(2) um die Response des Katalogpreises und danach um die Response der OLAs abzuwarten
// Logik gilt ausschließlich für Archivartikel
return false; setManualPrice$ = this.price$.pipe(
}), 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
fetchingAvailabilities$ = this.item$ const features = this.item().features as KeyValueDTOOfStringAndString[];
.pipe(switchMap((item) => this._store.getFetchingAvailabilitiesForItem$(item.id))) if (!!features && Array.isArray(features)) {
.pipe(map((fetchingAvailabilities) => fetchingAvailabilities.length > 0)); const isArchive = !!features?.find(
(feature) => feature?.enabled === true && feature?.key === 'ARC',
showNotAvailable$ = combineLatest([this.availabilities$, this.fetchingAvailabilities$]).pipe( );
map(([availabilities, fetchingAvailabilities]) => { return isArchive
if (fetchingAvailabilities) { ? !price?.value?.value || price?.vat === undefined
return false; : false;
} }
return false;
if (availabilities.length === 0) { }),
return true; );
}
vats$ = this._store.vats$.pipe(shareReplay());
return false;
}), priceVat$ = this.price$.pipe(map((price) => price?.vat?.vatType));
);
canAddResult$ = this.item$.pipe(
// Ticket #4813 Artikeldetailseite // EVT-Datum bei der Kaufoption Rücklage anzeigen switchMap((item) =>
get isEVT() { this._store.getCanAddResultForItemAndCurrentPurchaseOption$(item.id),
// Einstieg über Kaufoptionen - Hier wird die Katalogavailability verwendet die am ItemDTO hängt ),
if (isItemDTO(this.item, this._store.type)) { );
const firstDayOfSale = this.item?.catalogAvailability?.firstDayOfSale;
return this.firstDayOfSaleBiggerThanToday(firstDayOfSale); canEditPrice$ = this.item$.pipe(
} switchMap((item) =>
combineLatest([
// Einstieg über Warenkorb - Da wir hier ein ShoppingCartItemDTO haben werden hier die Katalogdaten neu gefetched und eine Katalogavailability drangehängt this.canAddResult$,
if (isShoppingCartItemDTO(this.item, this._store.type)) { this._store.getCanEditPrice$(item.id),
const catalogAvailabilities = this._store.availabilities?.filter( ]),
(availability) => availability?.purchaseOption === 'catalog', ),
); map(([canAddResult, canEditPrice]) => canAddResult?.canAdd && canEditPrice),
// #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, canEditVat$ = this.item$.pipe(
)?.data?.firstDayOfSale; switchMap((item) =>
return this.firstDayOfSaleBiggerThanToday(firstDayOfSale); combineLatest([this.canAddResult$, this._store.getCanEditVat$(item.id)]),
} ),
map(([canAddResult, canEditVat]) => canAddResult?.canAdd && canEditVat),
return undefined; );
}
isGiftCard$ = this.item$.pipe(
constructor(private _store: PurchaseOptionsStore) {} switchMap((item) => this._store.getIsGiftCard$(item.id)),
);
firstDayOfSaleBiggerThanToday(firstDayOfSale: string): Date {
if (!!firstDayOfSale && moment(firstDayOfSale)?.isAfter(moment())) { maxSelectableQuantity$ = combineLatest([
return moment(firstDayOfSale).toDate(); this._store.purchaseOption$,
} this.availability$,
return undefined; ]).pipe(
} map(([purchaseOption, availability]) => {
if (purchaseOption === 'in-store') {
onPriceInputInit(target: HTMLElement, overlayTrigger: UiOverlayTriggerDirective) { return availability?.inStock;
if (this._store.getIsGiftCard(this.item.id)) { }
overlayTrigger.open();
} return 999;
}),
target?.focus(); startWith(999),
} );
// Wichtig für das korrekte Setzen des Preises an das Item für den Endpoint request showMaxAvailableQuantity$ = combineLatest([
parsePrice(value: string) { this._store.purchaseOption$,
if (PRICE_PATTERN.test(value)) { this.availability$,
return parseFloat(value.replace(',', '.')); this.item$,
} ]).pipe(
} map(([purchaseOption, availability, item]) => {
if (
stringifyPrice(value: number) { purchaseOption === 'pickup' &&
if (!value) return ''; availability?.inStock < item.quantity
) {
const price = value.toFixed(2).replace('.', ','); return true;
if (price.includes(',')) { }
const [integer, decimal] = price.split(',');
return `${integer},${decimal.padEnd(2, '0')}`; return false;
} }),
);
return price;
} fetchingAvailabilities$ = this.item$
.pipe(
ngOnInit(): void { switchMap((item) =>
this.initPriceValidatorSubscription(); this._store.getFetchingAvailabilitiesForItem$(item.id),
this.initQuantitySubscription(); ),
this.initPriceSubscription(); )
this.initVatSubscription(); .pipe(map((fetchingAvailabilities) => fetchingAvailabilities.length > 0));
this.initSelectedSubscription();
} showNotAvailable$ = combineLatest([
this.availabilities$,
ngOnChanges({ item }: SimpleChanges) { this.fetchingAvailabilities$,
if (item) { ]).pipe(
this._itemSubject.next(this.item); map(([availabilities, fetchingAvailabilities]) => {
} if (fetchingAvailabilities) {
} return false;
}
ngOnDestroy(): void {
this._itemSubject.complete(); if (availabilities.length === 0) {
this._subscriptions.unsubscribe(); return true;
} }
initPriceValidatorSubscription() { return false;
const sub = this.item$.pipe(switchMap((item) => this._store.getIsGiftCard$(item.id))).subscribe((isGiftCard) => { }),
if (isGiftCard) { );
this.priceFormControl.setValidators(this._giftCardValidators);
} else { // Ticket #4813 Artikeldetailseite // EVT-Datum bei der Kaufoption Rücklage anzeigen
this.priceFormControl.setValidators(this._defaultValidators); get isEVT() {
} // Einstieg über Kaufoptionen - Hier wird die Katalogavailability verwendet die am ItemDTO hängt
}); if (isItemDTO(this.item, this._store.type)) {
const firstDayOfSale = this.item?.catalogAvailability?.firstDayOfSale;
this._subscriptions.add(sub); return this.firstDayOfSaleBiggerThanToday(firstDayOfSale);
} }
initQuantitySubscription() { // Einstieg über Warenkorb - Da wir hier ein ShoppingCartItemDTO haben werden hier die Katalogdaten neu gefetched und eine Katalogavailability drangehängt
const sub = this.item$.subscribe((item) => { if (isShoppingCartItemDTO(this.item, this._store.type)) {
if (this.quantityFormControl.value !== item.quantity) { const catalogAvailabilities = this._store.availabilities?.filter(
this.quantityFormControl.setValue(item.quantity); (availability) => availability?.purchaseOption === 'catalog',
} );
}); // #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(
const valueChangesSub = this.quantityFormControl.valueChanges.subscribe((quantity) => { (availability) => this.item().product?.ean === availability?.ean,
if (this.item.quantity !== quantity) { )?.data?.firstDayOfSale;
this._store.setItemQuantity(this.item.id, quantity); return this.firstDayOfSaleBiggerThanToday(firstDayOfSale);
} }
});
return undefined;
this._subscriptions.add(sub); }
this._subscriptions.add(valueChangesSub);
} useRedemptionPoints = toSignal(this._store.useRedemptionPoints$);
initPriceSubscription() { purchaseOption = toSignal(this._store.purchaseOption$);
const sub = combineLatest([this.canEditPrice$, this.price$]).subscribe(([canEditPrice, price]) => {
if (!canEditPrice) { isReservePurchaseOption = computed(() => {
return; return this.purchaseOption() === 'in-store';
} });
const priceStr = this.stringifyPrice(price?.value?.value); showLowStockMessage = computed(() => {
if (priceStr === '') return; return (
this.useRedemptionPoints() &&
if (this.parsePrice(this.priceFormControl.value) !== price?.value?.value) { this.isReservePurchaseOption() &&
this.priceFormControl.setValue(priceStr); this.availability().inStock < 2
} );
}); });
const valueChangesSub = combineLatest([this.canEditPrice$, this.priceFormControl.valueChanges]).subscribe( constructor(private _store: PurchaseOptionsStore) {}
([canEditPrice, value]) => {
if (!canEditPrice) { firstDayOfSaleBiggerThanToday(firstDayOfSale: string): Date {
return; if (!!firstDayOfSale && moment(firstDayOfSale)?.isAfter(moment())) {
} return moment(firstDayOfSale).toDate();
}
const price = this._store.getPrice(this.item.id); return undefined;
const parsedPrice = this.parsePrice(value); }
if (!parsedPrice) { onPriceInputInit(
this._store.setPrice(this.item.id, null); target: HTMLElement,
return; overlayTrigger: UiOverlayTriggerDirective,
} ) {
if (this._store.getIsGiftCard(this.item().id)) {
if (price[this.item.id] !== parsedPrice) { overlayTrigger.open();
this._store.setPrice(this.item.id, this.parsePrice(value)); }
}
}, target?.focus();
); }
this._subscriptions.add(sub);
this._subscriptions.add(valueChangesSub); // Wichtig für das korrekte Setzen des Preises an das Item für den Endpoint request
} parsePrice(value: string) {
if (PRICE_PATTERN.test(value)) {
initVatSubscription() { return parseFloat(value.replace(',', '.'));
const valueChangesSub = this.manualVatFormControl.valueChanges }
.pipe(withLatestFrom(this.vats$)) }
.subscribe(([formVatType, vats]) => {
const price = this._store.getPrice(this.item.id); stringifyPrice(value: number) {
if (!value) return '';
const vat = vats.find((vat) => vat?.vatType === Number(formVatType));
const price = value.toFixed(2).replace('.', ',');
if (!vat) { if (price.includes(',')) {
this._store.setVat(this.item.id, null); const [integer, decimal] = price.split(',');
return; return `${integer},${decimal.padEnd(2, '0')}`;
} }
if (price[this.item.id]?.vat?.vatType !== vat?.vatType) { return price;
this._store.setVat(this.item.id, vat); }
}
}); ngOnInit(): void {
this._subscriptions.add(valueChangesSub); this.initPriceValidatorSubscription();
} this.initQuantitySubscription();
this.initPriceSubscription();
initSelectedSubscription() { this.initVatSubscription();
const sub = this.item$ this.initSelectedSubscription();
.pipe(switchMap((item) => this._store.selectedItemIds$.pipe(map((ids) => ids.includes(item.id))))) }
.subscribe((selected) => {
if (this.selectedFormControl.value !== selected) { ngOnChanges({ item }: SimpleChanges) {
this.selectedFormControl.setValue(selected); if (item) {
} this._itemSubject.next(this.item());
}); }
const valueChangesSub = this.selectedFormControl.valueChanges.subscribe((selected) => { }
const current = this._store.selectedItemIds.includes(this.item.id);
if (current !== selected) { ngOnDestroy(): void {
this._store.setSelectedItem(this.item.id, selected); this._itemSubject.complete();
} this._subscriptions.unsubscribe();
}); }
this._subscriptions.add(sub);
this._subscriptions.add(valueChangesSub); 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);
}
});
this._subscriptions.add(sub);
}
initQuantitySubscription() {
const sub = this.item$.subscribe((item) => {
if (this.quantityFormControl.value !== item.quantity) {
this.quantityFormControl.setValue(item.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]) => {
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);
}
initVatSubscription() {
const valueChangesSub = this.manualVatFormControl.valueChanges
.pipe(withLatestFrom(this.vats$))
.subscribe(([formVatType, vats]) => {
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);
return;
}
if (price[this.item().id]?.vat?.vatType !== vat?.vatType) {
this._store.setVat(this.item().id, vat);
}
});
this._subscriptions.add(valueChangesSub);
}
initSelectedSubscription() {
const sub = this.item$
.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);
}
},
);
this._subscriptions.add(sub);
this._subscriptions.add(valueChangesSub);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,129 +1,169 @@
<div class="item-thumbnail"> <div class="item-thumbnail">
<a [routerLink]="productSearchDetailsPath" [queryParams]="{ main_qs: item?.product?.ean }"> <a
@if (item?.product?.ean | productImage; as thumbnailUrl) { [routerLink]="productSearchDetailsPath"
<img loading="lazy" [src]="thumbnailUrl" [alt]="item?.product?.name" /> [queryParams]="{ main_qs: item?.product?.ean }"
} >
</a> @if (item?.product?.ean | productImage; as thumbnailUrl) {
</div> <img loading="lazy" [src]="thumbnailUrl" [alt]="item?.product?.name" />
}
<div class="item-contributors"> </a>
@for (contributor of contributors$ | async; track contributor; let last = $last) { </div>
<a
[routerLink]="productSearchResultsPath" <div class="item-contributors">
[queryParams]="{ main_qs: contributor, main_author: 'author' }" @for (
(click)="$event?.stopPropagation()" contributor of contributors$ | async;
> track contributor;
{{ contributor }}{{ last ? '' : ';' }} let last = $last
</a> ) {
} <a
</div> [routerLink]="productSearchResultsPath"
[queryParams]="{ main_qs: contributor, main_author: 'author' }"
<div (click)="$event?.stopPropagation()"
class="item-title font-bold text-h2 mb-4" >
[class.text-h3]="item?.product?.name?.length >= 40 && isTablet" {{ contributor }}{{ last ? '' : ';' }}
[class.text-p1]="item?.product?.name?.length >= 50 || !isTablet" </a>
[class.text-p2]="item?.product?.name?.length >= 60 && isTablet" }
[class.text-p3]="item?.product?.name?.length >= 100" </div>
>
<a [routerLink]="productSearchDetailsPath" [queryParams]="{ main_qs: item?.product?.ean }">{{ item?.product?.name }}</a> <div
</div> class="item-title font-bold text-h2 mb-4"
[class.text-h3]="item?.product?.name?.length >= 40 && isTablet"
@if (item?.product?.format && item?.product?.formatDetail) { [class.text-p1]="item?.product?.name?.length >= 50 || !isTablet"
<div class="item-format"> [class.text-p2]="item?.product?.name?.length >= 60 && isTablet"
@if (item?.product?.format !== '--') { [class.text-p3]="item?.product?.name?.length >= 100"
<img >
src="assets/images/Icon_{{ item?.product?.format }}.svg" <a
[alt]="item?.product?.formatDetail" [routerLink]="productSearchDetailsPath"
/> [queryParams]="{ main_qs: item?.product?.ean }"
} >{{ item?.product?.name }}</a
{{ item?.product?.formatDetail }} >
</div> </div>
}
@if (item?.product?.format && item?.product?.formatDetail) {
<div class="item-info text-p2"> <div class="item-format">
<div class="mb-1">{{ item?.product?.manufacturer | substr: 25 }} | {{ item?.product?.ean }}</div> @if (item?.product?.format !== '--') {
<div class="mb-1"> <img
{{ item?.product?.volume }} src="assets/images/Icon_{{ item?.product?.format }}.svg"
@if (item?.product?.volume && item?.product?.publicationDate) { [alt]="item?.product?.formatDetail"
<span>|</span> />
} }
{{ item?.product?.publicationDate | date }} {{ item?.product?.formatDetail }}
</div> </div>
@if (notAvailable$ | async) { }
<div>
<span class="text-brand item-date">Nicht verfügbar</span> <div class="item-info text-p2">
</div> <div class="mb-1">
} {{ item?.product?.manufacturer | substr: 25 }} | {{ item?.product?.ean }}
</div>
@if (refreshingAvailabilit$ | async) { <div class="mb-1">
<shared-skeleton-loader class="w-40"></shared-skeleton-loader> {{ item?.product?.volume }}
} @else { @if (item?.product?.volume && item?.product?.publicationDate) {
@if (orderType === 'Abholung') { <span>|</span>
<div class="item-date" [class.availability-changed]="estimatedShippingDateChanged$ | async"> }
Abholung ab {{ item?.availability?.estimatedShippingDate | date }} {{ item?.product?.publicationDate | date }}
</div> </div>
} @if (notAvailable$ | async) {
@if (orderType === 'Versand' || orderType === 'B2B-Versand' || orderType === 'DIG-Versand') { <div>
<div <span class="text-brand item-date">Nicht verfügbar</span>
class="item-date" </div>
[class.availability-changed]="estimatedShippingDateChanged$ | async" }
>
@if (item?.availability?.estimatedDelivery) { @if (refreshingAvailabilit$ | async) {
Zustellung zwischen {{ (item?.availability?.estimatedDelivery?.start | date: 'EEE, dd.MM.')?.replace('.', '') }} <shared-skeleton-loader class="w-40"></shared-skeleton-loader>
und } @else {
{{ (item?.availability?.estimatedDelivery?.stop | date: 'EEE, dd.MM.')?.replace('.', '') }} @if (orderType === 'Abholung') {
} @else { <div
Versand {{ item?.availability?.estimatedShippingDate | date }} class="item-date"
} [class.availability-changed]="estimatedShippingDateChanged$ | async"
</div> >
} Abholung ab {{ item?.availability?.estimatedShippingDate | date }}
} </div>
}
@if (
@if (olaError$ | async) { orderType === 'Versand' ||
<div class="item-availability-message">Artikel nicht verfügbar</div> orderType === 'B2B-Versand' ||
} orderType === 'DIG-Versand'
</div> ) {
<div
<div class="item-price-stock flex flex-col"> class="item-date"
<div class="text-p2 font-bold">{{ item?.availability?.price?.value?.value | currency: 'EUR' : 'code' }}</div> [class.availability-changed]="estimatedShippingDateChanged$ | async"
<div class="text-p2 font-normal"> >
@if (!(isDummy$ | async)) { @if (item?.availability?.estimatedDelivery) {
<ui-quantity-dropdown Zustellung zwischen
[ngModel]="item?.quantity" {{
(ngModelChange)="onChangeQuantity($event)" (
[showSpinner]="(loadingOnQuantityChangeById$ | async) === item?.id" item?.availability?.estimatedDelivery?.start | date: 'EEE, dd.MM.'
[disabled]="(loadingOnItemChangeById$ | async) === item?.id" )?.replace('.', '')
[range]="quantityRange$ | async" }}
></ui-quantity-dropdown> und
} @else { {{
<div class="mt-2">{{ item?.quantity }}x</div> (
} item?.availability?.estimatedDelivery?.stop | date: 'EEE, dd.MM.'
</div> )?.replace('.', '')
@if (quantityError) { }}
<div class="quantity-error"> } @else {
{{ quantityError }} Versand {{ item?.availability?.estimatedShippingDate | date }}
</div> }
} </div>
</div> }
}
@if (orderType !== 'Download') {
<div class="actions"> @if (olaError$ | async) {
@if (!(hasOrderType$ | async)) { <div class="item-availability-message">Artikel nicht verfügbar</div>
<button }
[disabled]="(loadingOnQuantityChangeById$ | async) === item?.id || (loadingOnItemChangeById$ | async) === item?.id" </div>
(click)="onChangeItem()"
> <div class="item-price-stock flex flex-col">
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id">Lieferweg auswählen</ui-spinner> <div class="text-p2 font-bold">
</button> {{ item?.availability?.price?.value?.value | currency: 'EUR' : 'code' }}
} </div>
@if (canEdit$ | async) { <div class="text-p2 font-normal">
<button @if (!(isDummy$ | async)) {
[disabled]="(loadingOnQuantityChangeById$ | async) === item?.id || (loadingOnItemChangeById$ | async) === item?.id" <ui-quantity-dropdown
(click)="onChangeItem()" [ngModel]="item?.quantity"
> (ngModelChange)="onChangeQuantity($event)"
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id">Lieferweg ändern</ui-spinner> [showSpinner]="(loadingOnQuantityChangeById$ | async) === item?.id"
</button> [disabled]="(loadingOnItemChangeById$ | async) === item?.id"
} [range]="quantityRange$ | async"
</div> ></ui-quantity-dropdown>
} } @else {
<div class="mt-2">{{ item?.quantity }}x</div>
}
</div>
@if (quantityError) {
<div class="quantity-error">
{{ quantityError }}
</div>
}
</div>
@if (orderType !== 'Download') {
<div class="actions">
@if (!(hasOrderType$ | async)) {
<button
[disabled]="
(loadingOnQuantityChangeById$ | async) === item?.id ||
(loadingOnItemChangeById$ | async) === item?.id
"
(click)="onChangeItem()"
>
<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
"
(click)="onChangeItem()"
>
<ui-spinner [show]="(loadingOnItemChangeById$ | async) === item?.id"
>Lieferweg ändern</ui-spinner
>
</button>
}
</div>
}

View File

@@ -1,255 +1,298 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
NgZone, NgZone,
OnInit, OnInit,
Output, Output,
inject, inject,
} from '@angular/core'; } from '@angular/core';
import { ApplicationService } from '@core/application'; import { ApplicationService } from '@core/application';
import { EnvironmentService } from '@core/environment'; import { EnvironmentService } from '@core/environment';
import { DomainAvailabilityService } from '@domain/availability'; import { DomainAvailabilityService } from '@domain/availability';
import { DomainCheckoutService } from '@domain/checkout'; import { DomainCheckoutService } from '@domain/checkout';
import { ComponentStore } from '@ngrx/component-store'; import { ComponentStore } from '@ngrx/component-store';
import { ProductCatalogNavigationService } from '@shared/services/navigation'; import { ProductCatalogNavigationService } from '@shared/services/navigation';
import { ItemType, ShoppingCartItemDTO } from '@generated/swagger/checkout-api'; import { ItemType, ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import moment from 'moment'; import moment from 'moment';
import { combineLatest } from 'rxjs'; import { combineLatest } from 'rxjs';
import { filter, first, map, shareReplay, switchMap } from 'rxjs/operators'; import { filter, first, map, shareReplay, switchMap } from 'rxjs/operators';
export interface ShoppingCartItemComponentState { export interface ShoppingCartItemComponentState {
item: ShoppingCartItemDTO; item: ShoppingCartItemDTO;
orderType: string; orderType: string;
loadingOnItemChangeById?: number; loadingOnItemChangeById?: number;
loadingOnQuantityChangeById?: number; loadingOnQuantityChangeById?: number;
refreshingAvailability: boolean; refreshingAvailability: boolean;
sscChanged: boolean; sscChanged: boolean;
sscTextChanged: boolean; sscTextChanged: boolean;
estimatedShippingDateChanged: boolean; estimatedShippingDateChanged: boolean;
} }
@Component({ @Component({
selector: 'page-shopping-cart-item', selector: 'page-shopping-cart-item',
templateUrl: 'shopping-cart-item.component.html', templateUrl: 'shopping-cart-item.component.html',
styleUrls: ['shopping-cart-item.component.scss'], styleUrls: ['shopping-cart-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false, standalone: false,
}) })
export class ShoppingCartItemComponent extends ComponentStore<ShoppingCartItemComponentState> implements OnInit { export class ShoppingCartItemComponent
private _zone = inject(NgZone); extends ComponentStore<ShoppingCartItemComponentState>
implements OnInit
@Output() changeItem = new EventEmitter<{ shoppingCartItem: ShoppingCartItemDTO }>(); {
@Output() changeDummyItem = new EventEmitter<{ shoppingCartItem: ShoppingCartItemDTO }>(); private _zone = inject(NgZone);
@Output() changeQuantity = new EventEmitter<{ shoppingCartItem: ShoppingCartItemDTO; quantity: number }>();
@Output() changeItem = new EventEmitter<{
@Input() shoppingCartItem: ShoppingCartItemDTO;
get item() { }>();
return this.get((s) => s.item); @Output() changeDummyItem = new EventEmitter<{
} shoppingCartItem: ShoppingCartItemDTO;
set item(item: ShoppingCartItemDTO) { }>();
if (this.item !== item) { @Output() changeQuantity = new EventEmitter<{
this.patchState({ item }); shoppingCartItem: ShoppingCartItemDTO;
} quantity: number;
} }>();
readonly item$ = this.select((s) => s.item);
@Input()
readonly contributors$ = this.item$.pipe( get item() {
map((item) => item?.product?.contributors?.split(';').map((val) => val.trim())), return this.get((s) => s.item);
); }
set item(item: ShoppingCartItemDTO) {
@Input() if (this.item !== item) {
get orderType() { this.patchState({ item });
return this.get((s) => s.orderType); }
} }
set orderType(orderType: string) { readonly item$ = this.select((s) => s.item);
if (this.orderType !== orderType) {
this.patchState({ orderType }); readonly contributors$ = this.item$.pipe(
} map((item) =>
} item?.product?.contributors?.split(';').map((val) => val.trim()),
readonly orderType$ = this.select((s) => s.orderType); ),
);
@Input()
get loadingOnItemChangeById() { get showLoyaltyValue() {
return this.get((s) => s.loadingOnItemChangeById); return this.item?.loyalty?.value > 0;
} }
set loadingOnItemChangeById(loadingOnItemChangeById: number) {
if (this.loadingOnItemChangeById !== loadingOnItemChangeById) { @Input()
this.patchState({ loadingOnItemChangeById }); get orderType() {
} return this.get((s) => s.orderType);
} }
readonly loadingOnItemChangeById$ = this.select((s) => s.loadingOnItemChangeById).pipe(shareReplay()); set orderType(orderType: string) {
if (this.orderType !== orderType) {
@Input() this.patchState({ orderType });
get loadingOnQuantityChangeById() { }
return this.get((s) => s.loadingOnQuantityChangeById); }
} readonly orderType$ = this.select((s) => s.orderType);
set loadingOnQuantityChangeById(loadingOnQuantityChangeById: number) {
if (this.loadingOnQuantityChangeById !== loadingOnQuantityChangeById) { @Input()
this.patchState({ loadingOnQuantityChangeById }); get loadingOnItemChangeById() {
} return this.get((s) => s.loadingOnItemChangeById);
} }
readonly loadingOnQuantityChangeById$ = this.select((s) => s.loadingOnQuantityChangeById).pipe(shareReplay()); set loadingOnItemChangeById(loadingOnItemChangeById: number) {
if (this.loadingOnItemChangeById !== loadingOnItemChangeById) {
@Input() this.patchState({ loadingOnItemChangeById });
quantityError: string; }
}
isDummy$ = this.item$.pipe( readonly loadingOnItemChangeById$ = this.select(
map((item) => item?.availability?.supplyChannel === 'MANUALLY'), (s) => s.loadingOnItemChangeById,
shareReplay(), ).pipe(shareReplay());
);
hasOrderType$ = this.orderType$.pipe( @Input()
map((orderType) => orderType !== undefined), get loadingOnQuantityChangeById() {
shareReplay(), return this.get((s) => s.loadingOnQuantityChangeById);
); }
set loadingOnQuantityChangeById(loadingOnQuantityChangeById: number) {
canEdit$ = combineLatest([this.isDummy$, this.hasOrderType$, this.item$]).pipe( if (this.loadingOnQuantityChangeById !== loadingOnQuantityChangeById) {
map(([isDummy, hasOrderType, item]) => { this.patchState({ loadingOnQuantityChangeById });
if (item.itemType === (66560 as ItemType)) { }
return false; }
} readonly loadingOnQuantityChangeById$ = this.select(
return isDummy || hasOrderType; (s) => s.loadingOnQuantityChangeById,
}), ).pipe(shareReplay());
);
@Input()
quantityRange$ = combineLatest([this.orderType$, this.item$]).pipe( quantityError: string;
map(([orderType, item]) => (orderType === 'Rücklage' ? item.availability?.inStock : 999)),
); isDummy$ = this.item$.pipe(
map((item) => item?.availability?.supplyChannel === 'MANUALLY'),
isDownloadAvailable$ = combineLatest([this.item$, this.orderType$]).pipe( shareReplay(),
filter(([_, orderType]) => orderType === 'Download'), );
switchMap(([item]) => hasOrderType$ = this.orderType$.pipe(
this.availabilityService.getDownloadAvailability({ map((orderType) => orderType !== undefined),
item: { ean: item.product.ean, price: item.availability.price, itemId: +item.product.catalogProductNumber }, shareReplay(),
}), );
),
map((availability) => availability && this.availabilityService.isAvailable({ availability })), canEdit$ = combineLatest([
); this.isDummy$,
this.hasOrderType$,
olaError$ = this.checkoutService this.item$,
.getOlaErrors({ processId: this.application.activatedProcessId }) ]).pipe(
.pipe(map((ids) => ids?.find((id) => id === this.item.id))); map(([isDummy, hasOrderType, item]) => {
if (item.itemType === (66560 as ItemType)) {
get productSearchResultsPath() { return false;
return this._productNavigationService.getArticleSearchResultsPath(this.application.activatedProcessId).path; }
} return isDummy || hasOrderType;
}),
get productSearchDetailsPath() { );
return this._productNavigationService.getArticleDetailsPathByEan({
processId: this.application.activatedProcessId, quantityRange$ = combineLatest([this.orderType$, this.item$]).pipe(
ean: this.item?.product?.ean, map(([orderType, item]) =>
}).path; orderType === 'Rücklage' ? item.availability?.inStock : 999,
} ),
);
get isTablet() {
return this._environment.matchTablet(); isDownloadAvailable$ = combineLatest([this.item$, this.orderType$]).pipe(
} filter(([, orderType]) => orderType === 'Download'),
switchMap(([item]) =>
refreshingAvailabilit$ = this.select((s) => s.refreshingAvailability); this.availabilityService.getDownloadAvailability({
item: {
sscChanged$ = this.select((s) => s.sscChanged || s.sscTextChanged); ean: item.product.ean,
price: item.availability.price,
estimatedShippingDateChanged$ = this.select((s) => s.estimatedShippingDateChanged); itemId: +item.product.catalogProductNumber,
},
notAvailable$ = this.item$.pipe( }),
map((item) => { ),
const availability = item?.availability; map(
(availability) =>
if (availability.availabilityType === 0) { availability && this.availabilityService.isAvailable({ availability }),
return false; ),
} );
if (availability.inStock && item.quantity > availability.inStock) { olaError$ = this.checkoutService
return true; .getOlaErrors({ processId: this.application.activatedProcessId })
} .pipe(map((ids) => ids?.find((id) => id === this.item.id)));
return !this.availabilityService.isAvailable({ availability }); get productSearchResultsPath() {
}), return this._productNavigationService.getArticleSearchResultsPath(
); this.application.activatedProcessId,
).path;
constructor( }
private availabilityService: DomainAvailabilityService,
private checkoutService: DomainCheckoutService, get productSearchDetailsPath() {
public application: ApplicationService, return this._productNavigationService.getArticleDetailsPathByEan({
private _productNavigationService: ProductCatalogNavigationService, processId: this.application.activatedProcessId,
private _environment: EnvironmentService, ean: this.item?.product?.ean,
private _cdr: ChangeDetectorRef, }).path;
) { }
super({
item: undefined, get isTablet() {
orderType: '', return this._environment.matchTablet();
refreshingAvailability: false, }
sscChanged: false,
sscTextChanged: false, refreshingAvailabilit$ = this.select((s) => s.refreshingAvailability);
estimatedShippingDateChanged: false,
}); sscChanged$ = this.select((s) => s.sscChanged || s.sscTextChanged);
}
estimatedShippingDateChanged$ = this.select(
ngOnInit() {} (s) => s.estimatedShippingDateChanged,
);
async onChangeItem() {
const isDummy = await this.isDummy$.pipe(first()).toPromise(); notAvailable$ = this.item$.pipe(
isDummy map((item) => {
? this.changeDummyItem.emit({ shoppingCartItem: this.item }) const availability = item?.availability;
: this.changeItem.emit({ shoppingCartItem: this.item });
} if (availability.availabilityType === 0) {
return false;
onChangeQuantity(quantity: number) { }
this.changeQuantity.emit({ shoppingCartItem: this.item, quantity });
} if (availability.inStock && item.quantity > availability.inStock) {
return true;
async refreshAvailability() { }
const currentAvailability = cloneDeep(this.item.availability);
return !this.availabilityService.isAvailable({ availability });
try { }),
this.patchRefreshingAvailability(true); );
this._cdr.markForCheck();
const availability = await this.checkoutService.refreshAvailability({ constructor(
processId: this.application.activatedProcessId, private availabilityService: DomainAvailabilityService,
shoppingCartItemId: this.item.id, private checkoutService: DomainCheckoutService,
}); public application: ApplicationService,
private _productNavigationService: ProductCatalogNavigationService,
if (currentAvailability.ssc !== availability.ssc) { private _environment: EnvironmentService,
this.sscChanged(); private _cdr: ChangeDetectorRef,
} ) {
if (currentAvailability.sscText !== availability.sscText) { super({
this.ssctextChanged(); item: undefined,
} orderType: '',
if ( refreshingAvailability: false,
moment(currentAvailability.estimatedShippingDate) sscChanged: false,
.startOf('day') sscTextChanged: false,
.diff(moment(availability.estimatedShippingDate).startOf('day')) estimatedShippingDateChanged: false,
) { });
this.estimatedShippingDateChanged(); }
}
} catch (error) {} ngOnInit() {
// Component initialization
this.patchRefreshingAvailability(false); }
this._cdr.markForCheck();
} async onChangeItem() {
const isDummy = await this.isDummy$.pipe(first()).toPromise();
patchRefreshingAvailability(value: boolean) { if (isDummy) {
this._zone.run(() => { this.changeDummyItem.emit({ shoppingCartItem: this.item });
this.patchState({ refreshingAvailability: value }); } else {
this._cdr.markForCheck(); this.changeItem.emit({ shoppingCartItem: this.item });
}); }
} }
ssctextChanged() { onChangeQuantity(quantity: number) {
this.patchState({ sscTextChanged: true }); this.changeQuantity.emit({ shoppingCartItem: this.item, quantity });
this._cdr.markForCheck(); }
}
async refreshAvailability() {
sscChanged() { const currentAvailability = cloneDeep(this.item.availability);
this.patchState({ sscChanged: true });
this._cdr.markForCheck(); try {
} this.patchRefreshingAvailability(true);
this._cdr.markForCheck();
estimatedShippingDateChanged() { const availability = await this.checkoutService.refreshAvailability({
this.patchState({ estimatedShippingDateChanged: true }); processId: this.application.activatedProcessId,
this._cdr.markForCheck(); shoppingCartItemId: this.item.id,
} });
}
if (currentAvailability.ssc !== availability.ssc) {
this.sscChanged();
}
if (currentAvailability.sscText !== availability.sscText) {
this.ssctextChanged();
}
if (
moment(currentAvailability.estimatedShippingDate)
.startOf('day')
.diff(moment(availability.estimatedShippingDate).startOf('day'))
) {
this.estimatedShippingDateChanged();
}
} catch {
// Error handling for availability refresh
}
this.patchRefreshingAvailability(false);
this._cdr.markForCheck();
}
patchRefreshingAvailability(value: boolean) {
this._zone.run(() => {
this.patchState({ refreshingAvailability: value });
this._cdr.markForCheck();
});
}
ssctextChanged() {
this.patchState({ sscTextChanged: true });
this._cdr.markForCheck();
}
sscChanged() {
this.patchState({ sscChanged: true });
this._cdr.markForCheck();
}
estimatedShippingDateChanged() {
this.patchState({ estimatedShippingDateChanged: true });
this._cdr.markForCheck();
}
}

View File

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

View File

@@ -1,6 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { import {
EntityContainerSchema,
AvailabilityDTOSchema, AvailabilityDTOSchema,
CampaignDTOSchema, CampaignDTOSchema,
LoyaltyDTOSchema, LoyaltyDTOSchema,
@@ -9,14 +8,14 @@ import {
PriceSchema, PriceSchema,
EntityDTOContainerOfDestinationDTOSchema, EntityDTOContainerOfDestinationDTOSchema,
ItemTypeSchema, ItemTypeSchema,
PriceValueSchema,
} from './base-schemas'; } from './base-schemas';
const AddToShoppingCartDTOSchema = z.object({ const AddToShoppingCartDefaultSchema = z.object({
availability: AvailabilityDTOSchema, availability: AvailabilityDTOSchema,
campaign: CampaignDTOSchema, campaign: CampaignDTOSchema,
destination: EntityDTOContainerOfDestinationDTOSchema, destination: EntityDTOContainerOfDestinationDTOSchema,
itemType: ItemTypeSchema, itemType: ItemTypeSchema,
loyalty: LoyaltyDTOSchema,
product: ProductDTOSchema, product: ProductDTOSchema,
promotion: PromotionDTOSchema, promotion: PromotionDTOSchema,
quantity: z.number().int().positive(), quantity: z.number().int().positive(),
@@ -24,9 +23,34 @@ const AddToShoppingCartDTOSchema = z.object({
shopItemId: z.number().int().positive().optional(), 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({ export const AddItemToShoppingCartParamsSchema = z.object({
shoppingCartId: z.number().int().positive(), shoppingCartId: z.number().int().positive(),
items: z.array(AddToShoppingCartDTOSchema).min(1), items: z.array(AddToShoppingCartSchema).min(1),
}); });
export type AddItemToShoppingCartParams = z.infer< export type AddItemToShoppingCartParams = z.infer<

View File

@@ -1,6 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { import {
EntityContainerSchema,
AvailabilityDTOSchema, AvailabilityDTOSchema,
CampaignDTOSchema, CampaignDTOSchema,
LoyaltyDTOSchema, LoyaltyDTOSchema,
@@ -10,7 +9,7 @@ import {
} from './base-schemas'; } from './base-schemas';
import { UpdateShoppingCartItem } from '../models'; import { UpdateShoppingCartItem } from '../models';
const UpdateShoppingCartItemParamsValueSchema = z.object({ const UpdateShoppingCartItemParamsValueDefaultSchema = z.object({
availability: AvailabilityDTOSchema, availability: AvailabilityDTOSchema,
buyerComment: z.string().optional(), buyerComment: z.string().optional(),
campaign: CampaignDTOSchema, campaign: CampaignDTOSchema,
@@ -22,6 +21,34 @@ const UpdateShoppingCartItemParamsValueSchema = z.object({
specialComment: z.string().optional(), 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({ export const UpdateShoppingCartItemParamsSchema = z.object({
shoppingCartId: z.number().int().positive(), shoppingCartId: z.number().int().positive(),
shoppingCartItemId: z.number().int().positive(), shoppingCartItemId: z.number().int().positive(),

View File

@@ -28,7 +28,7 @@ export class CheckoutMetadataService {
setShoppingCartId(tabId: number, shoppingCartId: number | undefined) { setShoppingCartId(tabId: number, shoppingCartId: number | undefined) {
this.#tabService.patchTabMetadata(tabId, { 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)) @ValidateParam(0, z.string().min(1))
get(key: string): unknown { get(key: string): unknown {
console.log('Getting user state key:', key); const data = structuredClone(this.#state[key]);
return structuredClone(this.#state[key]); console.log('Got user state data:', key, data);
return data;
} }
@ValidateParam(0, z.string().min(1)) @ValidateParam(0, z.string().min(1))

70417
package-lock.json generated
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,138 +1,130 @@
{ {
"name": "hima", "name": "hima",
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "nx serve isa-app --ssl", "start": "nx serve isa-app --ssl",
"pretest": "npx trash-cli testresults", "pretest": "npx trash-cli testresults",
"test": "npx nx run-many --tuiAutoExit true -t test --exclude isa-app", "test": "npx nx run-many --tuiAutoExit true -t test --exclude isa-app",
"ci": "npx nx run-many -t test --exclude isa-app -c ci --tuiAutoExit true", "ci": "npx nx run-many -t test --exclude isa-app -c ci --tuiAutoExit true",
"build": "nx build isa-app --configuration=development", "build": "nx build isa-app --configuration=development",
"build-prod": "nx build isa-app --configuration=production", "build-prod": "nx build isa-app --configuration=production",
"lint": "nx lint", "lint": "nx lint",
"e2e": "nx e2e", "e2e": "nx e2e",
"generate:swagger": "nx run-many -t generate -p tag:generated,swagger", "generate:swagger": "nx run-many -t generate -p tag:generated,swagger",
"fix:files:swagger": "node ./tools/fix-files.js generated/swagger", "fix:files:swagger": "node ./tools/fix-files.js generated/swagger",
"prettier": "prettier --write .", "prettier": "prettier --write .",
"pretty-quick": "pretty-quick --staged", "pretty-quick": "pretty-quick --staged",
"prepare": "husky", "prepare": "husky",
"storybook": "npx nx run isa-app:storybook" "storybook": "npx nx run isa-app:storybook"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular-architects/ngrx-toolkit": "^20.4.0", "@angular-architects/ngrx-toolkit": "^20.4.0",
"@angular/animations": "20.1.2", "@angular/animations": "20.1.2",
"@angular/cdk": "20.1.2", "@angular/cdk": "20.1.2",
"@angular/common": "20.1.2", "@angular/common": "20.1.2",
"@angular/compiler": "20.1.2", "@angular/compiler": "20.1.2",
"@angular/core": "20.1.2", "@angular/core": "20.1.2",
"@angular/forms": "20.1.2", "@angular/forms": "20.1.2",
"@angular/localize": "20.1.2", "@angular/localize": "20.1.2",
"@angular/platform-browser": "20.1.2", "@angular/platform-browser": "20.1.2",
"@angular/platform-browser-dynamic": "20.1.2", "@angular/platform-browser-dynamic": "20.1.2",
"@angular/router": "20.1.2", "@angular/router": "20.1.2",
"@angular/service-worker": "20.1.2", "@angular/service-worker": "20.1.2",
"@microsoft/signalr": "^8.0.7", "@microsoft/signalr": "^8.0.7",
"@ng-icons/core": "32.0.0", "@ng-icons/core": "32.0.0",
"@ng-icons/material-icons": "32.0.0", "@ng-icons/material-icons": "32.0.0",
"@ngrx/component-store": "^20.0.0", "@ngrx/component-store": "^20.0.0",
"@ngrx/effects": "^20.0.0", "@ngrx/effects": "^20.0.0",
"@ngrx/entity": "^20.0.0", "@ngrx/entity": "^20.0.0",
"@ngrx/operators": "^20.0.0", "@ngrx/operators": "^20.0.0",
"@ngrx/signals": "^20.0.0", "@ngrx/signals": "^20.0.0",
"@ngrx/store": "^20.0.0", "@ngrx/store": "^20.0.0",
"@ngrx/store-devtools": "^20.0.0", "@ngrx/store-devtools": "^20.0.0",
"angular-oauth2-oidc": "20.0.0", "angular-oauth2-oidc": "20.0.0",
"angular-oauth2-oidc-jwks": "20.0.0", "angular-oauth2-oidc-jwks": "20.0.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.30.1", "moment": "^2.30.1",
"ng2-pdf-viewer": "^10.4.0", "ng2-pdf-viewer": "^10.4.0",
"ngx-matomo-client": "^8.0.0", "ngx-matomo-client": "^8.0.0",
"parse-duration": "^2.1.3", "parse-duration": "^2.1.3",
"rxjs": "~7.8.2", "rxjs": "~7.8.2",
"scandit-web-datacapture-barcode": "^6.28.1", "scandit-web-datacapture-barcode": "^6.28.1",
"scandit-web-datacapture-core": "^6.28.1", "scandit-web-datacapture-core": "^6.28.1",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"zod": "^3.24.2", "zod": "^3.24.2",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@analogjs/vite-plugin-angular": "1.19.1", "@analogjs/vite-plugin-angular": "1.19.1",
"@analogjs/vitest-angular": "1.19.1", "@analogjs/vitest-angular": "1.19.1",
"@angular-devkit/build-angular": "20.1.1", "@angular-devkit/build-angular": "20.1.1",
"@angular-devkit/core": "20.1.1", "@angular-devkit/core": "20.1.1",
"@angular-devkit/schematics": "20.1.1", "@angular-devkit/schematics": "20.1.1",
"@angular/build": "20.1.1", "@angular/build": "20.1.1",
"@angular/cli": "~20.1.0", "@angular/cli": "~20.1.0",
"@angular/compiler-cli": "20.1.2", "@angular/compiler-cli": "20.1.2",
"@angular/language-service": "20.1.2", "@angular/language-service": "20.1.2",
"@angular/pwa": "20.1.1", "@angular/pwa": "20.1.1",
"@eslint/js": "^9.8.0", "@eslint/js": "^9.8.0",
"@ngneat/spectator": "19.6.2", "@ngneat/spectator": "19.6.2",
"@nx/angular": "21.3.2", "@nx/angular": "21.3.2",
"@nx/eslint": "21.3.2", "@nx/eslint": "21.3.2",
"@nx/eslint-plugin": "21.3.2", "@nx/eslint-plugin": "21.3.2",
"@nx/jest": "21.3.2", "@nx/jest": "21.3.2",
"@nx/js": "21.3.2", "@nx/js": "21.3.2",
"@nx/storybook": "21.3.2", "@nx/storybook": "21.3.2",
"@nx/vite": "21.3.2", "@nx/vite": "21.3.2",
"@nx/web": "21.3.2", "@nx/web": "21.3.2",
"@nx/workspace": "21.3.2", "@nx/workspace": "21.3.2",
"@schematics/angular": "20.1.1", "@schematics/angular": "20.1.1",
"@storybook/addon-docs": "^9.0.11", "@storybook/addon-docs": "^9.0.11",
"@storybook/angular": "^9.0.5", "@storybook/angular": "^9.0.5",
"@swc-node/register": "1.10.10", "@swc-node/register": "1.10.10",
"@swc/core": "1.12.1", "@swc/core": "1.12.1",
"@swc/helpers": "0.5.17", "@swc/helpers": "0.5.17",
"@types/jest": "30.0.0", "@types/jest": "30.0.0",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/node": "18.16.9", "@types/node": "18.16.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/utils": "^8.33.1", "@typescript-eslint/utils": "^8.33.1",
"@vitest/coverage-v8": "^3.1.1", "@vitest/coverage-v8": "^3.1.1",
"@vitest/ui": "^3.1.1", "@vitest/ui": "^3.1.1",
"angular-eslint": "20.1.1", "angular-eslint": "20.1.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.28.0", "eslint": "^9.28.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",
"husky": "^9.1.7", "husky": "^9.1.7",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-environment-node": "^29.7.0", "jest-environment-node": "^29.7.0",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"jest-preset-angular": "14.6.0", "jest-preset-angular": "14.6.0",
"jiti": "2.4.2", "jiti": "2.4.2",
"jsdom": "~22.1.0", "jsdom": "~22.1.0",
"jsonc-eslint-parser": "^2.1.0", "jsonc-eslint-parser": "^2.1.0",
"ng-mocks": "14.13.5", "ng-mocks": "14.13.5",
"ng-packagr": "20.1.0", "ng-packagr": "20.1.0",
"ng-swagger-gen": "^2.3.1", "ng-swagger-gen": "^2.3.1",
"nx": "21.3.2", "nx": "21.3.2",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"postcss-url": "~10.1.3", "postcss-url": "~10.1.3",
"prettier": "^3.5.2", "prettier": "^3.5.2",
"pretty-quick": "~4.0.0", "pretty-quick": "~4.0.0",
"storybook": "^9.0.5", "storybook": "^9.0.5",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"ts-jest": "^29.1.0", "ts-jest": "^29.1.0",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"typescript": "5.8.3", "typescript": "5.8.3",
"typescript-eslint": "^8.33.1", "typescript-eslint": "^8.33.1",
"vite": "6.3.5", "vite": "6.3.5",
"vitest": "^3.1.1" "vitest": "^3.1.1"
}, },
"optionalDependencies": { "engines": {
"@esbuild/linux-x64": "^0.25.5" "node": ">=22.00.0",
}, "npm": ">=10.0.0"
"engines": { }
"node": ">=22.00.0", }
"npm": ">=10.0.0"
},
"overrides": {
"jest-environment-jsdom": {
"jsdom": "26.0.0"
}
}
}