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

feat: implement reward points system in purchase options

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

Refs: #5352

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,129 +1,169 @@
<div class="item-thumbnail">
<a [routerLink]="productSearchDetailsPath" [queryParams]="{ main_qs: item?.product?.ean }">
@if (item?.product?.ean | productImage; as thumbnailUrl) {
<img loading="lazy" [src]="thumbnailUrl" [alt]="item?.product?.name" />
}
</a>
</div>
<div class="item-contributors">
@for (contributor of contributors$ | async; track contributor; let last = $last) {
<a
[routerLink]="productSearchResultsPath"
[queryParams]="{ main_qs: contributor, main_author: 'author' }"
(click)="$event?.stopPropagation()"
>
{{ contributor }}{{ last ? '' : ';' }}
</a>
}
</div>
<div
class="item-title font-bold text-h2 mb-4"
[class.text-h3]="item?.product?.name?.length >= 40 && isTablet"
[class.text-p1]="item?.product?.name?.length >= 50 || !isTablet"
[class.text-p2]="item?.product?.name?.length >= 60 && isTablet"
[class.text-p3]="item?.product?.name?.length >= 100"
>
<a [routerLink]="productSearchDetailsPath" [queryParams]="{ main_qs: item?.product?.ean }">{{ item?.product?.name }}</a>
</div>
@if (item?.product?.format && item?.product?.formatDetail) {
<div class="item-format">
@if (item?.product?.format !== '--') {
<img
src="assets/images/Icon_{{ item?.product?.format }}.svg"
[alt]="item?.product?.formatDetail"
/>
}
{{ item?.product?.formatDetail }}
</div>
}
<div class="item-info text-p2">
<div class="mb-1">{{ item?.product?.manufacturer | substr: 25 }} | {{ item?.product?.ean }}</div>
<div class="mb-1">
{{ item?.product?.volume }}
@if (item?.product?.volume && item?.product?.publicationDate) {
<span>|</span>
}
{{ item?.product?.publicationDate | date }}
</div>
@if (notAvailable$ | async) {
<div>
<span class="text-brand item-date">Nicht verfügbar</span>
</div>
}
@if (refreshingAvailabilit$ | async) {
<shared-skeleton-loader class="w-40"></shared-skeleton-loader>
} @else {
@if (orderType === 'Abholung') {
<div class="item-date" [class.availability-changed]="estimatedShippingDateChanged$ | async">
Abholung ab {{ item?.availability?.estimatedShippingDate | date }}
</div>
}
@if (orderType === 'Versand' || orderType === 'B2B-Versand' || orderType === 'DIG-Versand') {
<div
class="item-date"
[class.availability-changed]="estimatedShippingDateChanged$ | async"
>
@if (item?.availability?.estimatedDelivery) {
Zustellung zwischen {{ (item?.availability?.estimatedDelivery?.start | date: 'EEE, dd.MM.')?.replace('.', '') }}
und
{{ (item?.availability?.estimatedDelivery?.stop | date: 'EEE, dd.MM.')?.replace('.', '') }}
} @else {
Versand {{ item?.availability?.estimatedShippingDate | date }}
}
</div>
}
}
@if (olaError$ | async) {
<div class="item-availability-message">Artikel nicht verfügbar</div>
}
</div>
<div class="item-price-stock flex flex-col">
<div class="text-p2 font-bold">{{ item?.availability?.price?.value?.value | currency: 'EUR' : 'code' }}</div>
<div class="text-p2 font-normal">
@if (!(isDummy$ | async)) {
<ui-quantity-dropdown
[ngModel]="item?.quantity"
(ngModelChange)="onChangeQuantity($event)"
[showSpinner]="(loadingOnQuantityChangeById$ | async) === item?.id"
[disabled]="(loadingOnItemChangeById$ | async) === item?.id"
[range]="quantityRange$ | async"
></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>
}
<div class="item-thumbnail">
<a
[routerLink]="productSearchDetailsPath"
[queryParams]="{ main_qs: item?.product?.ean }"
>
@if (item?.product?.ean | productImage; as thumbnailUrl) {
<img loading="lazy" [src]="thumbnailUrl" [alt]="item?.product?.name" />
}
</a>
</div>
<div class="item-contributors">
@for (
contributor of contributors$ | async;
track contributor;
let last = $last
) {
<a
[routerLink]="productSearchResultsPath"
[queryParams]="{ main_qs: contributor, main_author: 'author' }"
(click)="$event?.stopPropagation()"
>
{{ contributor }}{{ last ? '' : ';' }}
</a>
}
</div>
<div
class="item-title font-bold text-h2 mb-4"
[class.text-h3]="item?.product?.name?.length >= 40 && isTablet"
[class.text-p1]="item?.product?.name?.length >= 50 || !isTablet"
[class.text-p2]="item?.product?.name?.length >= 60 && isTablet"
[class.text-p3]="item?.product?.name?.length >= 100"
>
<a
[routerLink]="productSearchDetailsPath"
[queryParams]="{ main_qs: item?.product?.ean }"
>{{ item?.product?.name }}</a
>
</div>
@if (item?.product?.format && item?.product?.formatDetail) {
<div class="item-format">
@if (item?.product?.format !== '--') {
<img
src="assets/images/Icon_{{ item?.product?.format }}.svg"
[alt]="item?.product?.formatDetail"
/>
}
{{ item?.product?.formatDetail }}
</div>
}
<div class="item-info text-p2">
<div class="mb-1">
{{ item?.product?.manufacturer | substr: 25 }} | {{ item?.product?.ean }}
</div>
<div class="mb-1">
{{ item?.product?.volume }}
@if (item?.product?.volume && item?.product?.publicationDate) {
<span>|</span>
}
{{ item?.product?.publicationDate | date }}
</div>
@if (notAvailable$ | async) {
<div>
<span class="text-brand item-date">Nicht verfügbar</span>
</div>
}
@if (refreshingAvailabilit$ | async) {
<shared-skeleton-loader class="w-40"></shared-skeleton-loader>
} @else {
@if (orderType === 'Abholung') {
<div
class="item-date"
[class.availability-changed]="estimatedShippingDateChanged$ | async"
>
Abholung ab {{ item?.availability?.estimatedShippingDate | date }}
</div>
}
@if (
orderType === 'Versand' ||
orderType === 'B2B-Versand' ||
orderType === 'DIG-Versand'
) {
<div
class="item-date"
[class.availability-changed]="estimatedShippingDateChanged$ | async"
>
@if (item?.availability?.estimatedDelivery) {
Zustellung zwischen
{{
(
item?.availability?.estimatedDelivery?.start | date: 'EEE, dd.MM.'
)?.replace('.', '')
}}
und
{{
(
item?.availability?.estimatedDelivery?.stop | date: 'EEE, dd.MM.'
)?.replace('.', '')
}}
} @else {
Versand {{ item?.availability?.estimatedShippingDate | date }}
}
</div>
}
}
@if (olaError$ | async) {
<div class="item-availability-message">Artikel nicht verfügbar</div>
}
</div>
<div class="item-price-stock flex flex-col">
<div class="text-p2 font-bold">
{{ item?.availability?.price?.value?.value | currency: 'EUR' : 'code' }}
</div>
<div class="text-p2 font-normal">
@if (!(isDummy$ | async)) {
<ui-quantity-dropdown
[ngModel]="item?.quantity"
(ngModelChange)="onChangeQuantity($event)"
[showSpinner]="(loadingOnQuantityChangeById$ | async) === item?.id"
[disabled]="(loadingOnItemChangeById$ | async) === item?.id"
[range]="quantityRange$ | async"
></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 {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
NgZone,
OnInit,
Output,
inject,
} from '@angular/core';
import { ApplicationService } from '@core/application';
import { EnvironmentService } from '@core/environment';
import { DomainAvailabilityService } from '@domain/availability';
import { DomainCheckoutService } from '@domain/checkout';
import { ComponentStore } from '@ngrx/component-store';
import { ProductCatalogNavigationService } from '@shared/services/navigation';
import { ItemType, ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
import { cloneDeep } from 'lodash';
import moment from 'moment';
import { combineLatest } from 'rxjs';
import { filter, first, map, shareReplay, switchMap } from 'rxjs/operators';
export interface ShoppingCartItemComponentState {
item: ShoppingCartItemDTO;
orderType: string;
loadingOnItemChangeById?: number;
loadingOnQuantityChangeById?: number;
refreshingAvailability: boolean;
sscChanged: boolean;
sscTextChanged: boolean;
estimatedShippingDateChanged: boolean;
}
@Component({
selector: 'page-shopping-cart-item',
templateUrl: 'shopping-cart-item.component.html',
styleUrls: ['shopping-cart-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class ShoppingCartItemComponent extends ComponentStore<ShoppingCartItemComponentState> implements OnInit {
private _zone = inject(NgZone);
@Output() changeItem = new EventEmitter<{ shoppingCartItem: ShoppingCartItemDTO }>();
@Output() changeDummyItem = new EventEmitter<{ shoppingCartItem: ShoppingCartItemDTO }>();
@Output() changeQuantity = new EventEmitter<{ shoppingCartItem: ShoppingCartItemDTO; quantity: number }>();
@Input()
get item() {
return this.get((s) => s.item);
}
set item(item: ShoppingCartItemDTO) {
if (this.item !== item) {
this.patchState({ item });
}
}
readonly item$ = this.select((s) => s.item);
readonly contributors$ = this.item$.pipe(
map((item) => item?.product?.contributors?.split(';').map((val) => val.trim())),
);
@Input()
get orderType() {
return this.get((s) => s.orderType);
}
set orderType(orderType: string) {
if (this.orderType !== orderType) {
this.patchState({ orderType });
}
}
readonly orderType$ = this.select((s) => s.orderType);
@Input()
get loadingOnItemChangeById() {
return this.get((s) => s.loadingOnItemChangeById);
}
set loadingOnItemChangeById(loadingOnItemChangeById: number) {
if (this.loadingOnItemChangeById !== loadingOnItemChangeById) {
this.patchState({ loadingOnItemChangeById });
}
}
readonly loadingOnItemChangeById$ = this.select((s) => s.loadingOnItemChangeById).pipe(shareReplay());
@Input()
get loadingOnQuantityChangeById() {
return this.get((s) => s.loadingOnQuantityChangeById);
}
set loadingOnQuantityChangeById(loadingOnQuantityChangeById: number) {
if (this.loadingOnQuantityChangeById !== loadingOnQuantityChangeById) {
this.patchState({ loadingOnQuantityChangeById });
}
}
readonly loadingOnQuantityChangeById$ = this.select((s) => s.loadingOnQuantityChangeById).pipe(shareReplay());
@Input()
quantityError: string;
isDummy$ = this.item$.pipe(
map((item) => item?.availability?.supplyChannel === 'MANUALLY'),
shareReplay(),
);
hasOrderType$ = this.orderType$.pipe(
map((orderType) => orderType !== undefined),
shareReplay(),
);
canEdit$ = combineLatest([this.isDummy$, this.hasOrderType$, this.item$]).pipe(
map(([isDummy, hasOrderType, item]) => {
if (item.itemType === (66560 as ItemType)) {
return false;
}
return isDummy || hasOrderType;
}),
);
quantityRange$ = combineLatest([this.orderType$, this.item$]).pipe(
map(([orderType, item]) => (orderType === 'Rücklage' ? item.availability?.inStock : 999)),
);
isDownloadAvailable$ = combineLatest([this.item$, this.orderType$]).pipe(
filter(([_, orderType]) => orderType === 'Download'),
switchMap(([item]) =>
this.availabilityService.getDownloadAvailability({
item: { ean: item.product.ean, price: item.availability.price, itemId: +item.product.catalogProductNumber },
}),
),
map((availability) => availability && this.availabilityService.isAvailable({ availability })),
);
olaError$ = this.checkoutService
.getOlaErrors({ processId: this.application.activatedProcessId })
.pipe(map((ids) => ids?.find((id) => id === this.item.id)));
get productSearchResultsPath() {
return this._productNavigationService.getArticleSearchResultsPath(this.application.activatedProcessId).path;
}
get productSearchDetailsPath() {
return this._productNavigationService.getArticleDetailsPathByEan({
processId: this.application.activatedProcessId,
ean: this.item?.product?.ean,
}).path;
}
get isTablet() {
return this._environment.matchTablet();
}
refreshingAvailabilit$ = this.select((s) => s.refreshingAvailability);
sscChanged$ = this.select((s) => s.sscChanged || s.sscTextChanged);
estimatedShippingDateChanged$ = this.select((s) => s.estimatedShippingDateChanged);
notAvailable$ = this.item$.pipe(
map((item) => {
const availability = item?.availability;
if (availability.availabilityType === 0) {
return false;
}
if (availability.inStock && item.quantity > availability.inStock) {
return true;
}
return !this.availabilityService.isAvailable({ availability });
}),
);
constructor(
private availabilityService: DomainAvailabilityService,
private checkoutService: DomainCheckoutService,
public application: ApplicationService,
private _productNavigationService: ProductCatalogNavigationService,
private _environment: EnvironmentService,
private _cdr: ChangeDetectorRef,
) {
super({
item: undefined,
orderType: '',
refreshingAvailability: false,
sscChanged: false,
sscTextChanged: false,
estimatedShippingDateChanged: false,
});
}
ngOnInit() {}
async onChangeItem() {
const isDummy = await this.isDummy$.pipe(first()).toPromise();
isDummy
? this.changeDummyItem.emit({ shoppingCartItem: this.item })
: this.changeItem.emit({ shoppingCartItem: this.item });
}
onChangeQuantity(quantity: number) {
this.changeQuantity.emit({ shoppingCartItem: this.item, quantity });
}
async refreshAvailability() {
const currentAvailability = cloneDeep(this.item.availability);
try {
this.patchRefreshingAvailability(true);
this._cdr.markForCheck();
const availability = await this.checkoutService.refreshAvailability({
processId: this.application.activatedProcessId,
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) {}
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();
}
}
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
NgZone,
OnInit,
Output,
inject,
} from '@angular/core';
import { ApplicationService } from '@core/application';
import { EnvironmentService } from '@core/environment';
import { DomainAvailabilityService } from '@domain/availability';
import { DomainCheckoutService } from '@domain/checkout';
import { ComponentStore } from '@ngrx/component-store';
import { ProductCatalogNavigationService } from '@shared/services/navigation';
import { ItemType, ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
import { cloneDeep } from 'lodash';
import moment from 'moment';
import { combineLatest } from 'rxjs';
import { filter, first, map, shareReplay, switchMap } from 'rxjs/operators';
export interface ShoppingCartItemComponentState {
item: ShoppingCartItemDTO;
orderType: string;
loadingOnItemChangeById?: number;
loadingOnQuantityChangeById?: number;
refreshingAvailability: boolean;
sscChanged: boolean;
sscTextChanged: boolean;
estimatedShippingDateChanged: boolean;
}
@Component({
selector: 'page-shopping-cart-item',
templateUrl: 'shopping-cart-item.component.html',
styleUrls: ['shopping-cart-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class ShoppingCartItemComponent
extends ComponentStore<ShoppingCartItemComponentState>
implements OnInit
{
private _zone = inject(NgZone);
@Output() changeItem = new EventEmitter<{
shoppingCartItem: ShoppingCartItemDTO;
}>();
@Output() changeDummyItem = new EventEmitter<{
shoppingCartItem: ShoppingCartItemDTO;
}>();
@Output() changeQuantity = new EventEmitter<{
shoppingCartItem: ShoppingCartItemDTO;
quantity: number;
}>();
@Input()
get item() {
return this.get((s) => s.item);
}
set item(item: ShoppingCartItemDTO) {
if (this.item !== item) {
this.patchState({ item });
}
}
readonly item$ = this.select((s) => s.item);
readonly contributors$ = this.item$.pipe(
map((item) =>
item?.product?.contributors?.split(';').map((val) => val.trim()),
),
);
get showLoyaltyValue() {
return this.item?.loyalty?.value > 0;
}
@Input()
get orderType() {
return this.get((s) => s.orderType);
}
set orderType(orderType: string) {
if (this.orderType !== orderType) {
this.patchState({ orderType });
}
}
readonly orderType$ = this.select((s) => s.orderType);
@Input()
get loadingOnItemChangeById() {
return this.get((s) => s.loadingOnItemChangeById);
}
set loadingOnItemChangeById(loadingOnItemChangeById: number) {
if (this.loadingOnItemChangeById !== loadingOnItemChangeById) {
this.patchState({ loadingOnItemChangeById });
}
}
readonly loadingOnItemChangeById$ = this.select(
(s) => s.loadingOnItemChangeById,
).pipe(shareReplay());
@Input()
get loadingOnQuantityChangeById() {
return this.get((s) => s.loadingOnQuantityChangeById);
}
set loadingOnQuantityChangeById(loadingOnQuantityChangeById: number) {
if (this.loadingOnQuantityChangeById !== loadingOnQuantityChangeById) {
this.patchState({ loadingOnQuantityChangeById });
}
}
readonly loadingOnQuantityChangeById$ = this.select(
(s) => s.loadingOnQuantityChangeById,
).pipe(shareReplay());
@Input()
quantityError: string;
isDummy$ = this.item$.pipe(
map((item) => item?.availability?.supplyChannel === 'MANUALLY'),
shareReplay(),
);
hasOrderType$ = this.orderType$.pipe(
map((orderType) => orderType !== undefined),
shareReplay(),
);
canEdit$ = combineLatest([
this.isDummy$,
this.hasOrderType$,
this.item$,
]).pipe(
map(([isDummy, hasOrderType, item]) => {
if (item.itemType === (66560 as ItemType)) {
return false;
}
return isDummy || hasOrderType;
}),
);
quantityRange$ = combineLatest([this.orderType$, this.item$]).pipe(
map(([orderType, item]) =>
orderType === 'Rücklage' ? item.availability?.inStock : 999,
),
);
isDownloadAvailable$ = combineLatest([this.item$, this.orderType$]).pipe(
filter(([, orderType]) => orderType === 'Download'),
switchMap(([item]) =>
this.availabilityService.getDownloadAvailability({
item: {
ean: item.product.ean,
price: item.availability.price,
itemId: +item.product.catalogProductNumber,
},
}),
),
map(
(availability) =>
availability && this.availabilityService.isAvailable({ availability }),
),
);
olaError$ = this.checkoutService
.getOlaErrors({ processId: this.application.activatedProcessId })
.pipe(map((ids) => ids?.find((id) => id === this.item.id)));
get productSearchResultsPath() {
return this._productNavigationService.getArticleSearchResultsPath(
this.application.activatedProcessId,
).path;
}
get productSearchDetailsPath() {
return this._productNavigationService.getArticleDetailsPathByEan({
processId: this.application.activatedProcessId,
ean: this.item?.product?.ean,
}).path;
}
get isTablet() {
return this._environment.matchTablet();
}
refreshingAvailabilit$ = this.select((s) => s.refreshingAvailability);
sscChanged$ = this.select((s) => s.sscChanged || s.sscTextChanged);
estimatedShippingDateChanged$ = this.select(
(s) => s.estimatedShippingDateChanged,
);
notAvailable$ = this.item$.pipe(
map((item) => {
const availability = item?.availability;
if (availability.availabilityType === 0) {
return false;
}
if (availability.inStock && item.quantity > availability.inStock) {
return true;
}
return !this.availabilityService.isAvailable({ availability });
}),
);
constructor(
private availabilityService: DomainAvailabilityService,
private checkoutService: DomainCheckoutService,
public application: ApplicationService,
private _productNavigationService: ProductCatalogNavigationService,
private _environment: EnvironmentService,
private _cdr: ChangeDetectorRef,
) {
super({
item: undefined,
orderType: '',
refreshingAvailability: false,
sscChanged: false,
sscTextChanged: false,
estimatedShippingDateChanged: false,
});
}
ngOnInit() {
// Component initialization
}
async onChangeItem() {
const isDummy = await this.isDummy$.pipe(first()).toPromise();
if (isDummy) {
this.changeDummyItem.emit({ shoppingCartItem: this.item });
} else {
this.changeItem.emit({ shoppingCartItem: this.item });
}
}
onChangeQuantity(quantity: number) {
this.changeQuantity.emit({ shoppingCartItem: this.item, quantity });
}
async refreshAvailability() {
const currentAvailability = cloneDeep(this.item.availability);
try {
this.patchRefreshingAvailability(true);
this._cdr.markForCheck();
const availability = await this.checkoutService.refreshAvailability({
processId: this.application.activatedProcessId,
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,
isObservable,
} from 'rxjs';
import { map, switchMap, tap } from 'rxjs/operators';
import { map, tap } from 'rxjs/operators';
import { TabService } from '@isa/core/tabs';
import { CheckoutMetadataService } from '@isa/checkout/data-access';
@Component({
selector: 'shell-process-bar-item',
@@ -39,9 +40,22 @@ export class ShellProcessBarItemComponent
implements OnInit, OnDestroy, OnChanges
{
#tabService = inject(TabService);
#checkoutMetadataService = inject(CheckoutMetadataService);
tab = computed(() => this.#tabService.entityMap()[this.process().id]);
tabEffect = effect(() => {
console.log('tabEffect', this.tab());
});
shoppingCartId = computed(() => {
return this.#checkoutMetadataService.getShoppingCartId(this.process().id);
});
shoppingCartIdEffect = effect(() => {
console.log('shoppingCartIdEffect', this.shoppingCartId());
});
private _process$ = new BehaviorSubject<ApplicationProcess>(undefined);
process$ = this._process$.asObservable();
@@ -91,7 +105,7 @@ export class ShellProcessBarItemComponent
latestBreadcrumb$: Observable<Breadcrumb> = NEVER;
routerLink$: Observable<string[] | any[]> = NEVER;
routerLink$: Observable<string[] | unknown[]> = NEVER;
queryParams$: Observable<object> = NEVER;

View File

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

View File

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

View File

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

View File

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

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