Merge branch 'release/4.3'

This commit is contained in:
Nino
2025-11-11 21:56:00 +01:00
1517 changed files with 143336 additions and 17440 deletions

View File

@@ -1,4 +1,8 @@
import type { Preview } from '@storybook/angular';
import { registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
registerLocaleData(localeDe);
const preview: Preview = {
tags: ['autodocs'],

View File

@@ -1,6 +1,5 @@
import { inject, isDevMode, NgModule } from '@angular/core';
import { Location } from '@angular/common';
import { RouterModule, Routes, Router } from '@angular/router';
import { isDevMode, NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import {
CanActivateCartGuard,
CanActivateCartWithProcessIdGuard,
@@ -11,7 +10,6 @@ import {
CanActivateGoodsInGuard,
CanActivateProductGuard,
CanActivateProductWithProcessIdGuard,
CanActivateRemissionGuard,
CanActivateTaskCalendarGuard,
IsAuthenticatedGuard,
} from './guards';
@@ -31,12 +29,7 @@ import {
ActivateProcessIdWithConfigKeyGuard,
} from './guards/activate-process-id.guard';
import { MatomoRouteData } from 'ngx-matomo-client';
import {
tabResolverFn,
TabService,
TabNavigationService,
processResolverFn,
} from '@isa/core/tabs';
import { tabResolverFn, processResolverFn } from '@isa/core/tabs';
import { provideScrollPositionRestoration } from '@isa/utils/scroll-position';
const routes: Routes = [
@@ -193,9 +186,31 @@ const routes: Routes = [
children: [
{
path: 'reward',
loadChildren: () =>
import('@isa/checkout/feature/reward-catalog').then((m) => m.routes),
children: [
{
path: '',
loadChildren: () =>
import('@isa/checkout/feature/reward-catalog').then(
(m) => m.routes,
),
},
{
path: 'cart',
loadChildren: () =>
import('@isa/checkout/feature/reward-shopping-cart').then(
(m) => m.routes,
),
},
{
path: 'order-confirmation',
loadChildren: () =>
import('@isa/checkout/feature/reward-order-confirmation').then(
(m) => m.routes,
),
},
],
},
{
path: 'return',
loadChildren: () =>
@@ -242,9 +257,4 @@ if (isDevMode()) {
exports: [RouterModule],
providers: [provideScrollPositionRestoration()],
})
export class AppRoutingModule {
constructor() {
// Loading TabNavigationService to ensure tab state is synced with tab location
inject(TabNavigationService);
}
}
export class AppRoutingModule {}

View File

@@ -12,6 +12,7 @@ import {
NgModule,
inject,
provideAppInitializer,
signal,
} from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
@@ -78,8 +79,15 @@ import {
ConsoleLogSink,
logger as loggerFactory,
} from '@isa/core/logging';
import { IDBStorageProvider, UserStorageProvider } from '@isa/core/storage';
import {
IDBStorageProvider,
provideUserSubFactory,
UserStorageProvider,
} from '@isa/core/storage';
import { Store } from '@ngrx/store';
import { OAuthService } from 'angular-oauth2-oidc';
import z from 'zod';
import { TabNavigationService } from '@isa/core/tabs';
registerLocaleData(localeDe, localeDeExtra);
registerLocaleData(localeDe, 'de', localeDeExtra);
@@ -124,11 +132,12 @@ 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...';
logger.info('Performing login');
const strategy = injector.get(LoginStrategy);
await strategy.login();
return;
}
statusElement.innerHTML = 'Native Container wird initialisiert...';
@@ -169,6 +178,8 @@ export function _appInitializerFactory(config: Config, injector: Injector) {
});
logger.info('Application initialization completed');
// Inject tab navigation service to initialize it
injector.get(TabNavigationService).init();
} catch (error) {
logger.error('Application initialization failed', error as Error, () => ({
message: (error as Error).message,
@@ -217,6 +228,31 @@ export function _notificationsHubOptionsFactory(
return options;
}
const USER_SUB_FACTORY = () => {
const _logger = loggerFactory(() => ({
context: 'USER_SUB',
}));
const auth = inject(OAuthService);
const claims = auth.getIdentityClaims();
if (!claims || typeof claims !== 'object' || !('sub' in claims)) {
const err = new Error('No valid identity claims found. User is anonymous.');
_logger.error(err.message);
throw err;
}
const validation = z.string().safeParse(claims['sub']);
if (!validation.success) {
const err = new Error('Invalid "sub" claim in identity claims.');
_logger.error(err.message, { claims });
throw err;
}
return signal(validation.data);
};
@NgModule({
declarations: [AppComponent, MainComponent],
bootstrap: [AppComponent],
@@ -290,6 +326,7 @@ export function _notificationsHubOptionsFactory(
provide: DEFAULT_CURRENCY_CODE,
useValue: 'EUR',
},
provideUserSubFactory(USER_SUB_FACTORY),
],
})
export class AppModule {}

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

@@ -7,10 +7,8 @@ import {
HttpErrorResponse,
} from '@angular/common/http';
import { from, NEVER, Observable, throwError } from 'rxjs';
import { UiModalService } from '@ui/modal';
import { catchError, filter, mergeMap, takeUntil } from 'rxjs/operators';
import { AuthService, LoginStrategy } from '@core/auth';
import { LogLevel } from '@core/logger';
import { injectOnline$ } from '../services/network-status.service';
import { logger } from '@isa/core/logging';

View File

@@ -142,7 +142,6 @@ export class ApplicationServiceAdapter extends ApplicationService {
patchProcessData(processId: number, data: Record<string, unknown>): void {
const currentProcess = this.#tabService.entityMap()[processId];
const currentData: TabMetadata =
(currentProcess?.metadata?.['process_data'] as TabMetadata) ?? {};

View File

@@ -1,5 +1,5 @@
import { coerceArray } from '@angular/cdk/coercion';
import { inject, Injectable } from '@angular/core';
import { Injectable } from '@angular/core';
import { Config } from '@core/config';
import { isNullOrUndefined } from '@utils/common';
import { AuthConfig, OAuthService } from 'angular-oauth2-oidc';
@@ -144,6 +144,7 @@ export class AuthService {
if (isNullOrUndefined(token)) {
return null;
}
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');

View File

@@ -3,31 +3,49 @@ import { ActionHandler } from './action-handler.interface';
import { CommandService } from './command.service';
import { FEATURE_ACTION_HANDLERS, ROOT_ACTION_HANDLERS } from './tokens';
export function provideActionHandlers(actionHandlers: Type<ActionHandler>[]): Provider[] {
export function provideActionHandlers(
actionHandlers: Type<ActionHandler>[],
): Provider[] {
return [
CommandService,
actionHandlers.map((handler) => ({ provide: FEATURE_ACTION_HANDLERS, useClass: handler, multi: true })),
actionHandlers.map((handler) => ({
provide: FEATURE_ACTION_HANDLERS,
useClass: handler,
multi: true,
})),
];
}
@NgModule({})
export class CoreCommandModule {
static forRoot(actionHandlers: Type<ActionHandler>[]): ModuleWithProviders<CoreCommandModule> {
static forRoot(
actionHandlers: Type<ActionHandler>[],
): ModuleWithProviders<CoreCommandModule> {
return {
ngModule: CoreCommandModule,
providers: [
CommandService,
actionHandlers.map((handler) => ({ provide: ROOT_ACTION_HANDLERS, useClass: handler, multi: true })),
actionHandlers.map((handler) => ({
provide: ROOT_ACTION_HANDLERS,
useClass: handler,
multi: true,
})),
],
};
}
static forChild(actionHandlers: Type<ActionHandler>[]): ModuleWithProviders<CoreCommandModule> {
static forChild(
actionHandlers: Type<ActionHandler>[],
): ModuleWithProviders<CoreCommandModule> {
return {
ngModule: CoreCommandModule,
providers: [
CommandService,
actionHandlers.map((handler) => ({ provide: FEATURE_ACTION_HANDLERS, useClass: handler, multi: true })),
actionHandlers.map((handler) => ({
provide: FEATURE_ACTION_HANDLERS,
useClass: handler,
multi: true,
})),
],
};
}

View File

@@ -15,10 +15,13 @@ export class CommandService {
for (const action of actions) {
const handler = this.getActionHandler(action);
if (!handler) {
console.error('CommandService.handleCommand', 'Action Handler does not exist', { action });
console.error(
'CommandService.handleCommand',
'Action Handler does not exist',
{ action },
);
throw new Error('Action Handler does not exist');
}
data = await handler.handler(data, this);
}
return data;
@@ -29,10 +32,18 @@ export class CommandService {
}
getActionHandler(action: string): ActionHandler | undefined {
const featureActionHandlers: ActionHandler[] = this.injector.get(FEATURE_ACTION_HANDLERS, []);
const rootActionHandlers: ActionHandler[] = this.injector.get(ROOT_ACTION_HANDLERS, []);
const featureActionHandlers: ActionHandler[] = this.injector.get(
FEATURE_ACTION_HANDLERS,
[],
);
const rootActionHandlers: ActionHandler[] = this.injector.get(
ROOT_ACTION_HANDLERS,
[],
);
let handler = [...featureActionHandlers, ...rootActionHandlers].find((handler) => handler.action === action);
let handler = [...featureActionHandlers, ...rootActionHandlers].find(
(handler) => handler.action === action,
);
if (this._parent && !handler) {
handler = this._parent.getActionHandler(action);

View File

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,11 @@ export const setShoppingCart = createAction(
props<{ processId: number; shoppingCart: ShoppingCartDTO }>(),
);
export const setShoppingCartByShoppingCartId = createAction(
`${prefix} Set Shopping Cart By Shopping Cart Id`,
props<{ shoppingCartId: number; shoppingCart: ShoppingCartDTO }>(),
);
export const setCheckout = createAction(
`${prefix} Set Checkout`,
props<{ processId: number; checkout: CheckoutDTO }>(),

View File

@@ -1,207 +1,311 @@
import { createReducer, on } from '@ngrx/store';
import { initialCheckoutState, storeCheckoutAdapter } from './domain-checkout.state';
import * as DomainCheckoutActions from './domain-checkout.actions';
import { Dictionary } from '@ngrx/entity';
import { CheckoutEntity } from './defs/checkout.entity';
import { isNullOrUndefined } from '@utils/common';
const _domainCheckoutReducer = createReducer(
initialCheckoutState,
on(DomainCheckoutActions.setShoppingCart, (s, { processId, shoppingCart }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
const addedShoppingCartItems =
shoppingCart?.items
?.filter((item) => !entity.shoppingCart?.items?.find((i) => i.id === item.id))
?.map((item) => item.data) ?? [];
entity.shoppingCart = shoppingCart;
entity.itemAvailabilityTimestamp = entity.itemAvailabilityTimestamp ? { ...entity.itemAvailabilityTimestamp } : {};
const now = Date.now();
for (let shoppingCartItem of addedShoppingCartItems) {
if (shoppingCartItem.features?.orderType) {
entity.itemAvailabilityTimestamp[`${shoppingCartItem.id}_${shoppingCartItem.features.orderType}`] = now;
}
}
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setCheckout, (s, { processId, checkout }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.checkout = checkout;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setBuyerCommunicationDetails, (s, { processId, email, mobile }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
const communicationDetails = { ...entity.buyer.communicationDetails };
communicationDetails.email = email || communicationDetails.email;
communicationDetails.mobile = mobile || communicationDetails.mobile;
entity.buyer = {
...entity.buyer,
communicationDetails,
};
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setNotificationChannels, (s, { processId, notificationChannels }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
return storeCheckoutAdapter.setOne({ ...entity, notificationChannels }, s);
}),
on(DomainCheckoutActions.setCheckoutDestination, (s, { processId, destination }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.checkout = {
...entity.checkout,
destinations: entity.checkout.destinations.map((dest) => {
if (dest.id === destination.id) {
return { ...dest, ...destination };
}
return { ...dest };
}),
};
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setShippingAddress, (s, { processId, shippingAddress }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.shippingAddress = shippingAddress;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setBuyer, (s, { processId, buyer }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.buyer = buyer;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setPayer, (s, { processId, payer }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.payer = payer;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setSpecialComment, (s, { processId, agentComment }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.specialComment = agentComment;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.removeCheckoutWithProcessId, (s, { processId }) => {
return storeCheckoutAdapter.removeOne(processId, s);
}),
on(DomainCheckoutActions.setOrders, (s, { orders }) => ({ ...s, orders: [...s.orders, ...orders] })),
on(DomainCheckoutActions.updateOrderItem, (s, { item }) => {
const orders = [...s.orders];
const orderToUpdate = orders?.find((order) => order.items?.find((i) => i.id === item?.id));
const orderToUpdateIndex = orders?.indexOf(orderToUpdate);
const orderItemToUpdate = orderToUpdate?.items?.find((i) => i.id === item?.id);
const orderItemToUpdateIndex = orderToUpdate?.items?.indexOf(orderItemToUpdate);
const items = [...orderToUpdate?.items];
items[orderItemToUpdateIndex] = item;
orders[orderToUpdateIndex] = {
...orderToUpdate,
items: [...items],
};
return { ...s, orders: [...orders] };
}),
on(DomainCheckoutActions.removeAllOrders, (s) => ({
...s,
orders: [],
})),
on(DomainCheckoutActions.setOlaError, (s, { processId, olaErrorIds }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.olaErrorIds = olaErrorIds;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setCustomer, (s, { processId, customer }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
entity.customer = customer;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(
DomainCheckoutActions.addShoppingCartItemAvailabilityToHistory,
(s, { processId, shoppingCartItemId, availability }) => {
const entity = getOrCreateCheckoutEntity({ processId, entities: s.entities });
const itemAvailabilityTimestamp = entity?.itemAvailabilityTimestamp
? { ...entity?.itemAvailabilityTimestamp }
: {};
const item = entity?.shoppingCart?.items?.find((i) => i.id === shoppingCartItemId)?.data;
if (!item?.features?.orderType) return s;
itemAvailabilityTimestamp[`${item.id}_${item?.features?.orderType}`] = Date.now();
entity.itemAvailabilityTimestamp = itemAvailabilityTimestamp;
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(
DomainCheckoutActions.addShoppingCartItemAvailabilityToHistoryByShoppingCartId,
(s, { shoppingCartId, shoppingCartItemId, availability }) => {
const entity = getCheckoutEntityByShoppingCartId({ shoppingCartId, entities: s.entities });
const itemAvailabilityTimestamp = entity?.itemAvailabilityTimestamp
? { ...entity?.itemAvailabilityTimestamp }
: {};
const item = entity?.shoppingCart?.items?.find((i) => i.id === shoppingCartItemId)?.data;
if (!item?.features?.orderType) return s;
itemAvailabilityTimestamp[`${item.id}_${item?.features?.orderType}`] = Date.now();
entity.itemAvailabilityTimestamp = itemAvailabilityTimestamp;
return storeCheckoutAdapter.setOne(entity, s);
},
),
);
export function domainCheckoutReducer(state, action) {
return _domainCheckoutReducer(state, action);
}
function getOrCreateCheckoutEntity({
entities,
processId,
}: {
entities: Dictionary<CheckoutEntity>;
processId: number;
}): CheckoutEntity {
let entity = entities[processId];
if (isNullOrUndefined(entity)) {
return {
processId,
checkout: undefined,
shoppingCart: undefined,
shippingAddress: undefined,
orders: [],
payer: undefined,
buyer: undefined,
specialComment: '',
notificationChannels: 0,
olaErrorIds: [],
customer: undefined,
// availabilityHistory: [],
itemAvailabilityTimestamp: {},
};
}
return { ...entity };
}
function getCheckoutEntityByShoppingCartId({
entities,
shoppingCartId,
}: {
entities: Dictionary<CheckoutEntity>;
shoppingCartId: number;
}): CheckoutEntity {
return Object.values(entities).find((entity) => entity.shoppingCart?.id === shoppingCartId);
}
import { createReducer, on } from '@ngrx/store';
import {
initialCheckoutState,
storeCheckoutAdapter,
} from './domain-checkout.state';
import * as DomainCheckoutActions from './domain-checkout.actions';
import { Dictionary } from '@ngrx/entity';
import { CheckoutEntity } from './defs/checkout.entity';
import { isNullOrUndefined } from '@utils/common';
const _domainCheckoutReducer = createReducer(
initialCheckoutState,
on(
DomainCheckoutActions.setShoppingCart,
(s, { processId, shoppingCart }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
const addedShoppingCartItems =
shoppingCart?.items
?.filter(
(item) =>
!entity.shoppingCart?.items?.find((i) => i.id === item.id),
)
?.map((item) => item.data) ?? [];
entity.shoppingCart = shoppingCart;
entity.itemAvailabilityTimestamp = entity.itemAvailabilityTimestamp
? { ...entity.itemAvailabilityTimestamp }
: {};
const now = Date.now();
for (let shoppingCartItem of addedShoppingCartItems) {
if (shoppingCartItem.features?.orderType) {
entity.itemAvailabilityTimestamp[
`${shoppingCartItem.id}_${shoppingCartItem.features.orderType}`
] = now;
}
}
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(
DomainCheckoutActions.setShoppingCartByShoppingCartId,
(s, { shoppingCartId, shoppingCart }) => {
let entity = getCheckoutEntityByShoppingCartId({
shoppingCartId,
entities: s.entities,
});
if (!entity) {
// No entity found for this shoppingCartId, cannot update
return s;
}
entity = { ...entity, shoppingCart };
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(DomainCheckoutActions.setCheckout, (s, { processId, checkout }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.checkout = checkout;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(
DomainCheckoutActions.setBuyerCommunicationDetails,
(s, { processId, email, mobile }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
const communicationDetails = { ...entity.buyer.communicationDetails };
communicationDetails.email = email || communicationDetails.email;
communicationDetails.mobile = mobile || communicationDetails.mobile;
entity.buyer = {
...entity.buyer,
communicationDetails,
};
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(
DomainCheckoutActions.setNotificationChannels,
(s, { processId, notificationChannels }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
return storeCheckoutAdapter.setOne(
{ ...entity, notificationChannels },
s,
);
},
),
on(
DomainCheckoutActions.setCheckoutDestination,
(s, { processId, destination }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.checkout = {
...entity.checkout,
destinations: entity.checkout.destinations.map((dest) => {
if (dest.id === destination.id) {
return { ...dest, ...destination };
}
return { ...dest };
}),
};
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(
DomainCheckoutActions.setShippingAddress,
(s, { processId, shippingAddress }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.shippingAddress = shippingAddress;
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(DomainCheckoutActions.setBuyer, (s, { processId, buyer }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.buyer = buyer;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setPayer, (s, { processId, payer }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.payer = payer;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(
DomainCheckoutActions.setSpecialComment,
(s, { processId, agentComment }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.specialComment = agentComment;
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(DomainCheckoutActions.removeCheckoutWithProcessId, (s, { processId }) => {
return storeCheckoutAdapter.removeOne(processId, s);
}),
on(DomainCheckoutActions.setOrders, (s, { orders }) => ({
...s,
orders: [...s.orders, ...orders],
})),
on(DomainCheckoutActions.updateOrderItem, (s, { item }) => {
const orders = [...s.orders];
const orderToUpdate = orders?.find((order) =>
order.items?.find((i) => i.id === item?.id),
);
const orderToUpdateIndex = orders?.indexOf(orderToUpdate);
const orderItemToUpdate = orderToUpdate?.items?.find(
(i) => i.id === item?.id,
);
const orderItemToUpdateIndex =
orderToUpdate?.items?.indexOf(orderItemToUpdate);
const items = [...(orderToUpdate?.items ?? [])];
items[orderItemToUpdateIndex] = item;
orders[orderToUpdateIndex] = {
...orderToUpdate,
items: [...items],
};
return { ...s, orders: [...orders] };
}),
on(DomainCheckoutActions.removeAllOrders, (s) => ({
...s,
orders: [],
})),
on(DomainCheckoutActions.setOlaError, (s, { processId, olaErrorIds }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.olaErrorIds = olaErrorIds;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(DomainCheckoutActions.setCustomer, (s, { processId, customer }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
entity.customer = customer;
return storeCheckoutAdapter.setOne(entity, s);
}),
on(
DomainCheckoutActions.addShoppingCartItemAvailabilityToHistory,
(s, { processId, shoppingCartItemId, availability }) => {
const entity = getOrCreateCheckoutEntity({
processId,
entities: s.entities,
});
const itemAvailabilityTimestamp = entity?.itemAvailabilityTimestamp
? { ...entity?.itemAvailabilityTimestamp }
: {};
const item = entity?.shoppingCart?.items?.find(
(i) => i.id === shoppingCartItemId,
)?.data;
if (!item?.features?.orderType) return s;
itemAvailabilityTimestamp[`${item.id}_${item?.features?.orderType}`] =
Date.now();
entity.itemAvailabilityTimestamp = itemAvailabilityTimestamp;
return storeCheckoutAdapter.setOne(entity, s);
},
),
on(
DomainCheckoutActions.addShoppingCartItemAvailabilityToHistoryByShoppingCartId,
(s, { shoppingCartId, shoppingCartItemId, availability }) => {
const entity = getCheckoutEntityByShoppingCartId({
shoppingCartId,
entities: s.entities,
});
const itemAvailabilityTimestamp = entity?.itemAvailabilityTimestamp
? { ...entity?.itemAvailabilityTimestamp }
: {};
const item = entity?.shoppingCart?.items?.find(
(i) => i.id === shoppingCartItemId,
)?.data;
if (!item?.features?.orderType) return s;
itemAvailabilityTimestamp[`${item.id}_${item?.features?.orderType}`] =
Date.now();
entity.itemAvailabilityTimestamp = itemAvailabilityTimestamp;
return storeCheckoutAdapter.setOne(entity, s);
},
),
);
export function domainCheckoutReducer(state, action) {
return _domainCheckoutReducer(state, action);
}
function getOrCreateCheckoutEntity({
entities,
processId,
}: {
entities: Dictionary<CheckoutEntity>;
processId: number;
}): CheckoutEntity {
let entity = entities[processId];
if (isNullOrUndefined(entity)) {
return {
processId,
checkout: undefined,
shoppingCart: undefined,
shippingAddress: undefined,
orders: [],
payer: undefined,
buyer: undefined,
specialComment: '',
notificationChannels: 0,
olaErrorIds: [],
customer: undefined,
// availabilityHistory: [],
itemAvailabilityTimestamp: {},
};
}
return { ...entity };
}
function getCheckoutEntityByShoppingCartId({
entities,
shoppingCartId,
}: {
entities: Dictionary<CheckoutEntity>;
shoppingCartId: number;
}): CheckoutEntity {
return Object.values(entities).find(
(entity) => entity.shoppingCart?.id === shoppingCartId,
);
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -20,9 +20,15 @@ export class PrintShippingNoteActionHandler extends ActionHandler<OrderItemsCont
async printShippingNoteHelper(printer: string, receipts: ReceiptDTO[]) {
try {
for (const group of groupBy(receipts, (receipt) => receipt?.buyer?.buyerNumber)) {
for (const group of groupBy(
receipts,
(receipt) => receipt?.buyer?.buyerNumber,
)) {
await this.domainPrinterService
.printShippingNote({ printer, receipts: group?.items?.map((r) => r?.id) })
.printShippingNote({
printer,
receipts: group?.items?.map((r) => r?.id),
})
.toPromise();
}
return {
@@ -38,7 +44,9 @@ export class PrintShippingNoteActionHandler extends ActionHandler<OrderItemsCont
}
async handler(data: OrderItemsContext): Promise<OrderItemsContext> {
const printerList = await this.domainPrinterService.getAvailableLabelPrinters().toPromise();
const printerList = await this.domainPrinterService
.getAvailableLabelPrinters()
.toPromise();
const receipts = data?.receipts?.filter((r) => r?.receiptType & 1);
let printer: Printer;
@@ -53,7 +61,8 @@ export class PrintShippingNoteActionHandler extends ActionHandler<OrderItemsCont
data: {
printImmediately: !this._environmentSerivce.matchTablet(),
printerType: 'Label',
print: async (printer) => await this.printShippingNoteHelper(printer, receipts),
print: async (printer) =>
await this.printShippingNoteHelper(printer, receipts),
} as PrintModalData,
})
.afterClosed$.toPromise();

View File

@@ -12,7 +12,13 @@ import {
ShoppingCartItemDTO,
} from '@generated/swagger/checkout-api';
import { DomainCheckoutService } from '@domain/checkout';
import { catchError, mergeMap, switchMap, tap, withLatestFrom } from 'rxjs/operators';
import {
catchError,
mergeMap,
switchMap,
tap,
withLatestFrom,
} from 'rxjs/operators';
import {
BranchService,
DisplayOrderDTO,
@@ -40,7 +46,10 @@ export interface KulturpassOrderModalState {
}
@Injectable()
export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderModalState> implements OnStoreInit {
export class KulturpassOrderModalStore
extends ComponentStore<KulturpassOrderModalState>
implements OnStoreInit
{
private _checkoutService = inject(DomainCheckoutService);
private _branchService = inject(BranchService);
private _authService = inject(AuthService);
@@ -87,23 +96,33 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
readonly order$ = this.select((state) => state.order);
readonly updateCheckout = this.updater((state, checkout: CheckoutDTO) => ({ ...state, checkout }));
readonly updateCheckout = this.updater((state, checkout: CheckoutDTO) => ({
...state,
checkout,
}));
readonly updateOrder = this.updater((state, order: OrderDTO) => ({ ...state, order }));
readonly updateOrder = this.updater((state, order: OrderDTO) => ({
...state,
order,
}));
readonly fetchShoppingCart$ = this.select((state) => state.fetchShoppingCart);
readonly updateFetchShoppingCart = this.updater((state, fetchShoppingCart: boolean) => ({
...state,
fetchShoppingCart,
}));
readonly updateFetchShoppingCart = this.updater(
(state, fetchShoppingCart: boolean) => ({
...state,
fetchShoppingCart,
}),
);
readonly ordering$ = this.select((state) => state.ordering);
loadBranch = this.effect(($) =>
$.pipe(
switchMap(() =>
this._branchService.BranchGetBranches({}).pipe(tapResponse(this.handleBranchResponse, this.handleBranchError)),
this._branchService
.BranchGetBranches({})
.pipe(tapResponse(this.handleBranchResponse, this.handleBranchError)),
),
),
);
@@ -111,31 +130,45 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
handleBranchResponse = (res: ResponseArgsOfIEnumerableOfBranchDTO) => {
const branchNumber = this._authService.getClaimByKey('branch_no');
this.patchState({ branch: res.result.find((b) => b.branchNumber === branchNumber) });
this.patchState({
branch: res.result.find((b) => b.branchNumber === branchNumber),
});
};
handleBranchError = (err) => {
this._modal.error('Fehler beim Laden der Filiale', err);
};
createShoppingCart = this.effect((orderItemListItem$: Observable<OrderItemListItemDTO>) =>
orderItemListItem$.pipe(
tap((orderItemListItem) => {
this.patchState({ orderItemListItem });
this.updateFetchShoppingCart(true);
}),
switchMap((orderItemListItem) =>
this._checkoutService
.getShoppingCart({ processId: this.processId })
.pipe(tapResponse(this.handleCreateShoppingCartResponse, this.handleCreateShoppingCartError)),
createShoppingCart = this.effect(
(orderItemListItem$: Observable<OrderItemListItemDTO>) =>
orderItemListItem$.pipe(
tap((orderItemListItem) => {
this.patchState({ orderItemListItem });
this.updateFetchShoppingCart(true);
}),
switchMap((orderItemListItem) =>
this._checkoutService
.getShoppingCart({ processId: this.processId })
.pipe(
tapResponse(
this.handleCreateShoppingCartResponse,
this.handleCreateShoppingCartError,
),
),
),
),
),
);
handleCreateShoppingCartResponse = (res: ShoppingCartDTO) => {
this.patchState({ shoppingCart: res });
this._checkoutService.setBuyer({ processId: this.processId, buyer: this.order.buyer });
this._checkoutService.setPayer({ processId: this.processId, payer: this.order.billing?.data });
this._checkoutService.setBuyer({
processId: this.processId,
buyer: this.order.buyer,
});
this._checkoutService.setPayer({
processId: this.processId,
payer: this.order.billing?.data,
});
this.updateFetchShoppingCart(false);
};
@@ -154,7 +187,9 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
processId: this.processId,
items: [add],
})
.pipe(tapResponse(this.handleAddItemResponse, this.handleAddItemError)),
.pipe(
tapResponse(this.handleAddItemResponse, this.handleAddItemError),
),
),
),
);
@@ -174,7 +209,12 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
shoppingCartItemId: change.id,
update: { quantity: change.quantity },
})
.pipe(tapResponse(this.handleQuantityChangeResponse, this.handleQuantityChangeError)),
.pipe(
tapResponse(
this.handleQuantityChangeResponse,
this.handleQuantityChangeError,
),
),
),
),
);
@@ -206,7 +246,10 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
this.onOrderSuccess(res.result.item1[0], res.result.item2);
};
onOrderSuccess = (displayOrder: DisplayOrderDTO, action: KeyValueDTOOfStringAndString[]) => {};
onOrderSuccess = (
displayOrder: DisplayOrderDTO,
action: KeyValueDTOOfStringAndString[],
) => {};
handleOrderError = (err: any) => {
this._modal.error('Fehler beim Bestellen', err);
@@ -215,8 +258,9 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
itemQuantityByCatalogProductNumber(catalogProductNumber: string) {
return (
this.shoppingCart?.items?.find((i) => getCatalogProductNumber(i?.data) === catalogProductNumber)?.data
?.quantity ?? 0
this.shoppingCart?.items?.find(
(i) => getCatalogProductNumber(i?.data) === catalogProductNumber,
)?.data?.quantity ?? 0
);
}
@@ -227,7 +271,11 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
.canAddItemsKulturpass([item?.product])
.pipe(
tapResponse(
(results) => this.handleCanAddItemResponse({ item, result: results?.find((_) => true) }),
(results) =>
this.handleCanAddItemResponse({
item,
result: results?.find((_) => true),
}),
this.handleCanAddItemError,
),
),
@@ -235,14 +283,23 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
),
);
handleCanAddItemResponse = ({ item, result }: { item: ItemDTO; result: KulturPassResult }) => {
handleCanAddItemResponse = ({
item,
result,
}: {
item: ItemDTO;
result: KulturPassResult;
}) => {
if (result?.canAdd) {
this.addItemToShoppingCart(item);
} else {
this._modal.open({
content: UiMessageModalComponent,
title: 'Artikel nicht förderfähig',
data: { message: result?.message, closeAction: 'ohne Artikel fortfahren' },
data: {
message: result?.message,
closeAction: 'ohne Artikel fortfahren',
},
});
}
};
@@ -254,14 +311,18 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
addItemToShoppingCart = this.effect((item$: Observable<ItemDTO>) =>
item$.pipe(
mergeMap((item) => {
const takeAwayAvailability$ = this._availabilityService.getTakeAwayAvailability({
item: {
ean: item.product.ean,
itemId: item.id,
price: item.catalogAvailability.price,
},
quantity: this.itemQuantityByCatalogProductNumber(getCatalogProductNumber(item)) + 1,
});
const takeAwayAvailability$ =
this._availabilityService.getTakeAwayAvailability({
item: {
ean: item.product.ean,
itemId: item.id,
price: item.catalogAvailability.price,
},
quantity:
this.itemQuantityByCatalogProductNumber(
getCatalogProductNumber(item),
) + 1,
});
const deliveryAvailability$ = this._availabilityService
.getDeliveryAvailability({
@@ -270,7 +331,10 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
itemId: item.id,
price: item.catalogAvailability.price,
},
quantity: this.itemQuantityByCatalogProductNumber(getCatalogProductNumber(item)) + 1,
quantity:
this.itemQuantityByCatalogProductNumber(
getCatalogProductNumber(item),
) + 1,
})
.pipe(
catchError((err) => {
@@ -279,7 +343,10 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
);
return zip(takeAwayAvailability$, deliveryAvailability$).pipe(
tapResponse(this.handleAddItemToShoppingCartResponse2(item), this.handleAddItemToShoppingCartError),
tapResponse(
this.handleAddItemToShoppingCartResponse2(item),
this.handleAddItemToShoppingCartError,
),
);
}),
),
@@ -287,20 +354,27 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
handleAddItemToShoppingCartResponse2 =
(item: ItemDTO) =>
([takeAwayAvailability, deliveryAvailability]: [AvailabilityDTO, AvailabilityDTO]) => {
([takeAwayAvailability, deliveryAvailability]: [
AvailabilityDTO,
AvailabilityDTO,
]) => {
let isPriceMaintained = item?.catalogAvailability?.priceMaintained;
let onlinePrice = -1;
if (deliveryAvailability) {
isPriceMaintained = isPriceMaintained ?? deliveryAvailability['priceMaintained'] ?? false;
isPriceMaintained =
isPriceMaintained ?? deliveryAvailability['priceMaintained'] ?? false;
onlinePrice = deliveryAvailability?.price?.value?.value ?? -1;
}
// Preis und priceMaintained werden immer erst vom Katalog genommen. Bei nicht Verfügbarkeit greifen die anderen Availabilities
const offlinePrice =
item?.catalogAvailability?.price?.value?.value ?? takeAwayAvailability?.price?.value?.value ?? -1;
item?.catalogAvailability?.price?.value?.value ??
takeAwayAvailability?.price?.value?.value ??
-1;
const availability = takeAwayAvailability;
availability.price = item?.catalogAvailability?.price ?? takeAwayAvailability?.price;
availability.price =
item?.catalogAvailability?.price ?? takeAwayAvailability?.price;
/**
* Onlinepreis ist niedliger als der Offlinepreis
@@ -314,7 +388,11 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
* wenn der Artikel Preisgebunden ist, wird der Ladenpreis verwendet
*/
if (!!deliveryAvailability && onlinePrice < offlinePrice && !isPriceMaintained) {
if (
!!deliveryAvailability &&
onlinePrice < offlinePrice &&
!isPriceMaintained
) {
availability.price = deliveryAvailability.price;
}
@@ -333,10 +411,13 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
},
},
promotion: {
points: 0,
value: 0,
},
itemType: item.type,
product: { catalogProductNumber: getCatalogProductNumber(item), ...item.product },
product: {
catalogProductNumber: getCatalogProductNumber(item),
...item.product,
},
};
this.addItem(addToShoppingCartDTO);
@@ -346,15 +427,28 @@ export class KulturpassOrderModalStore extends ComponentStore<KulturpassOrderMod
this._modal.error('Fehler beim Hinzufügen des Artikels', err);
};
setAvailability = this.updater((state, data: { catalogProductNumber: string; availability: AvailabilityDTO }) => {
return { ...state, availabilities: { ...state.availabilities, [data.catalogProductNumber]: data.availability } };
});
setAvailability = this.updater(
(
state,
data: { catalogProductNumber: string; availability: AvailabilityDTO },
) => {
return {
...state,
availabilities: {
...state.availabilities,
[data.catalogProductNumber]: data.availability,
},
};
},
);
getAvailability(catalogProductNumber: string): AvailabilityDTO | undefined {
return this.get((state) => state.availabilities[catalogProductNumber]);
}
getAvailability$(catalogProductNumber: string): Observable<AvailabilityDTO | undefined> {
getAvailability$(
catalogProductNumber: string,
): Observable<AvailabilityDTO | undefined> {
return this.select((state) => state.availabilities[catalogProductNumber]);
}
}

View File

@@ -0,0 +1,313 @@
# Purchase Options Modal
The Purchase Options Modal allows users to select how they want to receive their items (delivery, pickup, in-store, download) during the checkout process.
## Overview
This modal handles the complete purchase option selection flow including:
- Fetching availability for each purchase option
- Validating if items can be added to the shopping cart
- Managing item quantities and prices
- Supporting reward redemption flows
## Features
### Core Functionality
- **Multiple Purchase Options**: Delivery, B2B Delivery, Digital Delivery, Pickup, In-Store, Download
- **Availability Checking**: Real-time availability checks for each option
- **Branch Selection**: Pick branches for pickup and in-store options
- **Price Management**: Handle pricing, VAT, and manual price adjustments
- **Reward Redemption**: Support for redeeming loyalty points
### Advanced Features
- **Disabled Purchase Options**: Prevent specific options from being available (skips API calls)
- **Hide Disabled Options**: Toggle visibility of disabled options (show grayed out or hide completely)
- **Pre-selection**: Pre-select a specific purchase option on open
- **Single Option Mode**: Show only one specific purchase option
## Usage
### Basic Usage
```typescript
import { PurchaseOptionsModalService } from '@modal/purchase-options';
constructor(private purchaseOptionsModal: PurchaseOptionsModalService) {}
async openModal() {
const modalRef = await this.purchaseOptionsModal.open({
tabId: 123,
shoppingCartId: 456,
type: 'add', // or 'update'
items: [/* array of items */],
});
const result = await firstValueFrom(modalRef.afterClosed$);
}
```
### Disabling Purchase Options
Prevent specific options from being available. The modal will **not make API calls** for disabled options.
```typescript
const modalRef = await this.purchaseOptionsModal.open({
tabId: 123,
shoppingCartId: 456,
type: 'add',
items: [item1, item2],
disabledPurchaseOptions: ['b2b-delivery'], // Disable B2B delivery
});
```
### Hide vs Show Disabled Options
Control whether disabled options are hidden or shown with a disabled visual state:
**Hide Disabled Options (default)**
```typescript
const modalRef = await this.purchaseOptionsModal.open({
tabId: 123,
shoppingCartId: 456,
type: 'add',
items: [item],
disabledPurchaseOptions: ['b2b-delivery'],
hideDisabledPurchaseOptions: true, // Default - option not visible
});
```
**Show Disabled Options (grayed out)**
```typescript
const modalRef = await this.purchaseOptionsModal.open({
tabId: 123,
shoppingCartId: 456,
type: 'add',
items: [item],
disabledPurchaseOptions: ['b2b-delivery'],
hideDisabledPurchaseOptions: false, // Show disabled with visual indicator
});
```
### Pre-selecting an Option
```typescript
const modalRef = await this.purchaseOptionsModal.open({
tabId: 123,
shoppingCartId: 456,
type: 'add',
items: [item],
preSelectOption: {
option: 'in-store',
showOptionOnly: false, // Optional: show only this option
},
});
```
### Reward Redemption Flow
```typescript
const modalRef = await this.purchaseOptionsModal.open({
tabId: 123,
shoppingCartId: 456,
type: 'add',
items: [rewardItem],
useRedemptionPoints: true,
preSelectOption: { option: 'in-store' },
disabledPurchaseOptions: ['b2b-delivery'], // Common for rewards
});
```
## API Reference
### PurchaseOptionsModalData
```typescript
interface PurchaseOptionsModalData {
/** Tab ID for context */
tabId: number;
/** Shopping cart ID to add/update items */
shoppingCartId: number;
/** Action type: 'add' = new items, 'update' = existing items */
type: 'add' | 'update';
/** Enable redemption points mode */
useRedemptionPoints?: boolean;
/** Items to show in the modal */
items: Array<ItemDTO | ShoppingCartItemDTO>;
/** Pre-configured pickup branch */
pickupBranch?: BranchDTO;
/** Pre-configured in-store branch */
inStoreBranch?: BranchDTO;
/** Pre-select a specific purchase option */
preSelectOption?: {
option: PurchaseOption;
showOptionOnly?: boolean;
};
/** Purchase options to disable (no API calls) */
disabledPurchaseOptions?: PurchaseOption[];
/** Hide disabled options (true) or show as grayed out (false). Default: true */
hideDisabledPurchaseOptions?: boolean;
}
```
### PurchaseOption Type
```typescript
type PurchaseOption =
| 'delivery' // Standard delivery
| 'dig-delivery' // Digital delivery
| 'b2b-delivery' // B2B delivery
| 'pickup' // Pickup at branch
| 'in-store' // Reserve in store
| 'download' // Digital download
| 'catalog'; // Catalog availability
```
## Architecture
### Component Structure
```
purchase-options/
├── purchase-options-modal.component.ts # Main modal component
├── purchase-options-modal.service.ts # Service to open modal
├── purchase-options-modal.data.ts # Data interfaces
├── store/
│ ├── purchase-options.store.ts # NgRx ComponentStore
│ ├── purchase-options.service.ts # Business logic service
│ ├── purchase-options.state.ts # State interface
│ ├── purchase-options.types.ts # Type definitions
│ └── purchase-options.selectors.ts # State selectors
├── purchase-options-tile/
│ ├── base-purchase-option.directive.ts # Base directive for tiles
│ ├── delivery-purchase-options-tile.component.ts
│ ├── pickup-purchase-options-tile.component.ts
│ ├── in-store-purchase-options-tile.component.ts
│ └── download-purchase-options-tile.component.ts
└── purchase-options-list-item/ # Item list components
```
### State Management
The modal uses NgRx ComponentStore for state management:
- **Availability Loading**: Parallel API calls for each enabled option
- **Can Add Validation**: Check if items can be added to cart
- **Item Selection**: Track selected items for batch operations
- **Branch Management**: Handle branch selection for pickup/in-store
### Disabled Options Flow
1. **Configuration**: `disabledPurchaseOptions` array passed to modal
2. **State**: Stored in store via `initialize()` method
3. **Availability Loading**: `isOptionDisabled()` check skips API calls
4. **UI Rendering**:
- If `hideDisabledPurchaseOptions: true` → not rendered
- If `hideDisabledPurchaseOptions: false` → rendered with `.disabled` class
5. **Click Prevention**: Disabled tiles prevent click action
## Common Use Cases
### 1. Regular Checkout
```typescript
// Show all options
await modalService.open({
tabId: processId,
shoppingCartId: cartId,
type: 'add',
items: catalogItems,
});
```
### 2. Reward Redemption
```typescript
// Pre-select in-store, disable B2B
await modalService.open({
tabId: processId,
shoppingCartId: rewardCartId,
type: 'add',
items: rewardItems,
useRedemptionPoints: true,
preSelectOption: { option: 'in-store' },
disabledPurchaseOptions: ['b2b-delivery'],
});
```
### 3. Update Existing Item
```typescript
// Allow user to change delivery option
await modalService.open({
tabId: processId,
shoppingCartId: cartId,
type: 'update',
items: [existingCartItem],
});
```
### 4. Gift Cards (In-Store Only)
```typescript
// Show only in-store and delivery
await modalService.open({
tabId: processId,
shoppingCartId: cartId,
type: 'add',
items: [giftCardItem],
disabledPurchaseOptions: ['pickup', 'b2b-delivery'],
});
```
## Testing
### Running Tests
```bash
# Run all tests for the app
npx nx test isa-app --skip-nx-cache
```
### Testing Disabled Options
When testing the disabled options feature:
1. Verify no API calls are made for disabled options
2. Check UI rendering based on `hideDisabledPurchaseOptions` flag
3. Ensure click events are prevented on disabled tiles
4. Validate backward compatibility (defaults work correctly)
## Migration Notes
### From `hidePurchaseOptions` to `disabledPurchaseOptions`
The field was renamed for clarity:
- **Old**: `hidePurchaseOptions` (ambiguous - hides from UI)
- **New**: `disabledPurchaseOptions` (clear - disabled functionality, may or may not be hidden)
This is a **breaking change** if you were using `hidePurchaseOptions`. Update all usages:
```typescript
// Before
hidePurchaseOptions: ['b2b-delivery']
// After
disabledPurchaseOptions: ['b2b-delivery']
```
## Troubleshooting
### Problem: Disabled option still making API calls
**Solution**: Ensure the option is in the `disabledPurchaseOptions` array and spelled correctly.
### Problem: Disabled option not showing even with `hideDisabledPurchaseOptions: false`
**Solution**: Check that `showOption()` logic and availability checks are working correctly.
### Problem: All options disabled
**Solution**: Don't disable all options. At least one option must be available for users to proceed.
## Related Documentation
- [Checkout Data Access](../../../libs/checkout/data-access/README.md)
- [Shopping Cart Flow](../../page/checkout/README.md)
- [Reward System](../../../libs/checkout/feature/reward-catalog/README.md)

View File

@@ -1,33 +1,48 @@
import { ItemType, PriceDTO, PriceValueDTO, VATValueDTO } from '@generated/swagger/checkout-api';
import { OrderType, PurchaseOption } from './store';
export const PURCHASE_OPTIONS: PurchaseOption[] = [
'in-store',
'pickup',
'delivery',
'dig-delivery',
'b2b-delivery',
'download',
];
export const DELIVERY_PURCHASE_OPTIONS: PurchaseOption[] = ['delivery', 'dig-delivery', 'b2b-delivery'];
export const PURCHASE_OPTION_TO_ORDER_TYPE: { [purchaseOption: string]: OrderType } = {
'in-store': 'Rücklage',
pickup: 'Abholung',
delivery: 'Versand',
'dig-delivery': 'Versand',
'b2b-delivery': 'Versand',
};
export const GIFT_CARD_TYPE = 66560 as ItemType;
export const DEFAULT_PRICE_DTO: PriceDTO = { value: { value: undefined }, vat: { vatType: 0 } };
export const DEFAULT_PRICE_VALUE: PriceValueDTO = { value: 0, currency: 'EUR' };
export const DEFAULT_VAT_VALUE: VATValueDTO = { value: 0 };
export const GIFT_CARD_MAX_PRICE = 200;
export const PRICE_PATTERN = /^\d+(,\d{1,2})?$/;
import {
ItemType,
PriceDTO,
PriceValueDTO,
VATValueDTO,
} from '@generated/swagger/checkout-api';
import { PurchaseOption } from './store';
import { OrderTypeFeature } from '@isa/checkout/data-access';
export const PURCHASE_OPTIONS: PurchaseOption[] = [
'in-store',
'pickup',
'delivery',
'dig-delivery',
'b2b-delivery',
'download',
];
export const DELIVERY_PURCHASE_OPTIONS: PurchaseOption[] = [
'delivery',
'dig-delivery',
'b2b-delivery',
];
export const PURCHASE_OPTION_TO_ORDER_TYPE: {
[purchaseOption: string]: OrderTypeFeature;
} = {
'in-store': 'Rücklage',
'pickup': 'Abholung',
'delivery': 'Versand',
'dig-delivery': 'Versand',
'b2b-delivery': 'Versand',
};
export const GIFT_CARD_TYPE = 66560 as ItemType;
export const DEFAULT_PRICE_DTO: PriceDTO = {
value: { value: undefined },
vat: { vatType: 0 },
};
export const DEFAULT_PRICE_VALUE: PriceValueDTO = { value: 0, currency: 'EUR' };
export const DEFAULT_VAT_VALUE: VATValueDTO = { value: 0 };
export const GIFT_CARD_MAX_PRICE = 200;
export const PRICE_PATTERN = /^\d+(,\d{1,2})?$/;

View File

@@ -1,185 +1,276 @@
<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 (showNoDownloadAvailability$ | async) {
<span
class="inline-block font-bold text-[#BE8100] mt-[14px] max-w-[19rem]"
>
Derzeit nicht verfügbar
</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,513 @@
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,
isDownload,
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;
}),
);
fetchingAvailabilitiesArray$ = this.item$.pipe(
switchMap((item) => this._store.getFetchingAvailabilitiesForItem$(item.id)),
);
fetchingAvailabilities$ = this.fetchingAvailabilitiesArray$.pipe(
map((fetchingAvailabilities) => fetchingAvailabilities.length > 0),
);
fetchingInStoreAvailability$ = this.fetchingAvailabilitiesArray$.pipe(
map((fetchingAvailabilities) =>
fetchingAvailabilities.some((fa) => fa.purchaseOption === 'in-store'),
),
);
isFetchingInStore = toSignal(this.fetchingInStoreAvailability$, {
initialValue: false,
});
showNotAvailable$ = combineLatest([
this.availabilities$,
this.fetchingAvailabilities$,
]).pipe(
map(([availabilities, fetchingAvailabilities]) => {
if (fetchingAvailabilities) {
return false;
}
if (availabilities.length === 0) {
return true;
}
return false;
}),
);
isDownload$ = this.item$.pipe(map((item) => isDownload(item)));
isDownloadItem = toSignal(this.isDownload$, { initialValue: false });
showNoDownloadAvailability$ = combineLatest([
this.isDownload$,
this.availabilities$,
this.fetchingAvailabilities$,
]).pipe(
map(([isDownloadItem, availabilities, fetchingAvailabilities]) => {
// Only check for download items
if (!isDownloadItem) {
return false;
}
// Don't show error while loading
if (fetchingAvailabilities) {
return false;
}
// Check if download availability exists
const hasDownloadAvailability = availabilities.some(
(a) => a.purchaseOption === 'download',
);
return !hasDownloadAvailability;
}),
);
// 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(() => {
const availability = this.availability();
const inStock = availability?.inStock ?? 0;
return (
this.useRedemptionPoints() &&
this.isReservePurchaseOption() &&
!this.isDownloadItem() &&
!this.isFetchingInStore() &&
inStock > 0 &&
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

@@ -1,145 +1,204 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, TrackByFunction, HostBinding } from '@angular/core';
import { UiModalRef } from '@ui/modal';
import { PurchaseOptionsModalData } from './purchase-options-modal.data';
import { PurchaseOptionsListHeaderComponent } from './purchase-options-list-header';
import { PurchaseOptionsListItemComponent } from './purchase-options-list-item';
import { CommonModule } from '@angular/common';
import { Subject, zip } from 'rxjs';
import {
DeliveryPurchaseOptionTileComponent,
DownloadPurchaseOptionTileComponent,
InStorePurchaseOptionTileComponent,
PickupPurchaseOptionTileComponent,
} from './purchase-options-tile';
import { isGiftCard, Item, PurchaseOption, PurchaseOptionsStore } from './store';
import { delay, map, shareReplay, skip, switchMap, takeUntil, tap } from 'rxjs/operators';
import { KeyValueDTOOfStringAndString } from '@generated/swagger/cat-search-api';
import { provideComponentStore } from '@ngrx/component-store';
@Component({
selector: 'shared-purchase-options-modal',
templateUrl: 'purchase-options-modal.component.html',
styleUrls: ['purchase-options-modal.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [provideComponentStore(PurchaseOptionsStore)],
imports: [
CommonModule,
PurchaseOptionsListHeaderComponent,
PurchaseOptionsListItemComponent,
DeliveryPurchaseOptionTileComponent,
InStorePurchaseOptionTileComponent,
PickupPurchaseOptionTileComponent,
DownloadPurchaseOptionTileComponent,
],
})
export class PurchaseOptionsModalComponent implements OnInit, OnDestroy {
get type() {
return this._uiModalRef.data.type;
}
@HostBinding('attr.data-loading')
get fetchingData() {
return this.store.fetchingAvailabilities.length > 0;
}
items$ = this.store.items$;
hasPrice$ = this.items$.pipe(
switchMap((items) =>
items.map((item) => {
let isArchive = false;
const features = item?.features as KeyValueDTOOfStringAndString[];
// Ticket #4074 analog zu Ticket #2244
// Ob Archivartikel kann nur über Kaufoptionen herausgefunden werden, nicht über Ändern im Warenkorb da am ShoppingCartItem das Archivartikel Feature fehlt
if (!!features && Array.isArray(features)) {
isArchive = !!features?.find((feature) => feature?.enabled === true && feature?.key === 'ARC');
}
return zip(
this.store
?.getPrice$(item?.id)
.pipe(
map((price) =>
isArchive
? !!price?.value?.value && price?.vat !== undefined && price?.vat?.value !== undefined
: !!price?.value?.value,
),
),
);
}),
),
switchMap((hasPrices) => hasPrices),
map((hasPrices) => {
const containsItemWithNoPrice = hasPrices?.filter((hasPrice) => hasPrice === false) ?? [];
return containsItemWithNoPrice?.length === 0;
}),
);
purchasingOptions$ = this.store.getPurchaseOptionsInAvailabilities$;
isDownloadOnly$ = this.purchasingOptions$.pipe(
map((purchasingOptions) => purchasingOptions.length === 1 && purchasingOptions[0] === 'download'),
);
isGiftCardOnly$ = this.store.items$.pipe(map((items) => items.every((item) => isGiftCard(item, this.store.type))));
hasDownload$ = this.purchasingOptions$.pipe(map((purchasingOptions) => purchasingOptions.includes('download')));
canContinue$ = this.store.canContinue$;
private _onDestroy$ = new Subject<void>();
saving = false;
constructor(
private _uiModalRef: UiModalRef<string, PurchaseOptionsModalData>,
public store: PurchaseOptionsStore,
) {
this.store.initialize(this._uiModalRef.data);
}
ngOnInit(): void {
this.items$.pipe(takeUntil(this._onDestroy$), skip(1), delay(100)).subscribe((items) => {
if (items.length === 0) {
this._uiModalRef.close();
return;
}
if (this._uiModalRef.data?.preSelectOption?.option) {
this.store.setPurchaseOption(this._uiModalRef.data?.preSelectOption?.option);
}
});
}
ngOnDestroy(): void {
this._onDestroy$.next();
this._onDestroy$.complete();
}
itemTrackBy: TrackByFunction<Item> = (_, item) => item.id;
showOption(option: PurchaseOption): boolean {
return this._uiModalRef.data?.preSelectOption?.showOptionOnly
? this._uiModalRef.data?.preSelectOption?.option === option
: true;
}
async save(action: string) {
if (this.saving) {
return;
}
this.saving = true;
try {
await this.store.save();
if (this.store.items.length === 0) {
this._uiModalRef.close(action);
}
} catch (error) {
console.error(error);
}
this.saving = false;
}
}
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
TrackByFunction,
HostBinding,
} from '@angular/core';
import { UiModalRef } from '@ui/modal';
import { PurchaseOptionsModalContext } from './purchase-options-modal.data';
import { PurchaseOptionsListHeaderComponent } from './purchase-options-list-header';
import { PurchaseOptionsListItemComponent } from './purchase-options-list-item';
import { CommonModule } from '@angular/common';
import { Subject, zip } from 'rxjs';
import {
DeliveryPurchaseOptionTileComponent,
DownloadPurchaseOptionTileComponent,
InStorePurchaseOptionTileComponent,
PickupPurchaseOptionTileComponent,
} from './purchase-options-tile';
import {
isDownload,
isGiftCard,
Item,
PurchaseOption,
PurchaseOptionsStore,
} from './store';
import {
delay,
map,
shareReplay,
skip,
switchMap,
takeUntil,
tap,
} from 'rxjs/operators';
import { KeyValueDTOOfStringAndString } from '@generated/swagger/cat-search-api';
import { provideComponentStore } from '@ngrx/component-store';
@Component({
selector: 'shared-purchase-options-modal',
templateUrl: 'purchase-options-modal.component.html',
styleUrls: ['purchase-options-modal.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [provideComponentStore(PurchaseOptionsStore)],
imports: [
CommonModule,
PurchaseOptionsListHeaderComponent,
PurchaseOptionsListItemComponent,
DeliveryPurchaseOptionTileComponent,
InStorePurchaseOptionTileComponent,
PickupPurchaseOptionTileComponent,
DownloadPurchaseOptionTileComponent,
],
})
export class PurchaseOptionsModalComponent implements OnInit, OnDestroy {
get type() {
return this._uiModalRef.data.type;
}
@HostBinding('attr.data-loading')
get fetchingData() {
return this.store.fetchingAvailabilities.length > 0;
}
items$ = this.store.items$;
hasPrice$ = this.items$.pipe(
switchMap((items) =>
items.map((item) => {
let isArchive = false;
const features = item?.features as KeyValueDTOOfStringAndString[];
// Ticket #4074 analog zu Ticket #2244
// Ob Archivartikel kann nur über Kaufoptionen herausgefunden werden, nicht über Ändern im Warenkorb da am ShoppingCartItem das Archivartikel Feature fehlt
if (!!features && Array.isArray(features)) {
isArchive = !!features?.find(
(feature) => feature?.enabled === true && feature?.key === 'ARC',
);
}
return zip(
this.store
?.getPrice$(item?.id)
.pipe(
map((price) =>
isArchive
? !!price?.value?.value &&
price?.vat !== undefined &&
price?.vat?.value !== undefined
: !!price?.value?.value,
),
),
);
}),
),
switchMap((hasPrices) => hasPrices),
map((hasPrices) => {
const containsItemWithNoPrice =
hasPrices?.filter((hasPrice) => hasPrice === false) ?? [];
return containsItemWithNoPrice?.length === 0;
}),
);
purchasingOptions$ = this.store.getPurchaseOptionsInAvailabilities$;
isDownloadOnly$ = this.store.items$.pipe(
map((items) => items.length > 0 && items.every((item) => isDownload(item))),
);
isGiftCardOnly$ = this.store.items$.pipe(
map((items) => items.every((item) => isGiftCard(item, this.store.type))),
);
hasDownload$ = this.store.items$.pipe(
map((items) => items.some((item) => isDownload(item))),
);
canContinue$ = this.store.canContinue$;
private _onDestroy$ = new Subject<void>();
saving = false;
constructor(
private _uiModalRef: UiModalRef<string, PurchaseOptionsModalContext>,
public store: PurchaseOptionsStore,
) {
this.store.initialize(this._uiModalRef.data);
}
ngOnInit(): void {
this.items$
.pipe(takeUntil(this._onDestroy$), skip(1), delay(100))
.subscribe((items) => {
if (items.length === 0) {
this._uiModalRef.close();
return;
}
if (this._uiModalRef.data?.preSelectOption?.option) {
this.store.setPurchaseOption(
this._uiModalRef.data?.preSelectOption?.option,
);
}
});
}
ngOnDestroy(): void {
this._onDestroy$.next();
this._onDestroy$.complete();
}
itemTrackBy: TrackByFunction<Item> = (_, item) => item.id;
/**
* Determines if a purchase option should be shown in the UI.
*
* Evaluation order:
* 1. If option is disabled AND hideDisabledPurchaseOptions is true -> hide (return false)
* 2. If preSelectOption.showOptionOnly is true -> show only that option
* 3. Otherwise -> show the option
*
* @param option - The purchase option to check
* @returns true if the option should be displayed, false otherwise
*
* @example
* ```typescript
* // In template
* @if (showOption('delivery')) {
* <app-delivery-purchase-options-tile></app-delivery-purchase-options-tile>
* }
* ```
*/
showOption(option: PurchaseOption): boolean {
const disabledOptions = this._uiModalRef.data?.disabledPurchaseOptions ?? [];
const hideDisabled = this._uiModalRef.data?.hideDisabledPurchaseOptions ?? true;
if (disabledOptions.includes(option) && hideDisabled) {
return false;
}
return this._uiModalRef.data?.preSelectOption?.showOptionOnly
? this._uiModalRef.data?.preSelectOption?.option === option
: true;
}
async save(action: string) {
if (this.saving) {
return;
}
this.saving = true;
try {
await this.store.save();
if (this.store.items.length === 0) {
this._uiModalRef.close(action);
}
} catch (error) {
console.error(error);
}
this.saving = false;
}
}

View File

@@ -1,12 +1,130 @@
import { ItemDTO } from '@generated/swagger/cat-search-api';
import { ShoppingCartItemDTO, BranchDTO } from '@generated/swagger/checkout-api';
import { ActionType, PurchaseOption } from './store';
export interface PurchaseOptionsModalData {
processId: number;
type: ActionType;
items: Array<ItemDTO | ShoppingCartItemDTO>;
pickupBranch?: BranchDTO;
inStoreBranch?: BranchDTO;
preSelectOption?: { option: PurchaseOption; showOptionOnly?: boolean };
}
import { ItemDTO } from '@generated/swagger/cat-search-api';
import {
ShoppingCartItemDTO,
BranchDTO,
} from '@generated/swagger/checkout-api';
import { Customer } from '@isa/crm/data-access';
import { ActionType, PurchaseOption } from './store';
/**
* Data interface for opening the Purchase Options Modal.
*
* @example
* ```typescript
* // Basic usage
* const modalRef = await purchaseOptionsModalService.open({
* tabId: 123,
* shoppingCartId: 456,
* type: 'add',
* items: [item1, item2],
* });
*
* // With disabled options
* const modalRef = await purchaseOptionsModalService.open({
* tabId: 123,
* shoppingCartId: 456,
* type: 'add',
* items: [rewardItem],
* disabledPurchaseOptions: ['b2b-delivery'],
* hideDisabledPurchaseOptions: true, // Hide completely (default)
* });
* ```
*/
export interface PurchaseOptionsModalData {
/** Tab ID for maintaining context across the application */
tabId: number;
/** Shopping cart ID where items will be added or updated */
shoppingCartId: number;
/**
* Action type determining modal behavior:
* - 'add': Adding new items to cart
* - 'update': Updating existing cart items
*/
type: ActionType;
/**
* Enable redemption points mode for reward items.
* When true, prices are set to 0 and loyalty points are applied.
*/
useRedemptionPoints?: boolean;
/** Items to display in the modal for purchase option selection */
items: Array<ItemDTO | ShoppingCartItemDTO>;
/** Pre-configured branch for pickup option */
pickupBranch?: BranchDTO;
/** Pre-configured branch for in-store option */
inStoreBranch?: BranchDTO;
/**
* Pre-select a specific purchase option on modal open.
* Set showOptionOnly to true to display only that option.
*/
preSelectOption?: { option: PurchaseOption; showOptionOnly?: boolean };
/**
* Purchase options to disable. Disabled options:
* - Will not have availability API calls made
* - Will either be hidden or shown as disabled based on hideDisabledPurchaseOptions
*
* @example ['b2b-delivery', 'download']
*/
disabledPurchaseOptions?: PurchaseOption[];
/**
* Controls visibility of disabled purchase options.
* - true (default): Disabled options are completely hidden from the UI
* - false: Disabled options are shown with a disabled visual state (grayed out, not clickable)
*
* @default true
*/
hideDisabledPurchaseOptions?: boolean;
}
/**
* Internal context interface used within the modal component.
* Extends PurchaseOptionsModalData with additional runtime data like selected customer.
*
* @internal
*/
export interface PurchaseOptionsModalContext {
/** Shopping cart ID where items will be added or updated */
shoppingCartId: number;
/**
* Action type determining modal behavior:
* - 'add': Adding new items to cart
* - 'update': Updating existing cart items
*/
type: ActionType;
/** Enable redemption points mode for reward items */
useRedemptionPoints: boolean;
/** Items to display in the modal for purchase option selection */
items: Array<ItemDTO | ShoppingCartItemDTO>;
/** Customer selected in the current tab (resolved at runtime) */
selectedCustomer?: Customer;
/** Default branch resolved from user settings or tab context */
selectedBranch?: BranchDTO;
/** Pre-configured branch for pickup option */
pickupBranch?: BranchDTO;
/** Pre-configured branch for in-store option */
inStoreBranch?: BranchDTO;
/** Pre-select a specific purchase option on modal open */
preSelectOption?: { option: PurchaseOption; showOptionOnly?: boolean };
/** Purchase options to disable (no API calls, conditional UI rendering) */
disabledPurchaseOptions?: PurchaseOption[];
/** Controls visibility of disabled purchase options (default: true = hidden) */
hideDisabledPurchaseOptions?: boolean;
}

View File

@@ -1,16 +1,118 @@
import { Injectable } from '@angular/core';
import { UiModalRef, UiModalService } from '@ui/modal';
import { PurchaseOptionsModalComponent } from './purchase-options-modal.component';
import { PurchaseOptionsModalData } from './purchase-options-modal.data';
@Injectable({ providedIn: 'root' })
export class PurchaseOptionsModalService {
constructor(private _uiModal: UiModalService) {}
open(data: PurchaseOptionsModalData): UiModalRef<string, PurchaseOptionsModalData> {
return this._uiModal.open<string, PurchaseOptionsModalData>({
content: PurchaseOptionsModalComponent,
data,
});
}
}
import { Injectable, inject, untracked } from '@angular/core';
import { UiModalRef, UiModalService } from '@ui/modal';
import { PurchaseOptionsModalComponent } from './purchase-options-modal.component';
import {
PurchaseOptionsModalData,
PurchaseOptionsModalContext,
} from './purchase-options-modal.data';
import {
CustomerFacade,
Customer,
CrmTabMetadataService,
} from '@isa/crm/data-access';
import { TabService } from '@isa/core/tabs';
import { BranchDTO } from '@generated/swagger/checkout-api';
/**
* Service for opening and managing the Purchase Options Modal.
*
* The Purchase Options Modal allows users to select how they want to receive items
* (delivery, pickup, in-store, download) and manages availability checking, pricing,
* and shopping cart operations.
*
* @example
* ```typescript
* // Basic usage
* const modalRef = await this.purchaseOptionsModalService.open({
* tabId: 123,
* shoppingCartId: 456,
* type: 'add',
* items: [item1, item2],
* });
*
* // Await modal close
* const result = await firstValueFrom(modalRef.afterClosed$);
* ```
*/
@Injectable({ providedIn: 'root' })
export class PurchaseOptionsModalService {
#uiModal = inject(UiModalService);
#tabService = inject(TabService);
#crmTabMetadataService = inject(CrmTabMetadataService);
#customerFacade = inject(CustomerFacade);
/**
* Opens the Purchase Options Modal.
*
* @param data - Configuration data for the modal
* @returns Promise resolving to a modal reference
*
* @example
* ```typescript
* // Add new items with disabled B2B delivery
* const modalRef = await this.purchaseOptionsModalService.open({
* tabId: processId,
* shoppingCartId: cartId,
* type: 'add',
* items: [item1, item2],
* disabledPurchaseOptions: ['b2b-delivery'],
* });
*
* // Wait for modal to close
* const action = await firstValueFrom(modalRef.afterClosed$);
* if (action === 'continue') {
* // Proceed to next step
* }
* ```
*/
async open(
data: PurchaseOptionsModalData,
): Promise<UiModalRef<string, PurchaseOptionsModalData>> {
const context: PurchaseOptionsModalContext = {
useRedemptionPoints: !!data.useRedemptionPoints,
...data,
};
context.selectedCustomer = await this.#getSelectedCustomer(data);
context.selectedBranch = this.#getSelectedBranch(data.tabId);
return this.#uiModal.open<string, PurchaseOptionsModalContext>({
content: PurchaseOptionsModalComponent,
data: context,
});
}
#getSelectedCustomer({
tabId,
}: {
tabId: number;
}): Promise<Customer | undefined> {
const customerId = this.#crmTabMetadataService.selectedCustomerId(tabId);
if (!customerId) {
return Promise.resolve(undefined);
}
return this.#customerFacade.fetchCustomer({ customerId });
}
#getSelectedBranch(tabId: number): BranchDTO | undefined {
const tab = untracked(() =>
this.#tabService.entities().find((t) => t.id === tabId),
);
if (!tab) {
return undefined;
}
const legacyProcessData = tab?.metadata?.process_data;
if (
typeof legacyProcessData === 'object' &&
'selectedBranch' in legacyProcessData
) {
return legacyProcessData.selectedBranch as BranchDTO;
}
return undefined;
}
}

View File

@@ -2,6 +2,27 @@ import { ChangeDetectorRef, Directive, HostBinding, HostListener } from '@angula
import { asapScheduler } from 'rxjs';
import { PurchaseOption, PurchaseOptionsStore } from '../store';
/**
* Base directive for purchase option tile components.
*
* Provides common functionality for all purchase option tiles:
* - Visual selected state binding
* - Visual disabled state binding
* - Click handling with disabled check
* - Auto-selection of available items
*
* @example
* ```typescript
* export class DeliveryPurchaseOptionTileComponent extends BasePurchaseOptionDirective {
* constructor(
* protected store: PurchaseOptionsStore,
* protected cdr: ChangeDetectorRef,
* ) {
* super('delivery');
* }
* }
* ```
*/
@Directive({
standalone: false,
})
@@ -9,15 +30,46 @@ export abstract class BasePurchaseOptionDirective {
protected abstract store: PurchaseOptionsStore;
protected abstract cdr: ChangeDetectorRef;
/**
* Binds the 'selected' CSS class to the host element.
* Applied when this purchase option is the currently selected one.
*/
@HostBinding('class.selected')
get selected() {
return this.store.purchaseOption === this.purchaseOption;
}
/**
* Binds the 'disabled' CSS class to the host element.
* Applied when this purchase option is in the disabledPurchaseOptions array.
* Disabled options:
* - Have no availability API calls made
* - Are shown with reduced opacity and not-allowed cursor
* - Prevent click interactions
*/
@HostBinding('class.disabled')
get disabled() {
return this.store.disabledPurchaseOptions.includes(this.purchaseOption);
}
constructor(protected purchaseOption: PurchaseOption) {}
/**
* Handles click events on the purchase option tile.
*
* Behavior:
* 1. If disabled, prevents any action
* 2. Sets this option as the selected purchase option
* 3. Resets selected items
* 4. Auto-selects all items that have availability and can be added for this option
*
* @listens click
*/
@HostListener('click')
setPurchaseOptions() {
if (this.disabled) {
return;
}
this.store.setPurchaseOption(this.purchaseOption);
this.store.resetSelectedItems();
asapScheduler.schedule(() => {

View File

@@ -10,6 +10,10 @@
@apply bg-[#D8DFE5] border-[#0556B4];
}
:host.disabled {
@apply opacity-50 cursor-not-allowed bg-gray-100;
}
.purchase-options-tile__heading {
@apply flex flex-row justify-center items-center;
}

View File

@@ -1,158 +1,180 @@
import { PriceDTO } from '@generated/swagger/availability-api';
import { ItemDTO } from '@generated/swagger/cat-search-api';
import { AvailabilityDTO, OLAAvailabilityDTO, ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
import { GIFT_CARD_TYPE } from '../constants';
import {
ActionType,
Item,
ItemData,
ItemPayloadWithSourceId,
OrderType,
PurchaseOption,
} from './purchase-options.types';
export function isItemDTO(item: any, type: ActionType): item is ItemDTO {
return type === 'add';
}
export function isItemDTOArray(items: any, type: ActionType): items is ItemDTO[] {
return type === 'add';
}
export function isShoppingCartItemDTO(item: any, type: ActionType): item is ShoppingCartItemDTO {
return type === 'update';
}
export function isShoppingCartItemDTOArray(items: any, type: ActionType): items is ShoppingCartItemDTO[] {
return type === 'update';
}
export function mapToItemData(item: Item, type: ActionType): ItemData {
const price: PriceDTO = {};
if (isItemDTO(item, type)) {
price.value = item?.catalogAvailability?.price?.value ?? {};
price.vat = item?.catalogAvailability?.price?.vat ?? {};
return {
ean: item.product.ean,
itemId: item.id,
price,
sourceId: item.id,
quantity: item.quantity ?? 1,
};
} else {
price.value = item?.unitPrice?.value ?? {};
price.vat = item?.unitPrice?.vat ?? {};
return {
ean: item.product.ean,
itemId: Number(item.product.catalogProductNumber),
price,
sourceId: item.id,
quantity: item.quantity ?? 1,
};
}
}
export function isDownload(item: Item): boolean {
return item.product.format === 'DL' || item.product.format === 'EB';
}
export function isGiftCard(item: Item, type: ActionType): boolean {
if (isItemDTO(item, type)) {
return item?.type === GIFT_CARD_TYPE;
} else {
return item?.itemType === GIFT_CARD_TYPE;
}
}
export function isArchive(item: Item, type: ActionType): boolean {
if (isItemDTO(item, type)) {
return item?.features?.some((f) => f.key === 'ARC');
} else {
return !!item?.features?.['ARC'];
}
}
export function mapToItemPayload({
item,
quantity,
availability,
type,
}: {
item: ItemDTO | ShoppingCartItemDTO;
quantity: number;
availability: AvailabilityDTO;
type: ActionType;
}): ItemPayloadWithSourceId {
return {
availabilities: [mapToOlaAvailability({ item, quantity, availability, type })],
id: String(getCatalogId(item, type)),
sourceId: item.id,
};
}
export function getCatalogId(item: ItemDTO | ShoppingCartItemDTO, type: ActionType): number | string {
return isItemDTO(item, type) ? item.id : item.product.catalogProductNumber;
}
export function mapToOlaAvailability({
availability,
item,
quantity,
type,
}: {
availability: AvailabilityDTO;
item: ItemDTO | ShoppingCartItemDTO;
quantity: number;
type: ActionType;
}): OLAAvailabilityDTO {
return {
status: availability?.availabilityType,
at: availability?.estimatedShippingDate,
ean: item?.product?.ean,
itemId: Number(getCatalogId(item, type)),
format: item?.product?.format,
isPrebooked: availability?.isPrebooked,
logisticianId: availability?.logistician?.id,
price: availability?.price,
qty: quantity,
ssc: availability?.ssc,
sscText: availability?.sscText,
supplierId: availability?.supplier?.id,
supplierProductNumber: availability?.supplierProductNumber,
};
}
export function getOrderTypeForPurchaseOption(purchaseOption: PurchaseOption): OrderType | undefined {
switch (purchaseOption) {
case 'delivery':
case 'dig-delivery':
case 'b2b-delivery':
return 'Versand';
case 'pickup':
return 'Abholung';
case 'in-store':
return 'Rücklage';
case 'download':
return 'Download';
default:
return undefined;
}
}
export function getPurchaseOptionForOrderType(orderType: OrderType): PurchaseOption | undefined {
switch (orderType) {
case 'Versand':
return 'delivery';
case 'Abholung':
return 'pickup';
case 'Rücklage':
return 'in-store';
case 'Download':
return 'download';
default:
return undefined;
}
}
import { PriceDTO } from '@generated/swagger/availability-api';
import { ItemDTO } from '@generated/swagger/cat-search-api';
import {
AvailabilityDTO,
OLAAvailabilityDTO,
ShoppingCartItemDTO,
} from '@generated/swagger/checkout-api';
import { GIFT_CARD_TYPE } from '../constants';
import {
ActionType,
Item,
ItemData,
ItemPayloadWithSourceId,
PurchaseOption,
} from './purchase-options.types';
import { OrderTypeFeature } from '@isa/checkout/data-access';
export function isItemDTO(item: any, type: ActionType): item is ItemDTO {
return type === 'add';
}
export function isItemDTOArray(
items: any,
type: ActionType,
): items is ItemDTO[] {
return type === 'add';
}
export function isShoppingCartItemDTO(
item: any,
type: ActionType,
): item is ShoppingCartItemDTO {
return type === 'update';
}
export function isShoppingCartItemDTOArray(
items: any,
type: ActionType,
): items is ShoppingCartItemDTO[] {
return type === 'update';
}
export function mapToItemData(item: Item, type: ActionType): ItemData {
const price: PriceDTO = {};
if (isItemDTO(item, type)) {
price.value = item?.catalogAvailability?.price?.value ?? {};
price.vat = item?.catalogAvailability?.price?.vat ?? {};
return {
ean: item.product.ean,
itemId: item.id,
price,
sourceId: item.id,
quantity: item.quantity ?? 1,
};
} else {
price.value = item?.unitPrice?.value ?? {};
price.vat = item?.unitPrice?.vat ?? {};
return {
ean: item.product.ean,
itemId: Number(item.product.catalogProductNumber),
price,
sourceId: item.id,
quantity: item.quantity ?? 1,
};
}
}
export function isDownload(item: Item): boolean {
return item.product.format === 'DL' || item.product.format === 'EB';
}
export function isGiftCard(item: Item, type: ActionType): boolean {
if (isItemDTO(item, type)) {
return item?.type === GIFT_CARD_TYPE;
} else {
return item?.itemType === GIFT_CARD_TYPE;
}
}
export function isArchive(item: Item, type: ActionType): boolean {
if (isItemDTO(item, type)) {
return item?.features?.some((f) => f.key === 'ARC');
} else {
return !!item?.features?.['ARC'];
}
}
export function mapToItemPayload({
item,
quantity,
availability,
type,
}: {
item: ItemDTO | ShoppingCartItemDTO;
quantity: number;
availability: AvailabilityDTO;
type: ActionType;
}): ItemPayloadWithSourceId {
return {
availabilities: [
mapToOlaAvailability({ item, quantity, availability, type }),
],
id: String(getCatalogId(item, type)),
sourceId: item.id,
};
}
export function getCatalogId(
item: ItemDTO | ShoppingCartItemDTO,
type: ActionType,
): number | string {
return isItemDTO(item, type) ? item.id : item.product.catalogProductNumber;
}
export function mapToOlaAvailability({
availability,
item,
quantity,
type,
}: {
availability: AvailabilityDTO;
item: ItemDTO | ShoppingCartItemDTO;
quantity: number;
type: ActionType;
}): OLAAvailabilityDTO {
return {
status: availability?.availabilityType,
at: availability?.estimatedShippingDate,
ean: item?.product?.ean,
itemId: Number(getCatalogId(item, type)),
format: item?.product?.format,
isPrebooked: availability?.isPrebooked,
logisticianId: availability?.logistician?.id,
price: availability?.price,
qty: quantity,
ssc: availability?.ssc,
sscText: availability?.sscText,
supplierId: availability?.supplier?.id,
supplierProductNumber: availability?.supplierProductNumber,
};
}
export function getOrderTypeForPurchaseOption(
purchaseOption: PurchaseOption,
): OrderTypeFeature | undefined {
switch (purchaseOption) {
case 'delivery':
case 'dig-delivery':
case 'b2b-delivery':
return 'Versand';
case 'pickup':
return 'Abholung';
case 'in-store':
return 'Rücklage';
case 'download':
return 'Download';
default:
return undefined;
}
}
export function getPurchaseOptionForOrderType(
orderType: OrderTypeFeature,
): PurchaseOption | undefined {
switch (orderType) {
case 'Versand':
return 'delivery';
case 'Abholung':
return 'pickup';
case 'Rücklage':
return 'in-store';
case 'Download':
return 'download';
default:
return undefined;
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,178 +1,238 @@
import { Injectable } from '@angular/core';
import { DomainAvailabilityService } from '@domain/availability';
import { DomainCheckoutService } from '@domain/checkout';
import {
AddToShoppingCartDTO,
AvailabilityDTO,
EntityDTOContainerOfDestinationDTO,
ItemPayload,
ItemsResult,
ShoppingCartDTO,
UpdateShoppingCartItemDTO,
} from '@generated/swagger/checkout-api';
import { Observable } from 'rxjs';
import { map, shareReplay, take } from 'rxjs/operators';
import { Branch, ItemData } from './purchase-options.types';
import { memorize } from '@utils/common';
import { AuthService } from '@core/auth';
import { ApplicationService } from '@core/application';
import { DomainOmsService } from '@domain/oms';
@Injectable({ providedIn: 'root' })
export class PurchaseOptionsService {
constructor(
private _availabilityService: DomainAvailabilityService,
private _checkoutService: DomainCheckoutService,
private _omsService: DomainOmsService,
private _auth: AuthService,
private _app: ApplicationService,
) {}
getVats$() {
return this._omsService.getVATs();
}
getSelectedBranchForProcess(processId: number): Observable<Branch> {
return this._app.getSelectedBranch$(processId).pipe(take(1), shareReplay(1));
}
getCustomerFeatures(processId: number): Observable<Record<string, string>> {
return this._checkoutService.getCustomerFeatures({ processId }).pipe(take(1), shareReplay(1));
}
@memorize()
fetchDefaultBranch(): Observable<Branch> {
return this.getBranch({ branchNumber: this._auth.getClaimByKey('branch_no') }).pipe(take(1), shareReplay(1));
}
fetchPickupAvailability(item: ItemData, quantity: number, branch: Branch): Observable<AvailabilityDTO> {
return this._availabilityService
.getPickUpAvailability({
branch,
quantity,
item,
})
.pipe(map((res) => (Array.isArray(res) ? res[0] : undefined)));
}
fetchInStoreAvailability(item: ItemData, quantity: number, branch: Branch): Observable<AvailabilityDTO> {
return this._availabilityService.getTakeAwayAvailability({
item,
quantity,
branch,
});
}
fetchDeliveryAvailability(item: ItemData, quantity: number): Observable<AvailabilityDTO> {
return this._availabilityService.getDeliveryAvailability({
item,
quantity,
});
}
fetchDigDeliveryAvailability(item: ItemData, quantity: number): Observable<AvailabilityDTO> {
return this._availabilityService.getDigDeliveryAvailability({
item,
quantity,
});
}
fetchB2bDeliveryAvailability(item: ItemData, quantity: number): Observable<AvailabilityDTO> {
return this._availabilityService.getB2bDeliveryAvailability({
item,
quantity,
});
}
fetchDownloadAvailability(item: ItemData): Observable<AvailabilityDTO> {
return this._availabilityService.getDownloadAvailability({
item,
});
}
isAvailable(availability: AvailabilityDTO): boolean {
return this._availabilityService.isAvailable({ availability });
}
fetchCanAdd(processId: number, orderType: string, payload: ItemPayload[]): Observable<ItemsResult[]> {
return this._checkoutService.canAddItems({
processId,
orderType,
payload,
});
}
removeItemFromShoppingCart(processId: number, shoppingCartItemId: number): Promise<ShoppingCartDTO> {
return this._checkoutService
.updateItemInShoppingCart({
processId,
shoppingCartItemId,
update: {
availability: null,
quantity: 0,
},
})
.toPromise();
}
getInStoreDestination(branch: Branch): EntityDTOContainerOfDestinationDTO {
return {
data: { target: 1, targetBranch: { id: branch.id } },
};
}
getPickupDestination(branch: Branch): EntityDTOContainerOfDestinationDTO {
return {
data: { target: 1, targetBranch: { id: branch.id } },
};
}
getDeliveryDestination(availability: AvailabilityDTO): EntityDTOContainerOfDestinationDTO {
return {
data: { target: 2, logistician: availability?.logistician },
};
}
getDownloadDestination(availability: AvailabilityDTO): EntityDTOContainerOfDestinationDTO {
return {
data: { target: 16, logistician: availability?.logistician },
};
}
addItemToShoppingCart(processId: number, items: AddToShoppingCartDTO[]) {
return this._checkoutService.addItemToShoppingCart({
processId,
items,
});
}
updateItemInShoppingCart(processId: number, shoppingCartItemId: number, payload: UpdateShoppingCartItemDTO) {
return this._checkoutService.updateItemInShoppingCart({
processId,
shoppingCartItemId,
update: payload,
});
}
@memorize({ comparer: (_) => true })
getBranches(): Observable<Branch[]> {
return this._availabilityService.getBranches().pipe(
map((branches) => {
return branches.filter((branch) => branch.isShippingEnabled == true);
}),
shareReplay(1),
);
}
getBranch(params: { id: number }): Observable<Branch>;
getBranch(params: { branchNumber: string }): Observable<Branch>;
getBranch(params: { id: number; branchNumber: string }): Observable<Branch>;
getBranch(params: { id?: number; branchNumber?: string }): Observable<Branch> {
return this.getBranches().pipe(
map((branches) => {
const branch = branches.find((branch) => branch.id == params.id || branch.branchNumber == params.branchNumber);
return branch;
}),
);
}
}
import { inject, Injectable } from '@angular/core';
import { DomainAvailabilityService } from '@domain/availability';
import { DomainCheckoutService } from '@domain/checkout';
import {
AddToShoppingCartDTO,
AvailabilityDTO,
EntityDTOContainerOfDestinationDTO,
ItemPayload,
ItemsResult,
ShoppingCartDTO,
UpdateShoppingCartItemDTO,
} from '@generated/swagger/checkout-api';
import { Observable } from 'rxjs';
import { map, shareReplay, take } from 'rxjs/operators';
import { Branch, ItemData } from './purchase-options.types';
import { memorize } from '@utils/common';
import { AuthService } from '@core/auth';
import { ApplicationService } from '@core/application';
import { DomainOmsService } from '@domain/oms';
import {
OrderTypeFeature,
PurchaseOptionsFacade,
} from '@isa/checkout/data-access';
@Injectable({ providedIn: 'root' })
export class PurchaseOptionsService {
#purchaseOptionsFacade = inject(PurchaseOptionsFacade);
constructor(
private _availabilityService: DomainAvailabilityService,
private _checkoutService: DomainCheckoutService,
private _omsService: DomainOmsService,
private _auth: AuthService,
) {}
getVats$() {
return this._omsService.getVATs();
}
getCustomerFeatures(processId: number): Observable<Record<string, string>> {
return this._checkoutService
.getCustomerFeatures({ processId })
.pipe(take(1), shareReplay(1));
}
@memorize()
fetchDefaultBranch(): Observable<Branch> {
return this.getBranch({
branchNumber: this._auth.getClaimByKey('branch_no'),
}).pipe(take(1), shareReplay(1));
}
fetchPickupAvailability(
item: ItemData,
quantity: number,
branch: Branch,
): Observable<AvailabilityDTO> {
return this._availabilityService
.getPickUpAvailability({
branch,
quantity,
item,
})
.pipe(map((res) => (Array.isArray(res) ? res[0] : undefined)));
}
fetchInStoreAvailability(
item: ItemData,
quantity: number,
branch: Branch,
): Observable<AvailabilityDTO> {
return this._availabilityService.getTakeAwayAvailability({
item,
quantity,
branch,
});
}
fetchDeliveryAvailability(
item: ItemData,
quantity: number,
): Observable<AvailabilityDTO> {
return this._availabilityService.getDeliveryAvailability({
item,
quantity,
});
}
fetchDigDeliveryAvailability(
item: ItemData,
quantity: number,
): Observable<AvailabilityDTO> {
return this._availabilityService.getDigDeliveryAvailability({
item,
quantity,
});
}
fetchB2bDeliveryAvailability(
item: ItemData,
quantity: number,
): Observable<AvailabilityDTO> {
return this._availabilityService.getB2bDeliveryAvailability({
item,
quantity,
});
}
fetchDownloadAvailability(item: ItemData): Observable<AvailabilityDTO> {
return this._availabilityService.getDownloadAvailability({
item,
});
}
isAvailable(availability: AvailabilityDTO): boolean {
return this._availabilityService.isAvailable({ availability });
}
fetchCanAdd(
shoppingCartId: number,
orderType: OrderTypeFeature,
payload: ItemPayload[],
customerFeatures: Record<string, string>,
): Promise<ItemsResult[]> {
return this.#purchaseOptionsFacade.canAddItems({
shoppingCartId,
payload: payload.map((p) => ({
...p,
customerFeatures: customerFeatures,
orderType: orderType,
})),
});
}
removeItemFromShoppingCart(
shoppingCartId: number,
shoppingCartItemId: number,
): Promise<ShoppingCartDTO> {
const shoppingCart = this.#purchaseOptionsFacade.removeItem({
shoppingCartId,
shoppingCartItemId,
});
return shoppingCart;
}
getInStoreDestination(branch: Branch): EntityDTOContainerOfDestinationDTO {
return {
data: { target: 1, targetBranch: { id: branch.id } },
};
}
getPickupDestination(branch: Branch): EntityDTOContainerOfDestinationDTO {
return {
data: { target: 1, targetBranch: { id: branch.id } },
};
}
getDeliveryDestination(
availability: AvailabilityDTO,
): EntityDTOContainerOfDestinationDTO {
return {
data: { target: 2, logistician: availability?.logistician },
};
}
getDownloadDestination(
availability: AvailabilityDTO,
): EntityDTOContainerOfDestinationDTO {
return {
data: { target: 16, logistician: availability?.logistician },
};
}
async addItemToShoppingCart(
shoppingCartId: number,
items: AddToShoppingCartDTO[],
) {
const shoppingCart = await this.#purchaseOptionsFacade.addItem({
shoppingCartId,
items,
});
console.log('added item to cart', { shoppingCart });
// FIX BUILD ERRORS
// this._checkoutService.updateProcessCount(
// this._app.activatedProcessId,
// shoppingCart,
// );
return shoppingCart;
}
async updateItemInShoppingCart(
shoppingCartId: number,
shoppingCartItemId: number,
payload: UpdateShoppingCartItemDTO,
) {
const shoppingCart = await this.#purchaseOptionsFacade.updateItem({
shoppingCartId,
shoppingCartItemId,
values: payload,
});
console.log('updated item in cart', { shoppingCart });
// FIX BUILD ERRORS
// this._checkoutService.updateProcessCount(
// this._app.activatedProcessId,
// shoppingCart,
// );
}
@memorize({ comparer: (_) => true })
getBranches(): Observable<Branch[]> {
return this._availabilityService.getBranches().pipe(
map((branches) => {
return branches.filter((branch) => branch.isShippingEnabled == true);
}),
shareReplay(1),
);
}
getBranch(params: { id: number }): Observable<Branch>;
getBranch(params: { branchNumber: string }): Observable<Branch>;
getBranch(params: { id: number; branchNumber: string }): Observable<Branch>;
getBranch(params: {
id?: number;
branchNumber?: string;
}): Observable<Branch> {
return this.getBranches().pipe(
map((branches) => {
const branch = branches.find(
(branch) =>
branch.id == params.id ||
branch.branchNumber == params.branchNumber,
);
return branch;
}),
);
}
}

View File

@@ -1,38 +1,42 @@
import { PriceDTO } from '@generated/swagger/checkout-api';
import {
ActionType,
Availability,
Branch,
CanAdd,
FetchingAvailability,
Item,
PurchaseOption,
} from './purchase-options.types';
export interface PurchaseOptionsState {
type: ActionType;
processId: number;
items: Item[];
availabilities: Availability[];
canAddResults: CanAdd[];
purchaseOption: PurchaseOption;
selectedItemIds: number[];
prices: { [itemId: number]: PriceDTO };
defaultBranch: Branch;
pickupBranch: Branch;
inStoreBranch: Branch;
customerFeatures: Record<string, string>;
fetchingAvailabilities: Array<FetchingAvailability>;
}
import { PriceDTO } from '@generated/swagger/checkout-api';
import {
ActionType,
Availability,
Branch,
CanAdd,
FetchingAvailability,
Item,
PurchaseOption,
} from './purchase-options.types';
export interface PurchaseOptionsState {
shoppingCartId: number;
type: ActionType;
items: Item[];
availabilities: Availability[];
canAddResults: CanAdd[];
purchaseOption: PurchaseOption;
selectedItemIds: number[];
prices: { [itemId: number]: PriceDTO };
defaultBranch: Branch;
pickupBranch: Branch;
inStoreBranch: Branch;
customerFeatures: Record<string, string>;
fetchingAvailabilities: Array<FetchingAvailability>;
useRedemptionPoints: boolean;
disabledPurchaseOptions: PurchaseOption[];
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,73 @@
import { ItemData as AvailabilityItemData } from '@domain/availability';
import { ItemDTO } from '@generated/swagger/cat-search-api';
import { AvailabilityDTO, BranchDTO, ItemPayload, ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
export type ActionType = 'add' | 'update';
export type PurchaseOption =
| 'delivery'
| 'dig-delivery'
| 'b2b-delivery'
| 'pickup'
| 'in-store'
| 'download'
| 'catalog';
export type OrderType = 'Rücklage' | 'Abholung' | 'Versand' | 'Download';
export type ItemDTOWithQuantity = ItemDTO & { quantity?: number };
export type Item = ItemDTOWithQuantity | ShoppingCartItemDTO;
export type Branch = BranchDTO;
export type Availability = {
itemId: number;
purchaseOption: PurchaseOption;
data: AvailabilityDTO & { priceMaintained?: boolean; orderDeadline?: string; firstDayOfSale?: string };
ean?: string;
};
export type ItemData = AvailabilityItemData & { sourceId: number; quantity: number };
export type ItemPayloadWithSourceId = ItemPayload & { sourceId: number };
export type CanAdd = { itemId: number; purchaseOption: PurchaseOption; canAdd: boolean; message?: string };
export type FetchingAvailability = { id: string; itemId: number; purchaseOption?: PurchaseOption };
import { ItemData as AvailabilityItemData } from '@domain/availability';
import { ItemDTO } from '@generated/swagger/cat-search-api';
import {
AvailabilityDTO,
BranchDTO,
ItemPayload,
ShoppingCartItemDTO,
} from '@generated/swagger/checkout-api';
/**
* Action type for the purchase options modal.
* - 'add': Adding new items to the shopping cart
* - 'update': Updating existing items in the shopping cart
*/
export type ActionType = 'add' | 'update';
/**
* Available purchase options for item delivery/fulfillment.
*
* Each option represents a different way customers can receive their items:
* - `delivery`: Standard home/address delivery
* - `dig-delivery`: Digital delivery (special handling)
* - `b2b-delivery`: Business-to-business delivery
* - `pickup`: Pickup at a branch location
* - `in-store`: Reserve and collect in store
* - `download`: Digital download (e-books, digital content)
* - `catalog`: Catalog availability (reference only)
*/
export type PurchaseOption =
| 'delivery'
| 'dig-delivery'
| 'b2b-delivery'
| 'pickup'
| 'in-store'
| 'download'
| 'catalog';
export type ItemDTOWithQuantity = ItemDTO & { quantity?: number };
export type Item = ItemDTOWithQuantity | ShoppingCartItemDTO;
export type Branch = BranchDTO;
export type Availability = {
itemId: number;
purchaseOption: PurchaseOption;
data: AvailabilityDTO & {
priceMaintained?: boolean;
orderDeadline?: string;
firstDayOfSale?: string;
};
ean?: string;
};
export type ItemData = AvailabilityItemData & {
sourceId: number;
quantity: number;
};
export type ItemPayloadWithSourceId = ItemPayload & { sourceId: number };
export type CanAdd = {
itemId: number;
purchaseOption: PurchaseOption;
canAdd: boolean;
message?: string;
};
export type FetchingAvailability = {
id: string;
itemId: number;
purchaseOption?: PurchaseOption;
};

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,47 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ArticleDetailsComponent } from './article-details.component';
import { ProductImageModule } from '@cdn/product-image';
import { UiIconModule } from '@ui/icon';
import { RouterModule } from '@angular/router';
import { UiStarsModule } from '@ui/stars';
import { UiSliderModule } from '@ui/slider';
import { ArticleRecommendationsComponent } from './recommendations/article-recommendations.component';
import { PipesModule } from '../shared/pipes/pipes.module';
import { UiTooltipModule } from '@ui/tooltip';
import { UiCommonModule } from '@ui/common';
import { OrderDeadlinePipeModule } from '@shared/pipes/order-deadline';
import { IconModule } from '@shared/components/icon';
import { ArticleDetailsTextComponent } from './article-details-text/article-details-text.component';
import { IconBadgeComponent } from 'apps/isa-app/src/shared/components/icon/badge/icon-badge.component';
import { MatomoModule } from 'ngx-matomo-client';
@NgModule({
imports: [
CommonModule,
ProductImageModule,
UiIconModule,
RouterModule,
UiStarsModule,
UiSliderModule,
UiCommonModule,
UiTooltipModule,
IconModule,
PipesModule,
OrderDeadlinePipeModule,
ArticleDetailsTextComponent,
IconBadgeComponent,
MatomoModule,
],
exports: [ArticleDetailsComponent, ArticleRecommendationsComponent],
declarations: [ArticleDetailsComponent, ArticleRecommendationsComponent],
})
export class ArticleDetailsModule {}
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ArticleDetailsComponent } from './article-details.component';
import { ProductImageModule } from '@cdn/product-image';
import { UiIconModule } from '@ui/icon';
import { RouterModule } from '@angular/router';
import { UiStarsModule } from '@ui/stars';
import { UiSliderModule } from '@ui/slider';
import { ArticleRecommendationsComponent } from './recommendations/article-recommendations.component';
import { PipesModule } from '../shared/pipes/pipes.module';
import { UiTooltipModule } from '@ui/tooltip';
import { UiCommonModule } from '@ui/common';
import { OrderDeadlinePipeModule } from '@shared/pipes/order-deadline';
import { IconModule } from '@shared/components/icon';
import { ArticleDetailsTextComponent } from './article-details-text/article-details-text.component';
import { IconBadgeComponent } from 'apps/isa-app/src/shared/components/icon/badge/icon-badge.component';
import { MatomoModule } from 'ngx-matomo-client';
import {
RewardSelectionService,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
@NgModule({
imports: [
CommonModule,
ProductImageModule,
UiIconModule,
RouterModule,
UiStarsModule,
UiSliderModule,
UiCommonModule,
UiTooltipModule,
IconModule,
PipesModule,
OrderDeadlinePipeModule,
ArticleDetailsTextComponent,
IconBadgeComponent,
MatomoModule,
],
exports: [ArticleDetailsComponent, ArticleRecommendationsComponent],
declarations: [ArticleDetailsComponent, ArticleRecommendationsComponent],
providers: [
RewardSelectionService,
RewardSelectionPopUpService,
],
})
export class ArticleDetailsModule {}

View File

@@ -22,12 +22,21 @@ import { UiErrorModalComponent, UiModalService } from '@ui/modal';
import { CacheService } from '@core/cache';
import { isEqual } from 'lodash';
import { BehaviorSubject, combineLatest, Subscription } from 'rxjs';
import { debounceTime, first, map, switchMap, withLatestFrom } from 'rxjs/operators';
import {
debounceTime,
first,
map,
switchMap,
withLatestFrom,
} from 'rxjs/operators';
import { ArticleSearchService } from '../article-search.store';
import { AddedToCartModalComponent } from './added-to-cart-modal/added-to-cart-modal.component';
import { SearchResultItemComponent } from './search-result-item.component';
import { ProductCatalogNavigationService } from '@shared/services/navigation';
import { Filter, FilterInputGroupMainComponent } from '@shared/components/filter';
import {
Filter,
FilterInputGroupMainComponent,
} from '@shared/components/filter';
import { DomainAvailabilityService, ItemData } from '@domain/availability';
import { asapScheduler } from 'rxjs';
import { ShellService } from '@shared/shell';
@@ -39,8 +48,11 @@ import { ShellService } from '@shared/shell';
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterViewInit {
@ViewChildren(SearchResultItemComponent) listItems: QueryList<SearchResultItemComponent>;
export class ArticleSearchResultsComponent
implements OnInit, OnDestroy, AfterViewInit
{
@ViewChildren(SearchResultItemComponent)
listItems: QueryList<SearchResultItemComponent>;
@ViewChild(CdkVirtualScrollViewport, { static: false })
scrollContainer: CdkVirtualScrollViewport;
@@ -59,7 +71,9 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
selectedItems$ = combineLatest([this.results$, this.selectedItemIds$]).pipe(
map(([items, selectedItemIds]) => {
return items?.filter((item) => selectedItemIds?.find((selectedItemId) => item.id === selectedItemId));
return items?.filter((item) =>
selectedItemIds?.find((selectedItemId) => item.id === selectedItemId),
);
}),
);
@@ -81,7 +95,10 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
return this._environment.matchDesktopLarge();
}
hasFilter$ = combineLatest([this.searchService.filter$, this.searchService.defaultSettings$]).pipe(
hasFilter$ = combineLatest([
this.searchService.filter$,
this.searchService.defaultSettings$,
]).pipe(
map(([filter, defaultFilter]) => {
const filterQueryParams = filter?.getQueryParams();
return !isEqual(
@@ -100,11 +117,15 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
get filterQueryParams() {
return this.cleanupQueryParams(this.searchService?.filter?.getQueryParams());
return this.cleanupQueryParams(
this.searchService?.filter?.getQueryParams(),
);
}
get primaryOutletActive$() {
return this._environment.matchDesktop$.pipe(map((matches) => matches && this.route.outlet === 'primary'));
return this._environment.matchDesktop$.pipe(
map((matches) => matches && this.route.outlet === 'primary'),
);
}
private readonly SCROLL_INDEX_TOKEN = 'CATALOG_RESULTS_LIST_SCROLL_INDEX';
@@ -129,28 +150,42 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
ngOnInit() {
this.subscriptions.add(
combineLatest([this.application.activatedProcessId$, this.route.queryParams])
combineLatest([
this.application.activatedProcessId$,
this.route.queryParams,
])
.pipe(
debounceTime(0),
switchMap(([processId, queryParams]) =>
this.application
.getSelectedBranch$(processId)
.pipe(map((selectedBranch) => ({ processId, queryParams, selectedBranch }))),
this.application.getSelectedBranch$(processId).pipe(
map((selectedBranch) => ({
processId,
queryParams,
selectedBranch,
})),
),
),
)
.subscribe(async ({ processId, queryParams, selectedBranch }) => {
const processChanged = processId !== this.searchService.processId;
const branchChanged = selectedBranch?.id !== this.searchService?.selectedBranch?.id;
const branchChanged =
selectedBranch?.id !== this.searchService?.selectedBranch?.id;
if (processChanged) {
if (!!this.searchService.processId && this.searchService.filter instanceof Filter) {
if (
!!this.searchService.processId &&
this.searchService.filter instanceof Filter
) {
this.cacheCurrentData(
this.searchService.processId,
this.searchService.filter.getQueryParams(),
this.searchService?.selectedBranch?.id,
);
this.updateBreadcrumbs(this.searchService.processId, this.searchService.filter.getQueryParams());
this.updateBreadcrumbs(
this.searchService.processId,
this.searchService.filter.getQueryParams(),
);
}
this.searchService.setProcess(processId);
}
@@ -169,9 +204,20 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
this.scrollToItem(await this._getScrollIndexFromCache());
}
if (!isEqual(cleanQueryParams, this.cleanupQueryParams(this.searchService.filter.getQueryParams()))) {
if (
!isEqual(
cleanQueryParams,
this.cleanupQueryParams(
this.searchService.filter.getQueryParams(),
),
)
) {
await this.searchService.setDefaultFilter(queryParams);
const data = await this.getCachedData(processId, queryParams, selectedBranch?.id);
const data = await this.getCachedData(
processId,
queryParams,
selectedBranch?.id,
);
if (data.items?.length > 0) {
this.searchService.setItems(data.items);
@@ -179,21 +225,29 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
if (
data.items?.length === 0 &&
this.route?.parent?.children?.find((childRoute) => childRoute?.outlet === 'side')?.snapshot?.routeConfig
?.path !== 'filter'
this.route?.parent?.children?.find(
(childRoute) => childRoute?.outlet === 'side',
)?.snapshot?.routeConfig?.path !== 'filter'
) {
this.search({ clear: true });
} else {
const selectedItemIds: Array<string> = queryParams?.selected_item_ids?.split(',') ?? [];
const selectedItemIds: Array<string> =
queryParams?.selected_item_ids?.split(',') ?? [];
for (const id of selectedItemIds) {
if (id) {
this.searchService.setSelected({ selected: true, itemId: Number(id) });
this.searchService.setSelected({
selected: true,
itemId: Number(id),
});
}
}
}
}
const process = await this.application.getProcessById$(processId).pipe(first()).toPromise();
const process = await this.application
.getProcessById$(processId)
.pipe(first())
.toPromise();
if (process) {
await this.updateBreadcrumbs(processId, queryParams);
await this.createBreadcrumb(processId, queryParams);
@@ -240,7 +294,10 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
})
.navigate();
}
} else if (searchCompleted?.clear || this.route.outlet === 'primary') {
} else if (
searchCompleted?.clear ||
this.route.outlet === 'primary'
) {
const ean = this.route?.snapshot?.params?.ean;
if (ean) {
@@ -253,7 +310,9 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
.navigate();
} else {
await this._navigationService
.getArticleSearchResultsPath(processId, { queryParams: params })
.getArticleSearchResultsPath(processId, {
queryParams: params,
})
.navigate();
}
}
@@ -266,7 +325,9 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
this.searchService.searchStarted.subscribe(async (options) => {
if (!options?.clear) {
const queryParams = {
...this.cleanupQueryParams(this.searchService.filter.getQueryParams()),
...this.cleanupQueryParams(
this.searchService.filter.getQueryParams(),
),
main_qs: this.sharedFilterInputGroupMain?.uiInput?.value,
};
@@ -281,11 +342,19 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
private _addScrollIndexToCache(index: number): void {
this.cache.set<number>({ processId: this.getProcessId(), token: this.SCROLL_INDEX_TOKEN }, index);
this.cache.set<number>(
{ processId: this.getProcessId(), token: this.SCROLL_INDEX_TOKEN },
index,
);
}
private async _getScrollIndexFromCache(): Promise<number> {
return (await this.cache.get<number>({ processId: this.getProcessId(), token: this.SCROLL_INDEX_TOKEN })) ?? 0;
return (
(await this.cache.get<number>({
processId: this.getProcessId(),
token: this.SCROLL_INDEX_TOKEN,
})) ?? 0
);
}
async scrollToItem(i?: number) {
@@ -303,12 +372,14 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
scrolledIndexChange(index: number) {
const completeListFetched = this.searchService.items.length === this.searchService.hits;
const completeListFetched =
this.searchService.items.length === this.searchService.hits;
if (
index &&
!completeListFetched &&
this.searchService.items.length <= this.scrollContainer?.getRenderedRange()?.end
this.searchService.items.length <=
this.scrollContainer?.getRenderedRange()?.end
) {
this.search({ clear: false });
}
@@ -326,7 +397,10 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
this.searchService.filter.getQueryParams(),
this.searchService?.selectedBranch?.id,
);
await this.updateBreadcrumbs(this.searchService.processId, this.searchService.filter.getQueryParams());
await this.updateBreadcrumbs(
this.searchService.processId,
this.searchService.filter.getQueryParams(),
);
this.unselectAll();
}
@@ -345,7 +419,15 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
return clean;
}
search({ filter, clear = false, orderBy = false }: { filter?: Filter; clear?: boolean; orderBy?: boolean }) {
search({
filter,
clear = false,
orderBy = false,
}: {
filter?: Filter;
clear?: boolean;
orderBy?: boolean;
}) {
if (filter) {
this.sharedFilterInputGroupMain.cancelAutocomplete();
}
@@ -354,19 +436,28 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
getDetailsPath(itemId: number) {
return this._navigationService.getArticleDetailsPath({ processId: this.application.activatedProcessId, itemId })
.path;
return this._navigationService.getArticleDetailsPath({
processId: this.application.activatedProcessId,
itemId,
}).path;
}
async updateBreadcrumbs(
processId: number = this.searchService.processId,
queryParams: Record<string, string> = this.searchService.filter?.getQueryParams(),
queryParams: Record<
string,
string
> = this.searchService.filter?.getQueryParams(),
) {
const selected_item_ids = this.searchService?.selectedItemIds?.toString();
if (queryParams) {
const crumbs = await this.breadcrumb
.getBreadcrumbsByKeyAndTags$(processId, ['catalog', 'filter', 'results'])
.getBreadcrumbsByKeyAndTags$(processId, [
'catalog',
'filter',
'results',
])
.pipe(first())
.toPromise();
@@ -382,13 +473,18 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
}
async createBreadcrumb(processId: number, queryParams: Record<string, string>) {
async createBreadcrumb(
processId: number,
queryParams: Record<string, string>,
) {
if (queryParams) {
const name = queryParams.main_qs ? queryParams.main_qs : 'Alle Artikel';
await this.breadcrumb.addBreadcrumbIfNotExists({
key: processId,
name,
path: this._navigationService.getArticleSearchResultsPath(processId, { queryParams }).path,
path: this._navigationService.getArticleSearchResultsPath(processId, {
queryParams,
}).path,
params: queryParams,
section: 'customer',
tags: ['catalog', 'filter', 'results'],
@@ -405,8 +501,16 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
crumbs?.forEach((crumb) => this.breadcrumb.removeBreadcrumb(crumb.id));
}
cacheCurrentData(processId: number, params: Record<string, string> = {}, branchId: number) {
const qparams = this.cleanupQueryParams({ ...params, processId: String(processId), branchId: String(branchId) });
cacheCurrentData(
processId: number,
params: Record<string, string> = {},
branchId: number,
) {
const qparams = this.cleanupQueryParams({
...params,
processId: String(processId),
branchId: String(branchId),
});
this.cache.set(qparams, {
items: this.searchService.items,
@@ -414,8 +518,16 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
});
}
async getCachedData(processId: number, params: Record<string, string> = {}, branchId: number) {
const qparams = this.cleanupQueryParams({ ...params, processId: String(processId), branchId: String(branchId) });
async getCachedData(
processId: number,
params: Record<string, string> = {},
branchId: number,
) {
const qparams = this.cleanupQueryParams({
...params,
processId: String(processId),
branchId: String(branchId),
});
const cacheData = await this.cache.get<{
items: ItemDTO[];
hits: number;
@@ -452,7 +564,12 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
unselectAll() {
this.listItems.forEach((listItem) => this.searchService.setSelected({ selected: false, itemId: listItem.item.id }));
this.listItems.forEach((listItem) =>
this.searchService.setSelected({
selected: false,
itemId: listItem.item.id,
}),
);
this.searchService.patchState({ selectedItemIds: [] });
}
@@ -474,39 +591,46 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
availability: {
availabilityType: item?.catalogAvailability?.status,
price: item?.catalogAvailability?.price,
supplierProductNumber: item?.ids?.dig ? String(item?.ids?.dig) : item?.product?.supplierProductNumber,
supplierProductNumber: item?.ids?.dig
? String(item?.ids?.dig)
: item?.product?.supplierProductNumber,
},
product: {
catalogProductNumber: String(item?.id),
...item?.product,
},
itemType: item?.type,
promotion: { points: item?.promoPoints },
promotion: { value: item?.promoPoints },
};
}
async addItemsToCart(item?: ItemDTO) {
const selectedItems = !item ? await this.selectedItems$.pipe(first()).toPromise() : [item];
const selectedItems = !item
? await this.selectedItems$.pipe(first()).toPromise()
: [item];
const items: AddToShoppingCartDTO[] = [];
const canAddItemsPayload = [];
for (const item of selectedItems) {
const isDownload = item?.product?.format === 'EB' || item?.product?.format === 'DL';
const isDownload =
item?.product?.format === 'EB' || item?.product?.format === 'DL';
const price = item?.catalogAvailability?.price;
const shoppingCartItem: AddToShoppingCartDTO = {
quantity: 1,
availability: {
availabilityType: item?.catalogAvailability?.status,
price,
supplierProductNumber: item?.ids?.dig ? String(item.ids?.dig) : item?.product?.supplierProductNumber,
supplierProductNumber: item?.ids?.dig
? String(item.ids?.dig)
: item?.product?.supplierProductNumber,
},
product: {
catalogProductNumber: String(item?.id),
...item?.product,
},
itemType: item.type,
promotion: { points: item?.promoPoints },
promotion: { value: item?.promoPoints },
};
if (isDownload) {
@@ -519,9 +643,14 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
.getDownloadAvailability({ item: downloadItem })
.pipe(first())
.toPromise();
shoppingCartItem.destination = { data: { target: 16, logistician: downloadAvailability?.logistician } };
shoppingCartItem.destination = {
data: { target: 16, logistician: downloadAvailability?.logistician },
};
if (downloadAvailability) {
shoppingCartItem.availability = { ...shoppingCartItem.availability, ...downloadAvailability };
shoppingCartItem.availability = {
...shoppingCartItem.availability,
...downloadAvailability,
};
}
canAddItemsPayload.push({
availabilities: [{ ...item.catalogAvailability, format: 'DL' }],
@@ -546,7 +675,10 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
if (response) {
const cantAdd = (response as any)?.filter((r) => r.status >= 2);
if (cantAdd?.length > 0) {
this.openModal({ itemLength: cantAdd.length, canAddMessage: cantAdd[0].message });
this.openModal({
itemLength: cantAdd.length,
canAddMessage: cantAdd[0].message,
});
return;
}
}
@@ -571,7 +703,15 @@ export class ArticleSearchResultsComponent implements OnInit, OnDestroy, AfterVi
}
}
openModal({ itemLength, canAddMessage, error }: { itemLength: number; canAddMessage?: string; error?: Error }) {
openModal({
itemLength,
canAddMessage,
error,
}: {
itemLength: number;
canAddMessage?: string;
error?: Error;
}) {
const modal = this._uiModal.open({
title:
!error && !canAddMessage

View File

@@ -114,15 +114,21 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
readonly processId$ = this._application.activatedProcessId$;
readonly customer$ = this.processId$.pipe(switchMap((processId) => this._checkoutService.getBuyer({ processId })));
readonly customer$ = this.processId$.pipe(
switchMap((processId) => this._checkoutService.getBuyer({ processId })),
);
readonly customerFeatures$ = this.processId$.pipe(
switchMap((processId) => this._checkoutService.getCustomerFeatures({ processId })),
switchMap((processId) =>
this._checkoutService.getCustomerFeatures({ processId }),
),
);
readonly customerFilter$ = this.customerFeatures$.pipe(
withLatestFrom(this.processId$),
switchMap(([customerFeatures, processId]) => this._checkoutService.canSetCustomer({ processId, customerFeatures })),
switchMap(([customerFeatures, processId]) =>
this._checkoutService.canSetCustomer({ processId, customerFeatures }),
),
map((res) => res.filter),
);
@@ -169,7 +175,11 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
tapResponse(
(res) => {
const item = res.result[0];
if (!!item && item?.product?.format !== 'EB' && item?.product?.format !== 'DL') {
if (
!!item &&
item?.product?.format !== 'EB' &&
item?.product?.format !== 'DL'
) {
this.patchState({
item: res.result[0],
message: '',
@@ -229,12 +239,22 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
updateCart = this.effect((cb$: Observable<Function>) =>
cb$.pipe(
tap((_) => this.patchState({ fetching: true })),
withLatestFrom(this.processId$, this.addToCartItem$, this.shoppingCartItemId$),
withLatestFrom(
this.processId$,
this.addToCartItem$,
this.shoppingCartItemId$,
),
switchMap(([cb, processId, newItem, shoppingCartItemId]) => {
const availability = newItem.availability;
const quantity = newItem.quantity;
const destination = newItem.destination;
return this.updateCartRequest({ processId, shoppingCartItemId, availability, quantity, destination }).pipe(
return this.updateCartRequest({
processId,
shoppingCartItemId,
availability,
quantity,
destination,
}).pipe(
tapResponse(
(res) => {
this.patchState({
@@ -270,7 +290,10 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
}
addToCartRequest(processId: number, newItem: AddToShoppingCartDTO) {
return this._checkoutService.addItemToShoppingCart({ processId, items: [newItem] });
return this._checkoutService.addItemToShoppingCart({
processId,
items: [newItem],
});
}
updateCartRequest({
@@ -297,7 +320,11 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
});
}
async createAddToCartItem(control: UntypedFormGroup, branch: BranchDTO, update?: boolean) {
async createAddToCartItem(
control: UntypedFormGroup,
branch: BranchDTO,
update?: boolean,
) {
let item: ItemDTO;
const quantity = Number(control.get('quantity').value);
const price = this._createPriceDTO(control);
@@ -305,7 +332,11 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
// Check if item exists or ean inside the control changed in the meantime
if (!!this.item && this.item.product.ean === control.get('ean').value) {
item = this.item;
promoPoints = await this._getPromoPoints({ itemId: item.id, quantity, price: price.value.value });
promoPoints = await this._getPromoPoints({
itemId: item.id,
quantity,
price: price.value.value,
});
} else {
item = undefined;
}
@@ -316,21 +347,33 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
quantity,
availability,
product,
promotion: item ? { points: promoPoints } : undefined,
promotion: item ? { value: promoPoints } : undefined,
destination: {
data: { target: 1, targetBranch: { id: branch?.id } },
},
itemType: this.item?.type ?? this.get((s) => s.shoppingCartItem)?.itemType,
itemType:
this.item?.type ?? this.get((s) => s.shoppingCartItem)?.itemType,
};
if (update) {
const existingItem = this.get((s) => s.shoppingCartItem);
this.patchState({ addToCartItem: newItem, shoppingCartItemId: existingItem?.id });
this.patchState({
addToCartItem: newItem,
shoppingCartItemId: existingItem?.id,
});
}
this.patchState({ addToCartItem: newItem });
}
private async _getPromoPoints({ itemId, quantity, price }: { itemId: number; quantity: number; price: number }) {
private async _getPromoPoints({
itemId,
quantity,
price,
}: {
itemId: number;
quantity: number;
price: number;
}) {
let points: number;
try {
points = await this._catalogService
@@ -371,7 +414,13 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
};
}
private _createAvailabilityDTO({ price, control }: { price: PriceDTO; control: UntypedFormGroup }): AvailabilityDTO {
private _createAvailabilityDTO({
price,
control,
}: {
price: PriceDTO;
control: UntypedFormGroup;
}): AvailabilityDTO {
return {
availabilityType: 1024,
supplier: {
@@ -383,7 +432,13 @@ export class CheckoutDummyStore extends ComponentStore<CheckoutDummyState> {
};
}
private _createProductDTO({ item, control }: { item?: ItemDTO; control: UntypedFormGroup }): ProductDTO {
private _createProductDTO({
item,
control,
}: {
item?: ItemDTO;
control: UntypedFormGroup;
}): ProductDTO {
const formValues: Partial<ProductDTO> = {
ean: control.get('ean').value,
name: control.get('name').value,

View File

@@ -13,8 +13,12 @@
keinen Artikel hinzugefügt.
</p>
<div class="btn-wrapper">
<a class="cta-primary" [routerLink]="productSearchBasePath">Artikel suchen</a>
<button class="cta-secondary" (click)="openDummyModal({})">Neuanlage</button>
<a class="cta-primary" [routerLink]="productSearchBasePath"
>Artikel suchen</a
>
<button class="cta-secondary" (click)="openDummyModal({})">
Neuanlage
</button>
</div>
</div>
</div>
@@ -24,11 +28,22 @@
<div class="cta-print-wrapper">
<button class="cta-print" (click)="openPrintModal()">Drucken</button>
</div>
<h1 class="header">Warenkorb</h1>
<div class="header-container">
<h1 class="header">Warenkorb</h1>
@if (orderTypesExist$ | async) {
<lib-reward-selection-trigger
class="pb-2 desktop-large:pb-0"
></lib-reward-selection-trigger>
}
</div>
@if (!(isDesktop$ | async)) {
<page-checkout-review-details></page-checkout-review-details>
}
@for (group of groupedItems$ | async; track trackByGroupedItems($index, group); let lastGroup = $last) {
@for (
group of groupedItems$ | async;
track trackByGroupedItems($index, group);
let lastGroup = $last
) {
@if (group?.orderType !== undefined) {
<hr />
<div class="row item-group-header bg-[#F5F7FA]">
@@ -40,20 +55,31 @@
></shared-icon>
}
<div class="label" [class.dummy]="group.orderType === 'Dummy'">
{{ group.orderType !== 'Dummy' ? group.orderType : 'Manuelle Anlage / Dummy Bestellung' }}
{{
group.orderType !== 'Dummy'
? group.orderType
: 'Manuelle Anlage / Dummy Bestellung'
}}
@if (group.orderType === 'Dummy') {
<button
class="text-brand border-none font-bold text-p1 outline-none pl-4"
(click)="openDummyModal({ changeDataFromCart: true })"
>
>
Hinzufügen
</button>
}
</div>
<div class="grow"></div>
@if (group.orderType !== 'Download' && group.orderType !== 'Dummy') {
@if (
group.orderType !== 'Download' && group.orderType !== 'Dummy'
) {
<div class="pl-4">
<button class="cta-edit" (click)="showPurchasingListModal(group.items)">Ändern</button>
<button
class="cta-edit"
(click)="showPurchasingListModal(group.items)"
>
Ändern
</button>
</div>
}
</div>
@@ -62,20 +88,44 @@
group.orderType === 'Versand' ||
group.orderType === 'B2B-Versand' ||
group.orderType === 'DIG-Versand'
) {
<hr
/>
) {
<hr />
}
}
@for (item of group.items; track trackByItemId(i, item); let lastItem = $last; let i = $index) {
@if (group?.orderType !== undefined && (item.features?.orderType === 'Abholung' || item.features?.orderType === 'Rücklage')) {
@for (
item of group.items;
track trackByItemId(i, item);
let lastItem = $last;
let i = $index
) {
@if (
group?.orderType !== undefined &&
(item.features?.orderType === 'Abholung' ||
item.features?.orderType === 'Rücklage')
) {
@if (item?.destination?.data?.targetBranch?.data; as targetBranch) {
@if (i === 0 || checkIfMultipleDestinationsForOrderTypeExist(targetBranch, group, i)) {
@if (
i === 0 ||
checkIfMultipleDestinationsForOrderTypeExist(
targetBranch,
group,
i
)
) {
<div
class="flex flex-row items-center px-5 pt-0 pb-[0.875rem] -mt-2 bg-[#F5F7FA]"
[class.multiple-destinations]="checkIfMultipleDestinationsForOrderTypeExist(targetBranch, group, i)"
[class.multiple-destinations]="
checkIfMultipleDestinationsForOrderTypeExist(
targetBranch,
group,
i
)
"
>
<span class="branch-name"
>{{ targetBranch?.name }} |
{{ targetBranch | branchAddress }}</span
>
<span class="branch-name">{{ targetBranch?.name }} | {{ targetBranch | branchAddress }}</span>
</div>
<hr />
}
@@ -85,7 +135,9 @@
(changeItem)="changeItem($event)"
(changeDummyItem)="changeDummyItem($event)"
(changeQuantity)="updateItemQuantity($event)"
[quantityError]="(quantityError$ | async)[item.product.catalogProductNumber]"
[quantityError]="
(quantityError$ | async)[item.product.catalogProductNumber]
"
[item]="item"
[orderType]="group?.orderType"
[loadingOnItemChangeById]="loadingOnItemChangeById$ | async"
@@ -109,7 +161,11 @@
}
<div class="flex flex-col w-full">
<strong class="total-value">
Zwischensumme {{ shoppingCart?.total?.value | currency: shoppingCart?.total?.currency : 'code' }}
Zwischensumme
{{
shoppingCart?.total?.value
| currency: shoppingCart?.total?.currency : 'code'
}}
</strong>
<span class="shipping-cost-info">ohne Versandkosten</span>
</div>
@@ -119,11 +175,13 @@
(click)="order()"
[disabled]="
showOrderButtonSpinner ||
((primaryCtaLabel$ | async) === 'Bestellen' && !(checkNotificationChannelControl$ | async)) ||
((primaryCtaLabel$ | async) === 'Bestellen' &&
!(checkNotificationChannelControl$ | async)) ||
notificationsControl?.invalid ||
((primaryCtaLabel$ | async) === 'Bestellen' && ((checkingOla$ | async) || (checkoutIsInValid$ | async)))
((primaryCtaLabel$ | async) === 'Bestellen' &&
((checkingOla$ | async) || (checkoutIsInValid$ | async)))
"
>
>
<ui-spinner [show]="showOrderButtonSpinner">
{{ primaryCtaLabel$ | async }}
</ui-spinner>
@@ -137,4 +195,3 @@
<ui-spinner [show]="true"></ui-spinner>
</div>
}

View File

@@ -72,8 +72,12 @@ button {
@apply text-lg;
}
.header-container {
@apply flex flex-col items-center justify-center desktop-large:pb-10 -mt-2;
}
.header {
@apply text-center text-h2 desktop-large:pb-10 -mt-2;
@apply text-center text-h2;
}
hr {

View File

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,11 @@ import { CheckoutReviewDetailsComponent } from './details/checkout-review-detail
import { CheckoutReviewStore } from './checkout-review.store';
import { IconModule } from '@shared/components/icon';
import { TextFieldModule } from '@angular/cdk/text-field';
import { LoaderComponent, SkeletonLoaderComponent } from '@shared/components/loader';
import {
LoaderComponent,
SkeletonLoaderComponent,
} from '@shared/components/loader';
import { RewardSelectionTriggerComponent } from '@isa/checkout/shared/reward-selection-dialog';
@NgModule({
imports: [
@@ -40,6 +44,7 @@ import { LoaderComponent, SkeletonLoaderComponent } from '@shared/components/loa
TextFieldModule,
LoaderComponent,
SkeletonLoaderComponent,
RewardSelectionTriggerComponent,
],
exports: [CheckoutReviewComponent, CheckoutReviewDetailsComponent],
declarations: [

View File

@@ -1,185 +1,237 @@
import { Injectable } from '@angular/core';
import { UntypedFormGroup } from '@angular/forms';
import { ApplicationService } from '@core/application';
import { DomainCheckoutService } from '@domain/checkout';
import { ComponentStore } from '@ngrx/component-store';
import { tapResponse } from '@ngrx/operators';
import { NotificationChannel, PayerDTO, ShoppingCartDTO, ShoppingCartItemDTO } from '@generated/swagger/checkout-api';
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
import { BehaviorSubject, Subject } from 'rxjs';
import { first, map, switchMap, takeUntil, tap, withLatestFrom } from 'rxjs/operators';
export interface CheckoutReviewState {
payer: PayerDTO;
shoppingCart: ShoppingCartDTO;
shoppingCartItems: ShoppingCartItemDTO[];
fetching: boolean;
}
@Injectable()
export class CheckoutReviewStore extends ComponentStore<CheckoutReviewState> {
orderCompleted = new Subject<void>();
get shoppingCart() {
return this.get((s) => s.shoppingCart);
}
set shoppingCart(shoppingCart: ShoppingCartDTO) {
this.patchState({ shoppingCart });
}
readonly shoppingCart$ = this.select((s) => s.shoppingCart);
get shoppingCartItems() {
return this.get((s) => s.shoppingCartItems);
}
set shoppingCartItems(shoppingCartItems: ShoppingCartItemDTO[]) {
this.patchState({ shoppingCartItems });
}
readonly shoppingCartItems$ = this.select((s) => s.shoppingCartItems);
get fetching() {
return this.get((s) => s.fetching);
}
set fetching(fetching: boolean) {
this.patchState({ fetching });
}
readonly fetching$ = this.select((s) => s.fetching);
customerFeatures$ = this._application.activatedProcessId$.pipe(
takeUntil(this.orderCompleted),
switchMap((processId) => this._domainCheckoutService.getCustomerFeatures({ processId })),
);
payer$ = this._application.activatedProcessId$.pipe(
takeUntil(this.orderCompleted),
switchMap((processId) => this._domainCheckoutService.getPayer({ processId })),
);
buyer$ = this._application.activatedProcessId$.pipe(
takeUntil(this.orderCompleted),
switchMap((processId) => this._domainCheckoutService.getBuyer({ processId })),
);
showBillingAddress$ = this.shoppingCartItems$.pipe(
withLatestFrom(this.customerFeatures$),
map(
([items, customerFeatures]) =>
items.some(
(item) =>
item.features?.orderType === 'Versand' ||
item.features?.orderType === 'B2B-Versand' ||
item.features?.orderType === 'DIG-Versand',
) || !!customerFeatures?.b2b,
),
);
checkNotificationChannelControl$ = new BehaviorSubject<boolean>(true);
notificationChannelLoading$ = new Subject<boolean>();
notificationsControl: UntypedFormGroup;
constructor(
private _domainCheckoutService: DomainCheckoutService,
private _application: ApplicationService,
private _uiModal: UiModalService,
) {
super({ payer: undefined, shoppingCart: undefined, shoppingCartItems: [], fetching: false });
}
loadShoppingCart = this.effect(($) =>
$.pipe(
tap(() => (this.fetching = true)),
withLatestFrom(this._application.activatedProcessId$),
switchMap(([_, processId]) => {
return this._domainCheckoutService.getShoppingCart({ processId, latest: true }).pipe(
tapResponse(
(shoppingCart) => {
const shoppingCartItems = shoppingCart?.items?.map((item) => item.data) || [];
this.patchState({
shoppingCart,
shoppingCartItems,
});
},
(err) => {},
() => {},
),
);
}),
tap(() => (this.fetching = false)),
),
);
async onNotificationChange(notificationChannels?: NotificationChannel[]) {
this.notificationChannelLoading$.next(true);
try {
const control = this.notificationsControl?.getRawValue();
const notificationChannel = notificationChannels
? (notificationChannels.reduce((val, current) => val | current, 0) as NotificationChannel)
: control?.notificationChannel?.selected || 0;
const processId = await this._application.activatedProcessId$.pipe(first()).toPromise();
const email = control?.notificationChannel?.email;
const mobile = control?.notificationChannel?.mobile;
// Check if E-Mail and Mobilnumber is available if E-Mail or SMS checkbox is active
if (notificationChannel === 3 && (!email || !mobile)) {
this.checkNotificationChannelControl$.next(false);
} else if (notificationChannel === 2 && !mobile) {
this.checkNotificationChannelControl$.next(false);
} else if (notificationChannel === 1 && !email) {
this.checkNotificationChannelControl$.next(false);
} else {
this.checkNotificationChannelControl$.next(true);
}
// NotificationChannel nur speichern, wenn Haken und Value gesetzt
let setNotificationChannel = 0;
if ((notificationChannel & 1) === 1 && email) {
setNotificationChannel += 1;
}
if ((notificationChannel & 2) === 2 && mobile) {
setNotificationChannel += 2;
}
if (notificationChannel > 0) {
this.setCommunicationDetails({ processId, notificationChannel, email, mobile });
}
this._domainCheckoutService.setNotificationChannels({
processId,
notificationChannels: (setNotificationChannel as NotificationChannel) || 0,
});
} catch (error) {
this._uiModal.open({
content: UiErrorModalComponent,
data: error,
title: 'Fehler beim setzen des Benachrichtigungskanals',
});
}
this.notificationChannelLoading$.next(false);
}
setCommunicationDetails({
processId,
notificationChannel,
email,
mobile,
}: {
processId: number;
notificationChannel: number;
email: string;
mobile: string;
}) {
const emailValid = this.notificationsControl?.get('notificationChannel')?.get('email')?.valid;
const mobileValid = this.notificationsControl?.get('notificationChannel')?.get('mobile')?.valid;
if (notificationChannel === 3 && emailValid && mobileValid) {
this._domainCheckoutService.setBuyerCommunicationDetails({ processId, email, mobile });
} else if (notificationChannel === 1 && emailValid) {
this._domainCheckoutService.setBuyerCommunicationDetails({ processId, email });
} else if (notificationChannel === 2 && mobileValid) {
this._domainCheckoutService.setBuyerCommunicationDetails({ processId, mobile });
}
}
}
import { Injectable } from '@angular/core';
import { UntypedFormGroup } from '@angular/forms';
import { ApplicationService } from '@core/application';
import { DomainCheckoutService } from '@domain/checkout';
import { ComponentStore } from '@ngrx/component-store';
import { tapResponse } from '@ngrx/operators';
import {
NotificationChannel,
PayerDTO,
ShoppingCartDTO,
ShoppingCartItemDTO,
} from '@generated/swagger/checkout-api';
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
import { BehaviorSubject, Subject } from 'rxjs';
import {
first,
map,
switchMap,
takeUntil,
tap,
withLatestFrom,
} from 'rxjs/operators';
export interface CheckoutReviewState {
payer: PayerDTO;
shoppingCart: ShoppingCartDTO;
shoppingCartItems: ShoppingCartItemDTO[];
fetching: boolean;
}
@Injectable()
export class CheckoutReviewStore extends ComponentStore<CheckoutReviewState> {
orderCompleted = new Subject<void>();
get shoppingCart() {
return this.get((s) => s.shoppingCart);
}
set shoppingCart(shoppingCart: ShoppingCartDTO) {
this.patchState({ shoppingCart });
}
readonly shoppingCart$ = this.select((s) => s.shoppingCart);
get shoppingCartItems() {
return this.get((s) => s.shoppingCartItems);
}
set shoppingCartItems(shoppingCartItems: ShoppingCartItemDTO[]) {
this.patchState({ shoppingCartItems });
}
readonly shoppingCartItems$ = this.select((s) => s.shoppingCartItems);
get fetching() {
return this.get((s) => s.fetching);
}
set fetching(fetching: boolean) {
this.patchState({ fetching });
}
readonly fetching$ = this.select((s) => s.fetching);
customerFeatures$ = this._application.activatedProcessId$.pipe(
takeUntil(this.orderCompleted),
switchMap((processId) =>
this._domainCheckoutService.getCustomerFeatures({ processId }),
),
);
payer$ = this._application.activatedProcessId$.pipe(
takeUntil(this.orderCompleted),
switchMap((processId) =>
this._domainCheckoutService.getPayer({ processId }),
),
);
buyer$ = this._application.activatedProcessId$.pipe(
takeUntil(this.orderCompleted),
switchMap((processId) =>
this._domainCheckoutService.getBuyer({ processId }),
),
);
showBillingAddress$ = this.shoppingCartItems$.pipe(
withLatestFrom(this.customerFeatures$),
map(
([items, customerFeatures]) =>
items.some(
(item) =>
item.features?.orderType === 'Versand' ||
item.features?.orderType === 'B2B-Versand' ||
item.features?.orderType === 'DIG-Versand',
) || !!customerFeatures?.b2b,
),
);
checkNotificationChannelControl$ = new BehaviorSubject<boolean>(true);
notificationChannelLoading$ = new Subject<boolean>();
notificationsControl: UntypedFormGroup;
constructor(
private _domainCheckoutService: DomainCheckoutService,
private _application: ApplicationService,
private _uiModal: UiModalService,
) {
super({
payer: undefined,
shoppingCart: undefined,
shoppingCartItems: [],
fetching: false,
});
}
loadShoppingCart = this.effect(($) =>
$.pipe(
tap(() => (this.fetching = true)),
withLatestFrom(this._application.activatedProcessId$),
switchMap(([_, processId]) => {
return this._domainCheckoutService
.getShoppingCart({ processId, latest: true })
.pipe(
tapResponse(
(shoppingCart) => {
console.log('Loaded shopping cart', { shoppingCart });
const shoppingCartItems =
shoppingCart?.items?.map((item) => item.data) || [];
this.patchState({
shoppingCart,
shoppingCartItems,
});
},
(err) => {},
() => {},
),
);
}),
tap(() => (this.fetching = false)),
),
);
async onNotificationChange(notificationChannels?: NotificationChannel[]) {
this.notificationChannelLoading$.next(true);
try {
const control = this.notificationsControl?.getRawValue();
const notificationChannel = notificationChannels
? (notificationChannels.reduce(
(val, current) => val | current,
0,
) as NotificationChannel)
: control?.notificationChannel?.selected || 0;
const processId = await this._application.activatedProcessId$
.pipe(first())
.toPromise();
const email = control?.notificationChannel?.email;
const mobile = control?.notificationChannel?.mobile;
// Check if E-Mail and Mobilnumber is available if E-Mail or SMS checkbox is active
if (notificationChannel === 3 && (!email || !mobile)) {
this.checkNotificationChannelControl$.next(false);
} else if (notificationChannel === 2 && !mobile) {
this.checkNotificationChannelControl$.next(false);
} else if (notificationChannel === 1 && !email) {
this.checkNotificationChannelControl$.next(false);
} else {
this.checkNotificationChannelControl$.next(true);
}
// NotificationChannel nur speichern, wenn Haken und Value gesetzt
let setNotificationChannel = 0;
if ((notificationChannel & 1) === 1 && email) {
setNotificationChannel += 1;
}
if ((notificationChannel & 2) === 2 && mobile) {
setNotificationChannel += 2;
}
if (notificationChannel > 0) {
this.setCommunicationDetails({
processId,
notificationChannel,
email,
mobile,
});
}
this._domainCheckoutService.setNotificationChannels({
processId,
notificationChannels:
(setNotificationChannel as NotificationChannel) || 0,
});
} catch (error) {
this._uiModal.open({
content: UiErrorModalComponent,
data: error,
title: 'Fehler beim setzen des Benachrichtigungskanals',
});
}
this.notificationChannelLoading$.next(false);
}
setCommunicationDetails({
processId,
notificationChannel,
email,
mobile,
}: {
processId: number;
notificationChannel: number;
email: string;
mobile: string;
}) {
const emailValid = this.notificationsControl
?.get('notificationChannel')
?.get('email')?.valid;
const mobileValid = this.notificationsControl
?.get('notificationChannel')
?.get('mobile')?.valid;
if (notificationChannel === 3 && emailValid && mobileValid) {
this._domainCheckoutService.setBuyerCommunicationDetails({
processId,
email,
mobile,
});
} else if (notificationChannel === 1 && emailValid) {
this._domainCheckoutService.setBuyerCommunicationDetails({
processId,
email,
});
} else if (notificationChannel === 2 && mobileValid) {
this._domainCheckoutService.setBuyerCommunicationDetails({
processId,
mobile,
});
}
}
}

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

@@ -1,15 +1,26 @@
<div class="summary-wrapper">
<div class="flex flex-col bg-white rounded pt-10 mb-24">
<div class="rounded-[50%] bg-[#26830C] w-8 h-8 flex items-center justify-center self-center">
<div
class="rounded-[50%] bg-[#26830C] w-8 h-8 flex items-center justify-center self-center"
>
<shared-icon class="text-white" icon="done" [size]="24"></shared-icon>
</div>
<h1 class="text-center text-h2 my-1 font-bold">Bestellbestätigung</h1>
<p class="text-center text-p1 mb-10">Nachfolgend erhalten Sie die Übersicht Ihrer Bestellung.</p>
<p class="text-center text-p1 mb-10">
Nachfolgend erhalten Sie die Übersicht Ihrer Bestellung.
</p>
@for (displayOrder of displayOrders$ | async; track displayOrder; let i = $index; let orderLast = $last) {
@for (
displayOrder of displayOrders$ | async;
track displayOrder;
let i = $index;
let orderLast = $last
) {
@if (i === 0) {
<div class="flex flex-row items-center bg-white shadow-card min-h-[3.3125rem]">
<div
class="flex flex-row items-center bg-white shadow-card min-h-[3.3125rem]"
>
<div class="text-h3 font-bold px-5 py-[0.875rem]">
{{ displayOrder?.buyer | buyerName }}
</div>
@@ -17,34 +28,45 @@
<hr />
}
<div class="flex flex-row items-center bg-[#F5F7FA] min-h-[3.3125rem]">
<div class="flex flex-row items-center justify-center px-5 py-[0.875rem]">
<div
class="flex flex-row items-center justify-center px-5 py-[0.875rem]"
>
@if ((displayOrder?.items)[0]?.features?.orderType !== 'Dummy') {
<shared-icon
class="mr-2"
[size]="(displayOrder?.items)[0]?.features?.orderType === 'B2B-Versand' ? 36 : 24"
[size]="
(displayOrder?.items)[0]?.features?.orderType === 'B2B-Versand'
? 36
: 24
"
[icon]="(displayOrder?.items)[0]?.features?.orderType"
></shared-icon>
}
<p class="text-p1 font-bold mr-3">{{ (displayOrder?.items)[0]?.features?.orderType }}</p>
<p class="text-p1 font-bold mr-3">
{{ (displayOrder?.items)[0]?.features?.orderType }}
</p>
@if (
(displayOrder?.items)[0]?.features?.orderType === 'Abholung' || (displayOrder?.items)[0]?.features?.orderType === 'Rücklage') {
<div
>
{{ displayOrder.targetBranch?.name }}, {{ displayOrder.targetBranch | branchAddress }}
(displayOrder?.items)[0]?.features?.orderType === 'Abholung' ||
(displayOrder?.items)[0]?.features?.orderType === 'Rücklage'
) {
<div>
{{ displayOrder.targetBranch?.name }},
{{ displayOrder.targetBranch | branchAddress }}
</div>
} @else {
{{ displayOrder.shippingAddress | branchAddress }}
}
@if ((displayOrder?.items)[0]?.features?.orderType === 'Download') {
<div>
| {{ displayOrder.buyer?.communicationDetails?.email }}
</div>
<div>| {{ displayOrder.buyer?.communicationDetails?.email }}</div>
}
</div>
</div>
<hr />
<div class="flex flex-row justify-between items-center">
<div class="flex flex-col px-5 py-4 bg-white" [attr.data-order-type]="(displayOrder?.items)[0]?.features?.orderType">
<div
class="flex flex-col px-5 py-4 bg-white"
[attr.data-order-type]="(displayOrder?.items)[0]?.features?.orderType"
>
<div class="flex flex-row justify-between items-center mb-[0.375rem]">
<div class="flex flex-row">
<span class="w-32">Vorgangs-ID</span>
@@ -54,14 +76,28 @@
data-which="Vorgangs-ID"
data-what="link"
class="font-bold text-[#0556B4] no-underline"
[routerLink]="['/kunde', processId, 'customer', 'search', customer?.id, 'orders', displayOrder.id]"
[queryParams]="{ main_qs: customer?.customerNumber, filter_customertype: '' }"
>
[routerLink]="[
'/kunde',
processId,
'customer',
'search',
customer?.id,
'orders',
displayOrder.id,
]"
[queryParams]="{
main_qs: customer?.customerNumber,
filter_customertype: '',
}"
>
{{ displayOrder.orderNumber }}
</a>
}
}
<ui-spinner class="text-[#0556B4] h-4 w-4" [show]="!(customer$ | async)"></ui-spinner>
<ui-spinner
class="text-[#0556B4] h-4 w-4"
[show]="!(customer$ | async)"
></ui-spinner>
</div>
</div>
<div class="flex flex-row justify-between items-center">
@@ -77,7 +113,7 @@
type="button"
class="text-[#0556B4] font-bold flex flex-row items-center justify-center"
[class.flex-row-reverse]="!expanded[i]"
>
>
<shared-icon
class="mr-1"
icon="arrow-back"
@@ -95,18 +131,26 @@
class="page-checkout-summary__items-tablet px-5 pb-[1.875rem] bg-white"
[class.page-checkout-summary__items]="isDesktop$ | async"
[class.last]="last"
>
>
<div class="page-checkout-summary__items-thumbnail flex flex-row">
<a [routerLink]="getProductSearchDetailsPath(order?.product?.ean)" [queryParams]="getProductSearchDetailsQueryParams(order)">
<img class="w-[3.125rem] max-h-20 mr-2" [src]="order.product?.ean | productImage: 195 : 315 : true" />
<a
[routerLink]="getProductSearchDetailsPath(order?.product?.ean)"
[queryParams]="getProductSearchDetailsQueryParams(order)"
>
<img
class="w-[3.125rem] max-h-20 mr-2"
[src]="order.product?.ean | productImage: 195 : 315 : true"
/>
</a>
</div>
<div class="page-checkout-summary__items-title whitespace-nowrap overflow-ellipsis overflow-hidden">
<div
class="page-checkout-summary__items-title whitespace-nowrap overflow-ellipsis overflow-hidden"
>
<a
class="font-bold no-underline text-[#0556B4]"
[routerLink]="getProductSearchDetailsPath(order?.product?.ean)"
[queryParams]="getProductSearchDetailsQueryParams(order)"
>
>
{{ order?.product?.name }}
</a>
</div>
@@ -120,40 +164,78 @@
}
</div>
}
<div class="page-checkout-summary__items-quantity font-bold justify-self-end">
<div
class="page-checkout-summary__items-quantity font-bold justify-self-end"
>
<span>{{ order.quantity }}x</span>
</div>
<div class="page-checkout-summary__items-price font-bold justify-self-end">
<span>{{ order.price?.value?.value | currency: ' ' }} {{ order.price?.value?.currency }}</span>
<div
class="page-checkout-summary__items-price font-bold justify-self-end"
>
<span
>{{ order.price?.value?.value | currency: ' ' }}
{{ order.price?.value?.currency }}</span
>
</div>
<div class="page-checkout-summary__items-delivery product-details">
<div class="delivery-row">
@switch (order?.features?.orderType) {
@case ('Abholung') {
<span class="order-type">
Abholung ab {{ (order?.subsetItems)[0]?.estimatedShippingDate | date }}
<ng-container [ngTemplateOutlet]="abholfrist" [ngTemplateOutletContext]="{ order: order }"></ng-container>
Abholung ab
{{
(order?.subsetItems)[0]?.estimatedShippingDate | date
}}
<ng-container
[ngTemplateOutlet]="abholfrist"
[ngTemplateOutletContext]="{ order: order }"
></ng-container>
</span>
}
@case ('Rücklage') {
<span class="order-type">
{{ order?.features?.orderType }}
<ng-container [ngTemplateOutlet]="abholfrist" [ngTemplateOutletContext]="{ order: order }"></ng-container>
<ng-container
[ngTemplateOutlet]="abholfrist"
[ngTemplateOutletContext]="{ order: order }"
></ng-container>
</span>
}
@case (['Versand', 'B2B-Versand', 'DIG-Versand'].indexOf(order?.features?.orderType) > -1) {
@case (
['Versand', 'B2B-Versand', 'DIG-Versand'].indexOf(
order?.features?.orderType
) > -1
) {
@if ((order?.subsetItems)[0]?.estimatedDelivery) {
<span class="order-type">
Zustellung zwischen
{{ ((order?.subsetItems)[0]?.estimatedDelivery?.start | date: 'EEE, dd.MM.')?.replace('.', '') }} und
{{ ((order?.subsetItems)[0]?.estimatedDelivery?.stop | date: 'EEE, dd.MM.')?.replace('.', '') }}
{{
(
(order?.subsetItems)[0]?.estimatedDelivery?.start
| date: 'EEE, dd.MM.'
)?.replace('.', '')
}}
und
{{
(
(order?.subsetItems)[0]?.estimatedDelivery?.stop
| date: 'EEE, dd.MM.'
)?.replace('.', '')
}}
</span>
} @else {
<span class="order-type">Versanddatum {{ (order?.subsetItems)[0]?.estimatedShippingDate | date }}</span>
<span class="order-type"
>Versanddatum
{{
(order?.subsetItems)[0]?.estimatedShippingDate | date
}}</span
>
}
}
@default {
<span class="order-type">{{ order?.features?.orderType }}</span>
<span class="order-type">{{
order?.features?.orderType
}}</span>
}
}
</div>
@@ -165,21 +247,31 @@
}
}
@if (orderLast) {
<div class="flex flex-row justify-between items-center min-h-[3.3125rem] bg-white px-5 py-4 rounded-b">
<div
class="flex flex-row justify-between items-center min-h-[3.3125rem] bg-white px-5 py-4 rounded-b"
>
@if (totalReadingPoints$ | async; as totalReadingPoints) {
<span class="text-p2 font-bold">
{{ totalItemCount$ | async }} Artikel | {{ totalReadingPoints }} Lesepunkte
{{ totalItemCount$ | async }} Artikel |
{{ totalReadingPoints }} Lesepunkte
</span>
}
<div class="flex flex-row items-center justify-center">
<div class="text-p1 font-bold flex flex-row items-center">
<div class="mr-1">Gesamtsumme {{ totalPrice$ | async | currency: ' ' }} {{ totalPriceCurrency$ | async }}</div>
<div class="mr-1">
Gesamtsumme {{ totalPrice$ | async | currency: ' ' }}
{{ totalPriceCurrency$ | async }}
</div>
</div>
@if ((takeNowOrders$ | async)?.length === 1 && (isB2BCustomer$ | async)) {
@if (
(takeNowOrders$ | async)?.length === 1 && (isB2BCustomer$ | async)
) {
<div
class="bg-brand text-white font-bold text-p1 outline-none border-none rounded-full px-6 py-3 ml-2"
>
<button class="cta-goods-out" (click)="navigateToShelfOut()">Zur Warenausgabe</button>
>
<button class="cta-goods-out" (click)="navigateToShelfOut()">
Zur Warenausgabe
</button>
</div>
}
</div>
@@ -192,12 +284,23 @@
<ng-template #abholfrist let-order="order">
@if (!(updatingPreferredPickUpDate$ | async)[(order?.subsetItems)[0].id]) {
<div class="inline-flex">
<button [uiOverlayTrigger]="deadlineDatepicker" #deadlineDatepickerTrigger="uiOverlayTrigger" class="flex flex-row items-center">
<button
[uiOverlayTrigger]="deadlineDatepicker"
#deadlineDatepickerTrigger="uiOverlayTrigger"
class="flex flex-row items-center"
>
<span class="mx-[0.625rem] font-normal">bis</span>
<strong class="border-r border-[#AEB7C1] pr-4">
{{ ((order?.subsetItems)[0]?.preferredPickUpDate | date: 'dd.MM.yy') || 'TT.MM.JJJJ' }}
{{
((order?.subsetItems)[0]?.preferredPickUpDate | date: 'dd.MM.yy') ||
'TT.MM.JJJJ'
}}
</strong>
<shared-icon class="text-[#596470] ml-4" [size]="24" icon="isa-calendar"></shared-icon>
<shared-icon
class="text-[#596470] ml-4"
[size]="24"
icon="isa-calendar"
></shared-icon>
</button>
<ui-datepicker
#deadlineDatepicker
@@ -207,12 +310,15 @@
[min]="minDateDatepicker"
[disabledDaysOfWeek]="[0]"
[(selected)]="selectedDate"
>
>
<div #content class="grid grid-flow-row gap-2">
<button
class="rounded-full font-bold text-white bg-brand py-px-15 px-px-25"
(click)="updatePreferredPickUpDate(undefined, selectedDate); deadlineDatepickerTrigger.close()"
>
(click)="
updatePreferredPickUpDate(undefined, selectedDate);
deadlineDatepickerTrigger.close()
"
>
Für den Warenkorb festlegen
</button>
</div>
@@ -225,15 +331,19 @@
</ng-template>
<div class="relative">
<div class="absolute left-1/2 bottom-10 inline-grid grid-flow-col gap-4 justify-center transform -translate-x-1/2">
<div
class="absolute left-1/2 bottom-10 flex flex-wrap w-full gap-4 justify-center transform -translate-x-1/2"
>
<button
*ifRole="'Store'"
[disabled]="isPrinting$ | async"
type="button"
class="px-6 py-2 rounded-full border-2 border-solid border-brand text-brand bg-white font-bold text-lg whitespace-nowrap h-14 flex flex-row items-center justify-center print-button"
(click)="printOrderConfirmation()"
>
<ui-spinner class="min-h-4 min-w-4" [show]="isPrinting$ | async"
>Bestellbestätigung drucken</ui-spinner
>
<ui-spinner class="min-h-4 min-w-4" [show]="isPrinting$ | async">Bestellbestätigung drucken</ui-spinner>
</button>
@if (hasAbholung$ | async) {
@@ -241,9 +351,18 @@
type="button"
class="px-6 py-2 rounded-full border-2 border-solid border-brand text-brand bg-white font-bold text-lg whitespace-nowrap h-14"
(click)="sendOrderConfirmation()"
>
>
Bestellbestätigung senden
</button>
}
@if (displayRewardNavigation()) {
<button
type="button"
class="px-6 py-2 rounded-full border-2 border-solid border-brand text-white bg-brand font-bold text-lg whitespace-nowrap h-14"
(click)="navigateToReward()"
>
Zur Prämienausgabe
</button>
}
</div>
</div>

View File

@@ -6,6 +6,7 @@ import {
OnDestroy,
OnInit,
inject,
computed,
} from '@angular/core';
import { DomainCheckoutService } from '@domain/checkout';
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
@@ -33,6 +34,9 @@ import {
import { EnvironmentService } from '@core/environment';
import { SendOrderConfirmationModalService } from '@modal/send-order-confirmation';
import { ToasterService } from '@shared/shell';
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
import { injectTabId } from '@isa/core/tabs';
import { PrimaryCustomerCardResource } from '@isa/crm/data-access';
@Component({
selector: 'page-checkout-summary',
@@ -44,6 +48,22 @@ import { ToasterService } from '@shared/shell';
export class CheckoutSummaryComponent implements OnInit, OnDestroy {
private _injector = inject(Injector);
#tabId = injectTabId();
#primaryCustomerCardResource = inject(PrimaryCustomerCardResource);
#rewardShoppingCartResource = inject(SelectedRewardShoppingCartResource)
.resource;
readonly rewardShoppingCartResponseValue =
this.#rewardShoppingCartResource.value.asReadonly();
readonly primaryCustomerCardValue =
this.#primaryCustomerCardResource.primaryCustomerCard;
displayRewardNavigation = computed(() => {
const rewardShoppingCart = this.rewardShoppingCartResponseValue();
const hasPrimaryCard = this.primaryCustomerCardValue();
return !!rewardShoppingCart?.items?.length && hasPrimaryCard;
});
get sendOrderConfirmationModalService() {
return this._injector.get(SendOrderConfirmationModalService);
}
@@ -85,7 +105,7 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
);
if (ordersWithMultipleFeatures) {
for (let orderWithMultipleFeatures of ordersWithMultipleFeatures) {
for (const orderWithMultipleFeatures of ordersWithMultipleFeatures) {
if (orderWithMultipleFeatures?.items?.length > 1) {
const itemsWithOrderFeature =
orderWithMultipleFeatures.items.filter(
@@ -397,7 +417,7 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
}
async navigateToShelfOut() {
let takeNowOrders = await this.takeNowOrders$.pipe(first()).toPromise();
const takeNowOrders = await this.takeNowOrders$.pipe(first()).toPromise();
if (takeNowOrders.length != 1) return;
try {
@@ -422,6 +442,10 @@ export class CheckoutSummaryComponent implements OnInit, OnDestroy {
await this.sendOrderConfirmationModalService.open(orders);
}
async navigateToReward() {
await this.router.navigate([`/${this.#tabId()}`, 'reward', 'cart']);
}
async printOrderConfirmation() {
this.isPrinting$.next(true);
const orders = await this.displayOrders$.pipe(first()).toPromise();

View File

@@ -1,30 +1,31 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CheckoutSummaryComponent } from './checkout-summary.component';
import { PageCheckoutPipeModule } from '../pipes/page-checkout-pipe.module';
import { ProductImageModule } from '@cdn/product-image';
import { RouterModule } from '@angular/router';
import { UiCommonModule } from '@ui/common';
import { UiSpinnerModule } from '@ui/spinner';
import { UiDatepickerModule } from '@ui/datepicker';
import { IconModule } from '@shared/components/icon';
import { AuthModule } from '@core/auth';
@NgModule({
imports: [
CommonModule,
RouterModule,
PageCheckoutPipeModule,
ProductImageModule,
IconModule,
UiCommonModule,
UiSpinnerModule,
UiDatepickerModule,
AuthModule,
UiSpinnerModule,
],
exports: [CheckoutSummaryComponent],
declarations: [CheckoutSummaryComponent],
})
export class CheckoutSummaryModule {}
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CheckoutSummaryComponent } from './checkout-summary.component';
import { PageCheckoutPipeModule } from '../pipes/page-checkout-pipe.module';
import { ProductImageModule } from '@cdn/product-image';
import { RouterModule } from '@angular/router';
import { UiCommonModule } from '@ui/common';
import { UiSpinnerModule } from '@ui/spinner';
import { UiDatepickerModule } from '@ui/datepicker';
import { IconModule } from '@shared/components/icon';
import { AuthModule } from '@core/auth';
@NgModule({
imports: [
CommonModule,
RouterModule,
PageCheckoutPipeModule,
ProductImageModule,
IconModule,
UiCommonModule,
UiSpinnerModule,
UiDatepickerModule,
AuthModule,
UiSpinnerModule,
],
exports: [CheckoutSummaryComponent],
declarations: [CheckoutSummaryComponent],
providers: [],
})
export class CheckoutSummaryModule {}

View File

@@ -1,9 +1,14 @@
<ng-container *ngIf="orderItem$ | async; let orderItem">
<div class="grid grid-flow-row gap-px-2">
<div class="bg-[#F5F7FA] flex flex-row justify-between items-center p-4 rounded-t">
<div class="grid grid-flow-col gap-[0.4375rem] items-center" *ngIf="features$ | async; let features; else: featureLoading">
<shared-icon *ngIf="features?.length > 0" [size]="24" icon="person"></shared-icon>
<div class="grid grid-flow-col gap-2 items-center font-bold text-p2" *ngFor="let feature of features">
<div
class="bg-[#F5F7FA] flex flex-row justify-between items-center p-4 rounded-t"
>
<div
class="grid grid-flow-col gap-[0.4375rem] items-center"
*ngIf="customerFeature$ | async; let feature; else: featureLoading"
>
<shared-icon *ngIf="!!feature" [size]="24" icon="person"></shared-icon>
<div class="grid grid-flow-col gap-2 items-center font-bold text-p2">
{{ feature?.description }}
</div>
</div>
@@ -18,29 +23,54 @@
</button>
</div>
<div class="page-customer-order-details-header__details bg-white px-4 pt-4 pb-5">
<div
class="page-customer-order-details-header__details bg-white px-4 pt-4 pb-5"
>
<h2
class="page-customer-order-details-header__details-header items-center"
[class.mb-8]="!orderItem?.features?.paid && !isKulturpass"
>
<div class="text-h2">
{{ orderItem?.organisation }}
<ng-container *ngIf="!!orderItem?.organisation && (!!orderItem?.firstName || !!orderItem?.lastName)">-</ng-container>
<ng-container
*ngIf="
!!orderItem?.organisation &&
(!!orderItem?.firstName || !!orderItem?.lastName)
"
>-</ng-container
>
{{ orderItem?.lastName }}
{{ orderItem?.firstName }}
</div>
<div class="page-customer-order-details-header__header-compartment text-h3">
{{ orderItem?.compartmentCode }}{{ orderItem?.compartmentInfo && '_' + orderItem?.compartmentInfo }}
<div
class="page-customer-order-details-header__header-compartment text-h3"
>
{{ orderItem?.compartmentCode
}}{{ orderItem?.compartmentInfo && '_' + orderItem?.compartmentInfo }}
</div>
</h2>
<div class="page-customer-order-details-header__paid-marker mt-[0.375rem]" *ngIf="orderItem?.features?.paid && !isKulturpass">
<div class="font-bold w-fit desktop-small:text-p2 px-3 py-[0.125rem] rounded text-white bg-[#26830C]">
<div
class="page-customer-order-details-header__paid-marker mt-[0.375rem]"
*ngIf="orderItem?.features?.paid && !isKulturpass"
>
<div
class="font-bold w-fit desktop-small:text-p2 px-3 py-[0.125rem] rounded text-white bg-[#26830C]"
>
{{ orderItem?.features?.paid }}
</div>
</div>
<div class="page-customer-order-details-header__paid-marker mt-[0.375rem] text-[#26830C]" *ngIf="isKulturpass">
<svg class="fill-current mr-2" xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22">
<div
class="page-customer-order-details-header__paid-marker mt-[0.375rem] text-[#26830C]"
*ngIf="isKulturpass"
>
<svg
class="fill-current mr-2"
xmlns="http://www.w3.org/2000/svg"
height="22"
viewBox="0 -960 960 960"
width="22"
>
<path
d="M880-740v520q0 24-18 42t-42 18H140q-24 0-42-18t-18-42v-520q0-24 18-42t42-18h680q24 0 42 18t18 42ZM140-631h680v-109H140v109Zm0 129v282h680v-282H140Zm0 282v-520 520Z"
/>
@@ -49,25 +79,49 @@
</div>
<div class="page-customer-order-details-header__details-wrapper -mt-3">
<div class="flex flex-row page-customer-order-details-header__buyer-number" data-detail-id="Kundennummer">
<div
class="flex flex-row page-customer-order-details-header__buyer-number"
data-detail-id="Kundennummer"
>
<div class="min-w-[9rem]">Kundennummer</div>
<div class="flex flex-row font-bold">{{ orderItem?.buyerNumber }}</div>
<div class="flex flex-row font-bold">
{{ orderItem?.buyerNumber }}
</div>
</div>
<div class="flex flex-row page-customer-order-details-header__order-number" data-detail-id="VorgangId">
<div
class="flex flex-row page-customer-order-details-header__order-number"
data-detail-id="VorgangId"
>
<div class="min-w-[9rem]">Vorgang-ID</div>
<div class="flex flex-row font-bold">{{ orderItem?.orderNumber }}</div>
<div class="flex flex-row font-bold">
{{ orderItem?.orderNumber }}
</div>
</div>
<div class="flex flex-row page-customer-order-details-header__order-date" data-detail-id="Bestelldatum">
<div
class="flex flex-row page-customer-order-details-header__order-date"
data-detail-id="Bestelldatum"
>
<div class="min-w-[9rem]">Bestelldatum</div>
<div class="flex flex-row font-bold">{{ orderItem?.orderDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
<div class="flex flex-row font-bold">
{{ orderItem?.orderDate | date: 'dd.MM.yy | HH:mm' }} Uhr
</div>
</div>
<div class="flex flex-row page-customer-order-details-header__processing-status justify-between" data-detail-id="Status">
<div
class="flex flex-row page-customer-order-details-header__processing-status justify-between"
data-detail-id="Status"
>
<div class="min-w-[9rem]">Status</div>
<div *ngIf="!(changeStatusLoader$ | async)" class="flex flex-row font-bold -mr-[0.125rem]">
<div
*ngIf="!(changeStatusLoader$ | async)"
class="flex flex-row font-bold -mr-[0.125rem]"
>
<shared-icon
class="mr-2 text-black flex items-center justify-center"
[size]="16"
*ngIf="orderItem.processingStatus | processingStatus: 'icon'; let icon"
*ngIf="
orderItem.processingStatus | processingStatus: 'icon';
let icon
"
[icon]="icon"
></shared-icon>
@@ -91,18 +145,36 @@
icon="arrow-drop-down"
></shared-icon>
</button>
<ui-dropdown #statusDropdown yPosition="below" xPosition="after" [xOffset]="8">
<button uiDropdownItem *ngFor="let action of statusActions$ | async" (click)="handleActionClick(action)">
<ui-dropdown
#statusDropdown
yPosition="below"
xPosition="after"
[xOffset]="8"
>
<button
uiDropdownItem
*ngFor="let action of statusActions$ | async"
(click)="handleActionClick(action)"
>
{{ action.label }}
</button>
</ui-dropdown>
</ng-container>
</div>
<ui-spinner *ngIf="changeStatusLoader$ | async; let loader" class="flex flex-row font-bold loader" [show]="loader"></ui-spinner>
<ui-spinner
*ngIf="changeStatusLoader$ | async; let loader"
class="flex flex-row font-bold loader"
[show]="loader"
></ui-spinner>
</div>
<div class="flex flex-row page-customer-order-details-header__order-source" data-detail-id="Bestellkanal">
<div
class="flex flex-row page-customer-order-details-header__order-source"
data-detail-id="Bestellkanal"
>
<div class="min-w-[9rem]">Bestellkanal</div>
<div class="flex flex-row font-bold">{{ order?.features?.orderSource }}</div>
<div class="flex flex-row font-bold">
{{ order?.features?.orderSource }}
</div>
</div>
<div
class="flex flex-row page-customer-order-details-header__change-date justify-between"
@@ -124,26 +196,39 @@
<ng-template #changeDate>
<div class="min-w-[9rem]">Geändert</div>
<div class="flex flex-row font-bold">{{ orderItem?.processingStatusDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
<div class="flex flex-row font-bold">
{{
orderItem?.processingStatusDate | date: 'dd.MM.yy | HH:mm'
}}
Uhr
</div>
</ng-template>
</div>
<div
class="flex flex-row page-customer-order-details-header__pick-up justify-between"
data-detail-id="Wunschdatum"
*ngIf="orderItem.orderType === 1 && (orderItem.processingStatus === 16 || orderItem.processingStatus === 8192)"
*ngIf="
orderItem.orderType === 1 &&
(orderItem.processingStatus === 16 ||
orderItem.processingStatus === 8192)
"
>
<ng-container *ngTemplateOutlet="preferredPickUpDate"></ng-container>
</div>
<div class="flex flex-col page-customer-order-details-header__dig-and-notification">
<div
class="flex flex-col page-customer-order-details-header__dig-and-notification"
>
<div
*ngIf="orderItem.orderType === 1"
class="flex flex-row page-customer-order-details-header__notification"
data-detail-id="Benachrichtigung"
>
<div class="min-w-[9rem]">Benachrichtigung</div>
<div class="flex flex-row font-bold">{{ (notificationsChannel | notificationsChannel) || '-' }}</div>
<div class="flex flex-row font-bold">
{{ (notificationsChannel | notificationsChannel) || '-' }}
</div>
</div>
<div
@@ -162,38 +247,50 @@
<div *ngIf="showFeature" class="flex flex-row items-center mr-3">
<ng-container [ngSwitch]="order.features.orderType">
<ng-container *ngSwitchCase="'Versand'">
<div class="flex items-center justify-center bg-[#D8DFE5] w-[2.25rem] h-[2.25rem] rounded rounded-br-none mr-2">
<div
class="flex items-center justify-center bg-[#D8DFE5] w-[2.25rem] h-[2.25rem] rounded rounded-br-none mr-2"
>
<shared-icon [size]="24" icon="isa-truck"></shared-icon>
</div>
<p class="font-bold text-p1">Versand</p>
</ng-container>
<ng-container *ngSwitchCase="'DIG-Versand'">
<div class="flex items-center justify-center bg-[#D8DFE5] w-[2.25rem] h-[2.25rem] rounded rounded-br-none mr-2">
<div
class="flex items-center justify-center bg-[#D8DFE5] w-[2.25rem] h-[2.25rem] rounded rounded-br-none mr-2"
>
<shared-icon [size]="24" icon="isa-truck"></shared-icon>
</div>
<p class="font-bold text-p1">Versand</p>
</ng-container>
<ng-container *ngSwitchCase="'B2B-Versand'">
<div class="flex items-center justify-center bg-[#D8DFE5] w-[2.25rem] h-[2.25rem] rounded rounded-br-none mr-2">
<div
class="flex items-center justify-center bg-[#D8DFE5] w-[2.25rem] h-[2.25rem] rounded rounded-br-none mr-2"
>
<shared-icon [size]="24" icon="isa-b2b-truck"></shared-icon>
</div>
<p class="font-bold text-p1">B2B-Versand</p>
</ng-container>
<ng-container *ngSwitchCase="'Abholung'">
<div class="flex items-center justify-center bg-[#D8DFE5] w-[2.25rem] h-[2.25rem] rounded rounded-br-none mr-2">
<div
class="flex items-center justify-center bg-[#D8DFE5] w-[2.25rem] h-[2.25rem] rounded rounded-br-none mr-2"
>
<shared-icon [size]="24" icon="isa-box-out"></shared-icon>
</div>
<p class="font-bold text-p1 mr-3">Abholung</p>
{{ orderItem.targetBranch }}
</ng-container>
<ng-container *ngSwitchCase="'Rücklage'">
<div class="flex items-center justify-center bg-[#D8DFE5] w-[2.25rem] h-[2.25rem] rounded rounded-br-none mr-2">
<div
class="flex items-center justify-center bg-[#D8DFE5] w-[2.25rem] h-[2.25rem] rounded rounded-br-none mr-2"
>
<shared-icon [size]="24" icon="isa-shopping-bag"></shared-icon>
</div>
<p class="font-bold text-p1">Rücklage</p>
</ng-container>
<ng-container *ngSwitchCase="'Download'">
<div class="flex items-center justify-center bg-[#D8DFE5] w-[2.25rem] h-[2.25rem] rounded rounded-br-none mr-2">
<div
class="flex items-center justify-center bg-[#D8DFE5] w-[2.25rem] h-[2.25rem] rounded rounded-br-none mr-2"
>
<shared-icon [size]="24" icon="isa-download"></shared-icon>
</div>
<p class="font-bold text-p1">Download</p>
@@ -201,50 +298,93 @@
</ng-container>
</div>
<div class="page-customer-order-details-header__additional-addresses" *ngIf="showAddresses">
<div
class="page-customer-order-details-header__additional-addresses"
*ngIf="showAddresses"
>
<button (click)="openAddresses = !openAddresses" class="text-[#0556B4]">
Lieferadresse / Rechnungsadresse {{ openAddresses ? 'ausblenden' : 'anzeigen' }}
Lieferadresse / Rechnungsadresse
{{ openAddresses ? 'ausblenden' : 'anzeigen' }}
</button>
<div class="page-customer-order-details-header__addresses-popover" *ngIf="openAddresses">
<div
class="page-customer-order-details-header__addresses-popover"
*ngIf="openAddresses"
>
<button (click)="openAddresses = !openAddresses" class="close">
<shared-icon icon="close" [size]="24"></shared-icon>
</button>
<div class="page-customer-order-details-header__addresses-popover-data">
<div *ngIf="order.shipping" class="page-customer-order-details-header__addresses-popover-delivery">
<div
class="page-customer-order-details-header__addresses-popover-data"
>
<div
*ngIf="order.shipping"
class="page-customer-order-details-header__addresses-popover-delivery"
>
<p>Lieferadresse</p>
<div class="page-customer-order-details-header__addresses-popover-delivery-data">
<div
class="page-customer-order-details-header__addresses-popover-delivery-data"
>
<ng-container *ngIf="order.shipping?.data?.organisation">
<p>{{ order.shipping?.data?.organisation?.name }}</p>
<p>{{ order.shipping?.data?.organisation?.department }}</p>
</ng-container>
<p>{{ order.shipping?.data?.firstName }} {{ order.shipping?.data?.lastName }}</p>
<p>
{{ order.shipping?.data?.firstName }}
{{ order.shipping?.data?.lastName }}
</p>
<p>{{ order.shipping?.data?.address?.info }}</p>
<p>{{ order.shipping?.data?.address?.street }} {{ order.shipping?.data?.address?.streetNumber }}</p>
<p>{{ order.shipping?.data?.address?.zipCode }} {{ order.shipping?.data?.address?.city }}</p>
<p>
{{ order.shipping?.data?.address?.street }}
{{ order.shipping?.data?.address?.streetNumber }}
</p>
<p>
{{ order.shipping?.data?.address?.zipCode }}
{{ order.shipping?.data?.address?.city }}
</p>
</div>
</div>
<div *ngIf="order.billing" class="page-customer-order-details-header__addresses-popover-billing">
<div
*ngIf="order.billing"
class="page-customer-order-details-header__addresses-popover-billing"
>
<p>Rechnungsadresse</p>
<div class="page-customer-order-details-header__addresses-popover-billing-data">
<div
class="page-customer-order-details-header__addresses-popover-billing-data"
>
<ng-container *ngIf="order.billing?.data?.organisation">
<p>{{ order.billing?.data?.organisation?.name }}</p>
<p>{{ order.billing?.data?.organisation?.department }}</p>
</ng-container>
<p>{{ order.billing?.data?.firstName }} {{ order.billing?.data?.lastName }}</p>
<p>
{{ order.billing?.data?.firstName }}
{{ order.billing?.data?.lastName }}
</p>
<p>{{ order.billing?.data?.address?.info }}</p>
<p>{{ order.billing?.data?.address?.street }} {{ order.billing?.data?.address?.streetNumber }}</p>
<p>{{ order.billing?.data?.address?.zipCode }} {{ order.billing?.data?.address?.city }}</p>
<p>
{{ order.billing?.data?.address?.street }}
{{ order.billing?.data?.address?.streetNumber }}
</p>
<p>
{{ order.billing?.data?.address?.zipCode }}
{{ order.billing?.data?.address?.city }}
</p>
</div>
</div>
</div>
</div>
</div>
<div class="page-customer-order-details-header__select grow" *ngIf="showMultiselect$ | async">
<button class="cta-select-all" (click)="selectAll()">Alle auswählen</button>
{{ selectedOrderItemCount$ | async }} von {{ orderItemCount$ | async }} Titeln
<div
class="page-customer-order-details-header__select grow"
*ngIf="showMultiselect$ | async"
>
<button class="cta-select-all" (click)="selectAll()">
Alle auswählen
</button>
{{ selectedOrderItemCount$ | async }} von
{{ orderItemCount$ | async }} Titeln
</div>
</div>
</div>
@@ -263,13 +403,20 @@
<button
[uiOverlayTrigger]="deadlineDatepicker"
#deadlineDatepickerTrigger="uiOverlayTrigger"
[disabled]="!isKulturpass && (!!orderItem?.features?.paid || (changeDateDisabled$ | async))"
[disabled]="
!isKulturpass &&
(!!orderItem?.features?.paid || (changeDateDisabled$ | async))
"
class="cta-pickup-deadline"
>
<strong class="border-r border-[#AEB7C1] pr-4">
{{ orderItem?.pickUpDeadline | date: 'dd.MM.yy' }}
</strong>
<shared-icon class="text-[#596470] ml-4" [size]="24" icon="isa-calendar"></shared-icon>
<shared-icon
class="text-[#596470] ml-4"
[size]="24"
icon="isa-calendar"
></shared-icon>
</button>
<ui-datepicker
#deadlineDatepicker
@@ -282,25 +429,46 @@
(save)="updatePickupDeadline($event)"
></ui-datepicker>
</div>
<ui-spinner *ngIf="changeDateLoader$ | async; let loader" class="flex flex-row font-bold loader" [show]="loader"></ui-spinner>
<ui-spinner
*ngIf="changeDateLoader$ | async; let loader"
class="flex flex-row font-bold loader"
[show]="loader"
></ui-spinner>
</ng-template>
<ng-template #preferredPickUpDate>
<div class="min-w-[9rem]">Zurücklegen bis</div>
<div *ngIf="!(changePreferredDateLoader$ | async)" class="flex flex-row font-bold">
<div
*ngIf="!(changePreferredDateLoader$ | async)"
class="flex flex-row font-bold"
>
<button
[uiOverlayTrigger]="preferredPickUpDatePicker"
#preferredPickUpDatePickerTrigger="uiOverlayTrigger"
[disabled]="(!isKulturpass && !!orderItem?.features?.paid) || (changeDateDisabled$ | async)"
[disabled]="
(!isKulturpass && !!orderItem?.features?.paid) ||
(changeDateDisabled$ | async)
"
class="cta-pickup-preferred"
>
<strong class="border-r border-[#AEB7C1] pr-4" *ngIf="preferredPickUpDate$ | async; let pickUpDate; else: selectTemplate">
<strong
class="border-r border-[#AEB7C1] pr-4"
*ngIf="
preferredPickUpDate$ | async;
let pickUpDate;
else: selectTemplate
"
>
{{ pickUpDate | date: 'dd.MM.yy' }}
</strong>
<ng-template #selectTemplate>
<strong class="border-r border-[#AEB7C1] pr-4">Auswählen</strong>
</ng-template>
<shared-icon class="text-[#596470] ml-4" [size]="24" icon="isa-calendar"></shared-icon>
<shared-icon
class="text-[#596470] ml-4"
[size]="24"
icon="isa-calendar"
></shared-icon>
</button>
<ui-datepicker
#preferredPickUpDatePicker
@@ -313,7 +481,11 @@
(save)="updatePreferredPickUpDate($event)"
></ui-datepicker>
</div>
<ui-spinner *ngIf="changePreferredDateLoader$ | async; let loader" class="flex flex-row font-bold loader" [show]="loader"></ui-spinner>
<ui-spinner
*ngIf="changePreferredDateLoader$ | async; let loader"
class="flex flex-row font-bold loader"
[show]="loader"
></ui-spinner>
</ng-template>
<ng-template #vslLieferdatum>
@@ -328,7 +500,11 @@
<span class="border-r border-[#AEB7C1] pr-4">
{{ orderItem?.estimatedShippingDate | date: 'dd.MM.yy' }}
</span>
<shared-icon class="text-[#596470] ml-4" [size]="24" icon="isa-calendar"></shared-icon>
<shared-icon
class="text-[#596470] ml-4"
[size]="24"
icon="isa-calendar"
></shared-icon>
</button>
<ui-datepicker
#uiDatepicker
@@ -341,6 +517,10 @@
(save)="updateEstimatedShippingDate($event)"
></ui-datepicker>
</div>
<ui-spinner *ngIf="changeDateLoader$ | async; let loader" class="flex flex-row font-bold loader" [show]="loader"></ui-spinner>
<ui-spinner
*ngIf="changeDateLoader$ | async; let loader"
class="flex flex-row font-bold loader"
[show]="loader"
></ui-spinner>
</ng-template>
</ng-container>

View File

@@ -11,12 +11,17 @@ import {
import { CrmCustomerService } from '@domain/crm';
import { DomainOmsService } from '@domain/oms';
import { NotificationChannel } from '@generated/swagger/checkout-api';
import { KeyValueDTOOfStringAndString, OrderDTO, OrderItemListItemDTO } from '@generated/swagger/oms-api';
import {
KeyValueDTOOfStringAndString,
OrderDTO,
OrderItemListItemDTO,
} from '@generated/swagger/oms-api';
import { DateAdapter } from '@ui/common';
import { cloneDeep } from 'lodash';
import { BehaviorSubject, combineLatest } from 'rxjs';
import { filter, first, map, shareReplay, switchMap } from 'rxjs/operators';
import { CustomerOrderDetailsStore } from '../customer-order-details.store';
import { getEnabledCustomerFeature } from '@isa/crm/data-access';
@Component({
selector: 'page-customer-order-details-header',
@@ -39,14 +44,21 @@ export class CustomerOrderDetailsHeaderComponent implements OnChanges {
return this.order?.features?.orderSource === 'KulturPass';
}
minDateDatepicker = this.dateAdapter.addCalendarDays(this.dateAdapter.today(), -1);
minDateDatepicker = this.dateAdapter.addCalendarDays(
this.dateAdapter.today(),
-1,
);
today = this.dateAdapter.today();
selectedOrderItemCount$ = this._store.selectedeOrderItemSubsetIds$.pipe(map((ids) => ids?.length ?? 0));
selectedOrderItemCount$ = this._store.selectedeOrderItemSubsetIds$.pipe(
map((ids) => ids?.length ?? 0),
);
orderItemCount$ = this._store.items$.pipe(map((items) => items?.length ?? 0));
orderItem$ = this._store.items$.pipe(map((orderItems) => orderItems?.find((_) => true)));
orderItem$ = this._store.items$.pipe(
map((orderItems) => orderItems?.find((_) => true)),
);
preferredPickUpDate$ = new BehaviorSubject<Date>(undefined);
@@ -58,37 +70,57 @@ export class CustomerOrderDetailsHeaderComponent implements OnChanges {
changeStatusDisabled$ = this._store.changeActionDisabled$;
changeDateDisabled$ = this.changeStatusDisabled$;
features$ = this.orderItem$.pipe(
customerFeature$ = this.orderItem$.pipe(
filter((orderItem) => !!orderItem),
switchMap((orderItem) =>
this.customerService.getCustomers(orderItem.buyerNumber).pipe(
map((res) => res.result.find((c) => c.customerNumber === orderItem.buyerNumber)),
map((customer) => customer?.features || []),
map((features) => features.filter((f) => f.enabled && !!f.description)),
map((res) =>
res.result.find((c) => c.customerNumber === orderItem.buyerNumber),
),
map((customer) => getEnabledCustomerFeature(customer?.features)),
),
),
shareReplay(),
);
statusActions$ = this.orderItem$.pipe(
map((orderItem) => orderItem?.actions?.filter((action) => action.enabled === false)),
map((orderItem) =>
orderItem?.actions?.filter((action) => action.enabled === false),
),
);
showMultiselect$ = combineLatest([this._store.items$, this._store.fetchPartial$, this._store.itemsSelectable$]).pipe(
map(([orderItems, fetchPartial, multiSelect]) => multiSelect && fetchPartial && orderItems?.length > 1),
showMultiselect$ = combineLatest([
this._store.items$,
this._store.fetchPartial$,
this._store.itemsSelectable$,
]).pipe(
map(
([orderItems, fetchPartial, multiSelect]) =>
multiSelect && fetchPartial && orderItems?.length > 1,
),
);
crudaUpdate$ = this.orderItem$.pipe(map((orederItem) => !!(orederItem?.cruda & 4)));
crudaUpdate$ = this.orderItem$.pipe(
map((orederItem) => !!(orederItem?.cruda & 4)),
);
editButtonDisabled$ = combineLatest([this.changeStatusLoader$, this.crudaUpdate$]).pipe(
map(([changeStatusLoader, crudaUpdate]) => changeStatusLoader || !crudaUpdate),
editButtonDisabled$ = combineLatest([
this.changeStatusLoader$,
this.crudaUpdate$,
]).pipe(
map(
([changeStatusLoader, crudaUpdate]) => changeStatusLoader || !crudaUpdate,
),
);
canEditStatus$ = combineLatest([this.statusActions$, this.crudaUpdate$]).pipe(
map(([statusActions, crudaUpdate]) => statusActions?.length > 0 && crudaUpdate),
map(
([statusActions, crudaUpdate]) =>
statusActions?.length > 0 && crudaUpdate,
),
);
openAddresses: boolean = false;
openAddresses = false;
get digOrderNumber(): string {
return this.order?.linkedRecords?.find((_) => true)?.number;
@@ -96,7 +128,8 @@ export class CustomerOrderDetailsHeaderComponent implements OnChanges {
get showAddresses(): boolean {
return (
(this.order?.orderType === 2 || this.order?.orderType === 4) && (!!this.order?.shipping || !!this.order?.billing)
(this.order?.orderType === 2 || this.order?.orderType === 4) &&
(!!this.order?.shipping || !!this.order?.billing)
);
}
@@ -130,10 +163,20 @@ export class CustomerOrderDetailsHeaderComponent implements OnChanges {
this.changeStatusDisabled$.next(true);
const orderItems = cloneDeep(this._store.items);
for (const item of orderItems) {
if (this.dateAdapter.compareDate(deadline, new Date(item.pickUpDeadline)) !== 0) {
if (
this.dateAdapter.compareDate(
deadline,
new Date(item.pickUpDeadline),
) !== 0
) {
try {
const res = await this.omsService
.setPickUpDeadline(item.orderId, item.orderItemId, item.orderItemSubsetId, deadline?.toISOString())
.setPickUpDeadline(
item.orderId,
item.orderItemId,
item.orderItemSubsetId,
deadline?.toISOString(),
)
.pipe(first())
.toPromise();
item.pickUpDeadline = deadline.toISOString();
@@ -152,7 +195,12 @@ export class CustomerOrderDetailsHeaderComponent implements OnChanges {
this.changeStatusDisabled$.next(true);
const orderItems = cloneDeep(this._store.items);
for (const item of orderItems) {
if (this.dateAdapter.compareDate(estimatedShippingDate, new Date(item.pickUpDeadline)) !== 0) {
if (
this.dateAdapter.compareDate(
estimatedShippingDate,
new Date(item.pickUpDeadline),
) !== 0
) {
try {
const res = await this.omsService
.setEstimatedShippingDate(
@@ -198,7 +246,10 @@ export class CustomerOrderDetailsHeaderComponent implements OnChanges {
try {
await this.omsService.setPreferredPickUpDate({ data }).toPromise();
this.order.items.forEach((item) => {
item.data.subsetItems.forEach((subsetItem) => (subsetItem.data.preferredPickUpDate = date.toISOString()));
item.data.subsetItems.forEach(
(subsetItem) =>
(subsetItem.data.preferredPickUpDate = date.toISOString()),
);
});
this.findLatestPreferredPickUpDate();
} catch (error) {
@@ -218,7 +269,10 @@ export class CustomerOrderDetailsHeaderComponent implements OnChanges {
if (subsetItems?.length > 0) {
latestDate = new Date(
subsetItems?.reduce((a, b) => {
return new Date(a.data.preferredPickUpDate) > new Date(b.data.preferredPickUpDate) ? a : b;
return new Date(a.data.preferredPickUpDate) >
new Date(b.data.preferredPickUpDate)
? a
: b;
})?.data?.preferredPickUpDate,
);
}

View File

@@ -1,6 +1,7 @@
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { CustomerInfoDTO } from '@generated/swagger/crm-api';
import { CustomerLabelColor, CustomerLabelTextColor } from '../../../constants';
import { getEnabledCustomerFeature } from '@isa/crm/data-access';
@Component({
selector: 'page-customer-result-list-item-full',
@@ -15,11 +16,9 @@ export class CustomerResultListItemFullComponent {
customerLabelTextColor = CustomerLabelTextColor;
get label() {
return this.customer?.features?.find((f) => f.enabled);
return getEnabledCustomerFeature(this.customer?.features);
}
@Input()
customer: CustomerInfoDTO;
constructor() {}
}

View File

@@ -1,6 +1,7 @@
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { CustomerInfoDTO } from '@generated/swagger/crm-api';
import { CustomerLabelColor, CustomerLabelTextColor } from '../../../constants';
import { getEnabledCustomerFeature } from '@isa/crm/data-access';
@Component({
selector: 'page-customer-result-list-item',
@@ -15,11 +16,9 @@ export class CustomerResultListItemComponent {
customerLabelTextColor = CustomerLabelTextColor;
get label() {
return this.customer?.features?.find((f) => f.enabled);
return getEnabledCustomerFeature(this.customer?.features);
}
@Input()
customer: CustomerInfoDTO;
constructor() {}
}

View File

@@ -1,92 +1,99 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup } from '@angular/forms';
import { FormBlock } from '../form-block';
import { InterestsFormBlockData } from './interests-form-block-data';
import { LoyaltyCardService } from '@generated/swagger/crm-api';
import { shareReplay } from 'rxjs/operators';
import { isEqual } from 'lodash';
import { memorize } from '@utils/common';
@Component({
selector: 'app-interests-form-block',
templateUrl: 'interests-form-block.component.html',
styleUrls: ['interests-form-block.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class InterestsFormBlockComponent extends FormBlock<InterestsFormBlockData, UntypedFormGroup> {
private _interests: Map<string, string>;
get interests(): Map<string, string> {
return this._interests;
}
set interests(value: Map<string, string>) {
if (!isEqual(this._interests, value)) {
this._interests = value;
if (this.control) {
this.updateInterestControls();
}
}
}
get tabIndexEnd() {
return this.tabIndexStart + this.interests?.keys.length;
}
constructor(
private _fb: UntypedFormBuilder,
private _LoyaltyCardService: LoyaltyCardService,
) {
super();
this.getInterests().subscribe({
next: (response) => {
const interests = new Map<string, string>();
response.result.forEach((preference) => {
interests.set(preference.key, preference.value);
});
this.interests = interests;
},
error: (error) => {
console.error(error);
},
});
}
@memorize({ ttl: 28800000 })
getInterests() {
return this._LoyaltyCardService.LoyaltyCardListInteressen().pipe(shareReplay(1));
}
updateInterestControls() {
const fData = this.data ?? {};
this.interests?.forEach((value, key) => {
if (!this.control.contains(key)) {
this.control.addControl(key, new UntypedFormControl(fData[key] ?? false));
}
});
Object.keys(this.control.controls).forEach((key) => {
if (!this.interests.has(key)) {
this.control.removeControl(key);
}
});
}
initializeControl(data?: InterestsFormBlockData): void {
const fData = data ?? {};
this.control = this._fb.group({});
this.interests?.forEach((value, key) => {
this.control.addControl(key, new UntypedFormControl(fData[key] ?? false));
});
}
_patchValue(update: { previous: InterestsFormBlockData; current: InterestsFormBlockData }): void {
const fData = update.current ?? {};
this.interests?.forEach((value, key) => {
this.control.get(key).patchValue(fData[key] ?? false);
});
}
}
import { Component, ChangeDetectionStrategy } from '@angular/core';
import {
UntypedFormBuilder,
UntypedFormControl,
UntypedFormGroup,
} from '@angular/forms';
import { FormBlock } from '../form-block';
import { InterestsFormBlockData } from './interests-form-block-data';
import { isEqual } from 'lodash';
@Component({
selector: 'app-interests-form-block',
templateUrl: 'interests-form-block.component.html',
styleUrls: ['interests-form-block.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
})
export class InterestsFormBlockComponent extends FormBlock<
InterestsFormBlockData,
UntypedFormGroup
> {
private _interests: Map<string, string>;
get interests(): Map<string, string> {
return this._interests;
}
set interests(value: Map<string, string>) {
if (!isEqual(this._interests, value)) {
this._interests = value;
if (this.control) {
this.updateInterestControls();
}
}
}
get tabIndexEnd() {
return this.tabIndexStart + this.interests?.keys.length;
}
constructor(private _fb: UntypedFormBuilder) {
super();
// this.getInterests().subscribe({
// next: (response) => {
// const interests = new Map<string, string>();
// response.result.forEach((preference) => {
// interests.set(preference.key, preference.value);
// });
// this.interests = interests;
// },
// error: (error) => {
// console.error(error);
// },
// });
}
// @memorize({ ttl: 28800000 })
// getInterests() {
// return this._LoyaltyCardService.LoyaltyCardListInteressen().pipe(shareReplay(1));
// }
updateInterestControls() {
const fData = this.data ?? {};
this.interests?.forEach((value, key) => {
if (!this.control.contains(key)) {
this.control.addControl(
key,
new UntypedFormControl(fData[key] ?? false),
);
}
});
Object.keys(this.control.controls).forEach((key) => {
if (!this.interests.has(key)) {
this.control.removeControl(key);
}
});
}
initializeControl(data?: InterestsFormBlockData): void {
const fData = data ?? {};
this.control = this._fb.group({});
this.interests?.forEach((value, key) => {
this.control.addControl(key, new UntypedFormControl(fData[key] ?? false));
});
}
_patchValue(update: {
previous: InterestsFormBlockData;
current: InterestsFormBlockData;
}): void {
const fData = update.current ?? {};
this.interests?.forEach((value, key) => {
this.control.get(key).patchValue(fData[key] ?? false);
});
}
}

View File

@@ -1,4 +1,8 @@
<div class="wrapper text-center" [@cardFlip]="state" (@cardFlip.done)="flipAnimationDone($event)">
<div
class="wrapper text-center"
[@cardFlip]="state"
(@cardFlip.done)="flipAnimationDone($event)"
>
@if (cardDetails) {
<div class="card-main">
<div class="icons text-brand">
@@ -36,12 +40,18 @@
<div class="barcode-button">
@if (!isCustomerCard || (isCustomerCard && !frontside)) {
<div class="barcode-field">
<img class="barcode" src="/assets/images/barcode.png" alt="Barcode" />
<img
class="barcode"
src="/assets/images/barcode.png"
alt="Barcode"
/>
</div>
}
@if (isCustomerCard && frontside) {
<div>
<button class="button" (click)="onRewardShop()">Zum Prämienshop</button>
<button class="button" (click)="navigateToReward()">
Zum Prämienshop
</button>
</div>
}
</div>
@@ -55,7 +65,11 @@
}
@if (isCustomerCard && frontside) {
<div class="logo ml-2">
<img class="logo-picture" src="/assets/images/Hugendubel_Logo.png" alt="Hugendubel Logo" />
<img
class="logo-picture"
src="/assets/images/Hugendubel_Logo.png"
alt="Hugendubel Logo"
/>
</div>
}
</div>

View File

@@ -1,8 +1,18 @@
import { animate, state, style, transition, trigger } from '@angular/animations';
import {
animate,
state,
style,
transition,
trigger,
} from '@angular/animations';
import { DecimalPipe } from '@angular/common';
import { Component, Input, OnInit } from '@angular/core';
import { Component, Input, OnInit, inject } from '@angular/core';
import { IconComponent } from '@shared/components/icon';
import { BonusCardInfoDTO } from '@generated/swagger/crm-api';
import { injectTabId } from '@isa/core/tabs';
import { NavigationStateService } from '@isa/core/navigation';
import { Router } from '@angular/router';
import { CustomerSearchNavigation } from '@shared/services/navigation';
@Component({
selector: 'page-customer-kundenkarte',
@@ -35,18 +45,45 @@ import { BonusCardInfoDTO } from '@generated/swagger/crm-api';
],
})
export class KundenkarteComponent implements OnInit {
#tabId = injectTabId();
#router = inject(Router);
#navigationState = inject(NavigationStateService);
#customerNavigationService = inject(CustomerSearchNavigation);
@Input() cardDetails: BonusCardInfoDTO;
@Input() isCustomerCard: boolean;
@Input() customerId: number;
frontside: boolean;
state: 'front' | 'flip' | 'back' = 'front';
constructor() {}
ngOnInit() {
this.frontside = true;
}
onRewardShop(): void {}
async navigateToReward() {
const tabId = this.#tabId();
const customerId = this.customerId;
if (!customerId || !tabId) {
return;
}
this.#navigationState.preserveContext(
{
returnUrl: `/${tabId}/reward`,
autoTriggerContinueFn: true,
},
'select-customer',
);
await this.#router.navigate(
this.#customerNavigationService.detailsRoute({
processId: tabId,
customerId,
}).path,
);
}
onDeletePartnerCard(): void {}

View File

@@ -1,33 +1,41 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CustomerSearchComponent } from './customer-search.component';
import { CustomerResultsSideViewModule } from './results-side-view/results-side-view.module';
import { RouterModule } from '@angular/router';
import { CustomerResultsMainViewModule } from './results-main-view/results-main-view.module';
import { CustomerDetailsMainViewModule } from './details-main-view/details-main-view.module';
import { CustomerHistoryMainViewModule } from './history-main-view/history-main-view.module';
import { CustomerFilterMainViewModule } from './filter-main-view/filter-main-view.module';
import { MainSideViewModule } from './main-side-view/main-side-view.module';
import { OrderDetailsSideViewComponent } from './order-details-side-view/order-details-side-view.component';
import { CustomerMainViewComponent } from './main-view/main-view.component';
import { SharedSplitscreenComponent } from '@shared/components/splitscreen';
@NgModule({
imports: [
CommonModule,
RouterModule,
SharedSplitscreenComponent,
CustomerResultsSideViewModule,
CustomerResultsMainViewModule,
CustomerDetailsMainViewModule,
CustomerHistoryMainViewModule,
CustomerFilterMainViewModule,
MainSideViewModule,
OrderDetailsSideViewComponent,
CustomerMainViewComponent,
],
exports: [CustomerSearchComponent],
declarations: [CustomerSearchComponent],
})
export class CustomerSearchModule {}
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CustomerSearchComponent } from './customer-search.component';
import { CustomerResultsSideViewModule } from './results-side-view/results-side-view.module';
import { RouterModule } from '@angular/router';
import { CustomerResultsMainViewModule } from './results-main-view/results-main-view.module';
import { CustomerDetailsMainViewModule } from './details-main-view/details-main-view.module';
import { CustomerHistoryMainViewModule } from './history-main-view/history-main-view.module';
import { CustomerFilterMainViewModule } from './filter-main-view/filter-main-view.module';
import { MainSideViewModule } from './main-side-view/main-side-view.module';
import { OrderDetailsSideViewComponent } from './order-details-side-view/order-details-side-view.component';
import { CustomerMainViewComponent } from './main-view/main-view.component';
import { SharedSplitscreenComponent } from '@shared/components/splitscreen';
import {
RewardSelectionService,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
@NgModule({
imports: [
CommonModule,
RouterModule,
SharedSplitscreenComponent,
CustomerResultsSideViewModule,
CustomerResultsMainViewModule,
CustomerDetailsMainViewModule,
CustomerHistoryMainViewModule,
CustomerFilterMainViewModule,
MainSideViewModule,
OrderDetailsSideViewComponent,
CustomerMainViewComponent,
],
exports: [CustomerSearchComponent],
declarations: [CustomerSearchComponent],
providers: [
RewardSelectionService,
RewardSelectionPopUpService,
],
})
export class CustomerSearchModule {}

View File

@@ -1,218 +1,245 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, Host, inject } from '@angular/core';
import { CustomerSearchStore } from '../../store';
import { CrmCustomerService } from '@domain/crm';
import { debounceTime, map, switchMap, takeUntil } from 'rxjs/operators';
import { Observable, Subject, combineLatest } from 'rxjs';
import { AssignedPayerDTO, CustomerDTO, ListResponseArgsOfAssignedPayerDTO } from '@generated/swagger/crm-api';
import { AsyncPipe } from '@angular/common';
import { CustomerPipesModule } from '@shared/pipes/customer';
import { ComponentStore } from '@ngrx/component-store';
import { tapResponse } from '@ngrx/operators';
import { UiModalService } from '@ui/modal';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { RouterLink } from '@angular/router';
import { CustomerDetailsViewMainComponent } from '../details-main-view.component';
import { PayerDTO } from '@generated/swagger/checkout-api';
interface DetailsMainViewBillingAddressesComponentState {
assignedPayers: AssignedPayerDTO[];
selectedPayer: AssignedPayerDTO;
}
@Component({
selector: 'page-details-main-view-billing-addresses',
templateUrl: 'details-main-view-billing-addresses.component.html',
styleUrls: ['details-main-view-billing-addresses.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-details-main-view-billing-addresses' },
imports: [AsyncPipe, CustomerPipesModule, RouterLink],
})
export class DetailsMainViewBillingAddressesComponent
extends ComponentStore<DetailsMainViewBillingAddressesComponentState>
implements OnInit, OnDestroy
{
private _host = inject(CustomerDetailsViewMainComponent, { host: true });
private _store = inject(CustomerSearchStore);
private _customerService = inject(CrmCustomerService);
private _modal = inject(UiModalService);
private _navigation = inject(CustomerSearchNavigation);
assignedPayers$ = this.select((state) => state.assignedPayers);
selectedPayer$ = this.select((state) => state.selectedPayer);
isNotBusinessKonto$ = this._store.isBusinessKonto$.pipe(map((isBusinessKonto) => !isBusinessKonto));
showCustomerAddress$ = combineLatest([
this._store.isBusinessKonto$,
this._store.isMitarbeiter$,
this._store.isKundenkarte$,
]).pipe(map(([isBusinessKonto, isMitarbeiter, isKundenkarte]) => isBusinessKonto || isMitarbeiter || isKundenkarte));
get showCustomerAddress() {
return this._store.isBusinessKonto || this._store.isMitarbeiter;
}
canAddNewAddress$ = combineLatest([
this._store.isOnlinekonto$,
this._store.isOnlineKontoMitKundenkarte$,
this._store.isKundenkarte$,
]).pipe(
map(
([isOnlinekonto, isOnlineKontoMitKundenkarte, isKundenkarte]) =>
isOnlinekonto || isOnlineKontoMitKundenkarte || isKundenkarte,
),
);
canEditAddress$ = combineLatest([this._store.isKundenkarte$]).pipe(map(([isKundenkarte]) => isKundenkarte));
customer$ = this._store.customer$;
private _onDestroy$ = new Subject<void>();
editRoute$ = combineLatest([this._store.processId$, this._store.customerId$, this._store.isBusinessKonto$]).pipe(
map(([processId, customerId, isB2b]) => this._navigation.editRoute({ processId, customerId, isB2b })),
);
addBillingAddressRoute$ = combineLatest([
this.canAddNewAddress$,
this._store.processId$,
this._store.customerId$,
]).pipe(
map(([canAddNewAddress, processId, customerId]) =>
canAddNewAddress ? this._navigation.addBillingAddressRoute({ processId, customerId }) : undefined,
),
);
constructor() {
super({
assignedPayers: [],
selectedPayer: undefined,
});
}
editRoute(payerId: number) {
return this._navigation.editBillingAddressRoute({
customerId: this._store.customerId,
payerId,
processId: this._store.processId,
});
}
ngOnInit() {
combineLatest([this._store.customerId$, this._store.isMitarbeiter$])
.pipe(takeUntil(this._onDestroy$), debounceTime(250))
.subscribe(([customerId, isMitarbeiter]) => {
this.resetStore();
// #4715 Hier erfolgt ein Check auf Mitarbeiter, da Mitarbeiter keine zusätzlichen Rechnungsadressen haben sollen
if (customerId && !isMitarbeiter) {
this.loadAssignedPayers(customerId);
}
});
combineLatest([this.selectedPayer$, this._store.customer$])
.pipe(takeUntil(this._onDestroy$))
.subscribe(([selectedPayer, customer]) => {
if (selectedPayer) {
this._host.setPayer(this._createPayerFromCrmPayerDTO(selectedPayer));
} else if (this.showCustomerAddress) {
this._host.setPayer(this._createPayerFormCustomer(customer));
}
});
}
_createPayerFromCrmPayerDTO(assignedPayer: AssignedPayerDTO): PayerDTO {
const payer = assignedPayer.payer.data;
return {
reference: { id: payer.id },
payerType: payer.payerType as any,
payerNumber: payer.payerNumber,
payerStatus: payer.payerStatus,
gender: payer.gender,
title: payer.title,
firstName: payer.firstName,
lastName: payer.lastName,
communicationDetails: payer.communicationDetails ? { ...payer.communicationDetails } : undefined,
organisation: payer.organisation ? { ...payer.organisation } : undefined,
address: payer.address ? { ...payer.address } : undefined,
source: payer.id,
};
}
_createPayerFormCustomer(customer: CustomerDTO): PayerDTO {
return {
reference: { id: customer.id },
payerType: customer.customerType as any,
payerNumber: customer.customerNumber,
payerStatus: 0,
gender: customer.gender,
title: customer.title,
firstName: customer.firstName,
lastName: customer.lastName,
communicationDetails: customer.communicationDetails ? { ...customer.communicationDetails } : undefined,
organisation: customer.organisation ? { ...customer.organisation } : undefined,
address: customer.address ? { ...customer.address } : undefined,
};
}
ngOnDestroy() {
this._onDestroy$.next();
this._onDestroy$.complete();
}
loadAssignedPayers = this.effect((customerId$: Observable<number>) =>
customerId$.pipe(
switchMap((customerId) =>
this._customerService
.getAssignedPayers({ customerId })
.pipe(tapResponse(this.handleLoadAssignedPayersResponse, this.handleLoadAssignedPayersError)),
),
),
);
handleLoadAssignedPayersResponse = (response: ListResponseArgsOfAssignedPayerDTO) => {
const selectedPayer = response.result.reduce<AssignedPayerDTO>((prev, curr) => {
if (!prev) {
return curr;
}
const prevDate = new Date(prev?.isDefault ?? 0);
const currDate = new Date(curr?.isDefault ?? 0);
if (prevDate > currDate) {
return prev;
}
return curr;
}, undefined);
this.patchState({
assignedPayers: response.result,
selectedPayer,
});
};
handleLoadAssignedPayersError = (err: any) => {
this._modal.error('Laden der Rechnungsadressen fehlgeschlagen', err);
};
resetStore() {
this.patchState({
assignedPayers: [],
selectedPayer: undefined,
});
}
selectPayer(payer: AssignedPayerDTO) {
this.patchState({
selectedPayer: payer,
});
}
selectCustomerAddress() {
this.patchState({
selectedPayer: undefined,
});
}
}
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
inject,
} from '@angular/core';
import { CustomerSearchStore } from '../../store';
import { CrmCustomerService } from '@domain/crm';
import { debounceTime, map, switchMap, takeUntil } from 'rxjs/operators';
import { Observable, Subject, combineLatest } from 'rxjs';
import {
AssignedPayerDTO,
ListResponseArgsOfAssignedPayerDTO,
} from '@generated/swagger/crm-api';
import { AsyncPipe } from '@angular/common';
import { CustomerPipesModule } from '@shared/pipes/customer';
import { ComponentStore } from '@ngrx/component-store';
import { tapResponse } from '@ngrx/operators';
import { UiModalService } from '@ui/modal';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { RouterLink } from '@angular/router';
import { CustomerDetailsViewMainComponent } from '../details-main-view.component';
import {
AssignedPayer,
CrmTabMetadataService,
Customer,
} from '@isa/crm/data-access';
import { injectTabId } from '@isa/core/tabs';
import { CustomerAdapter } from '@isa/checkout/data-access';
interface DetailsMainViewBillingAddressesComponentState {
assignedPayers: AssignedPayerDTO[];
selectedPayer: AssignedPayerDTO;
}
@Component({
selector: 'page-details-main-view-billing-addresses',
templateUrl: 'details-main-view-billing-addresses.component.html',
styleUrls: ['details-main-view-billing-addresses.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-details-main-view-billing-addresses' },
imports: [AsyncPipe, CustomerPipesModule, RouterLink],
})
export class DetailsMainViewBillingAddressesComponent
extends ComponentStore<DetailsMainViewBillingAddressesComponentState>
implements OnInit, OnDestroy
{
tabId = injectTabId();
crmTabMetadataService = inject(CrmTabMetadataService);
private _host = inject(CustomerDetailsViewMainComponent, { host: true });
private _store = inject(CustomerSearchStore);
private _customerService = inject(CrmCustomerService);
private _modal = inject(UiModalService);
private _navigation = inject(CustomerSearchNavigation);
assignedPayers$ = this.select((state) => state.assignedPayers);
selectedPayer$ = this.select((state) => state.selectedPayer);
isNotBusinessKonto$ = this._store.isBusinessKonto$.pipe(
map((isBusinessKonto) => !isBusinessKonto),
);
showCustomerAddress$ = combineLatest([
this._store.isBusinessKonto$,
this._store.isMitarbeiter$,
this._store.isKundenkarte$,
]).pipe(
map(
([isBusinessKonto, isMitarbeiter, isKundenkarte]) =>
isBusinessKonto || isMitarbeiter || isKundenkarte,
),
);
get showCustomerAddress() {
return this._store.isBusinessKonto || this._store.isMitarbeiter;
}
canAddNewAddress$ = combineLatest([
this._store.isOnlinekonto$,
this._store.isOnlineKontoMitKundenkarte$,
this._store.isKundenkarte$,
]).pipe(
map(
([isOnlinekonto, isOnlineKontoMitKundenkarte, isKundenkarte]) =>
isOnlinekonto || isOnlineKontoMitKundenkarte || isKundenkarte,
),
);
canEditAddress$ = combineLatest([this._store.isKundenkarte$]).pipe(
map(([isKundenkarte]) => isKundenkarte),
);
customer$ = this._store.customer$;
private _onDestroy$ = new Subject<void>();
editRoute$ = combineLatest([
this._store.processId$,
this._store.customerId$,
this._store.isBusinessKonto$,
]).pipe(
map(([processId, customerId, isB2b]) =>
this._navigation.editRoute({ processId, customerId, isB2b }),
),
);
addBillingAddressRoute$ = combineLatest([
this.canAddNewAddress$,
this._store.processId$,
this._store.customerId$,
]).pipe(
map(([canAddNewAddress, processId, customerId]) =>
canAddNewAddress
? this._navigation.addBillingAddressRoute({ processId, customerId })
: undefined,
),
);
constructor() {
super({
assignedPayers: [],
selectedPayer: undefined,
});
}
editRoute(payerId: number) {
return this._navigation.editBillingAddressRoute({
customerId: this._store.customerId,
payerId,
processId: this._store.processId,
});
}
ngOnInit() {
combineLatest([this._store.customerId$, this._store.isMitarbeiter$])
.pipe(takeUntil(this._onDestroy$), debounceTime(250))
.subscribe(([customerId, isMitarbeiter]) => {
this.resetStore();
// #4715 Hier erfolgt ein Check auf Mitarbeiter, da Mitarbeiter keine zusätzlichen Rechnungsadressen haben sollen
if (customerId && !isMitarbeiter) {
this.loadAssignedPayers(customerId);
}
});
combineLatest([this.selectedPayer$, this._store.customer$])
.pipe(takeUntil(this._onDestroy$))
.subscribe(([selectedPayer, customer]) => {
if (selectedPayer) {
const payer = CustomerAdapter.toPayerFromAssignedPayer(
selectedPayer as AssignedPayer,
);
if (payer) {
this._host.setPayer(payer);
this.crmTabMetadataService.setSelectedPayerId(
this.tabId(),
selectedPayer?.payer?.id,
);
}
} else if (this.showCustomerAddress) {
this._host.setPayer(
CustomerAdapter.toPayerFromCustomer(
customer as unknown as Customer,
),
);
}
});
}
ngOnDestroy() {
this._onDestroy$.next();
this._onDestroy$.complete();
}
loadAssignedPayers = this.effect((customerId$: Observable<number>) =>
customerId$.pipe(
switchMap((customerId) =>
this._customerService
.getAssignedPayers({ customerId })
.pipe(
tapResponse(
this.handleLoadAssignedPayersResponse,
this.handleLoadAssignedPayersError,
),
),
),
),
);
handleLoadAssignedPayersResponse = (
response: ListResponseArgsOfAssignedPayerDTO,
) => {
const selectedPayer = response.result.reduce<AssignedPayerDTO>(
(prev, curr) => {
if (!prev) {
return curr;
}
const prevDate = new Date(prev?.isDefault ?? 0);
const currDate = new Date(curr?.isDefault ?? 0);
if (prevDate > currDate) {
return prev;
}
return curr;
},
undefined,
);
this.patchState({
assignedPayers: response.result,
selectedPayer,
});
};
handleLoadAssignedPayersError = (err: unknown) => {
this._modal.error(
'Laden der Rechnungsadressen fehlgeschlagen',
err as Error,
);
};
resetStore() {
this.patchState({
assignedPayers: [],
selectedPayer: undefined,
});
}
selectPayer(payer: AssignedPayerDTO) {
this.patchState({
selectedPayer: payer,
});
}
selectCustomerAddress() {
this.patchState({
selectedPayer: undefined,
});
}
}

View File

@@ -1,217 +1,267 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, Host, inject } from '@angular/core';
import { CustomerSearchStore } from '../../store';
import { CrmCustomerService } from '@domain/crm';
import { map, switchMap, takeUntil } from 'rxjs/operators';
import { Observable, Subject, combineLatest } from 'rxjs';
import { CustomerDTO, ListResponseArgsOfAssignedPayerDTO, ShippingAddressDTO } from '@generated/swagger/crm-api';
import { AsyncPipe } from '@angular/common';
import { CustomerPipesModule } from '@shared/pipes/customer';
import { ComponentStore } from '@ngrx/component-store';
import { tapResponse } from '@ngrx/operators';
import { UiModalService } from '@ui/modal';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { RouterLink } from '@angular/router';
import { CustomerDetailsViewMainComponent } from '../details-main-view.component';
interface DetailsMainViewDeliveryAddressesComponentState {
shippingAddresses: ShippingAddressDTO[];
selectedShippingAddress: ShippingAddressDTO;
}
@Component({
selector: 'page-details-main-view-delivery-addresses',
templateUrl: 'details-main-view-delivery-addresses.component.html',
styleUrls: ['details-main-view-delivery-addresses.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-details-main-view-delivery-addresses' },
imports: [AsyncPipe, CustomerPipesModule, RouterLink],
})
export class DetailsMainViewDeliveryAddressesComponent
extends ComponentStore<DetailsMainViewDeliveryAddressesComponentState>
implements OnInit, OnDestroy
{
private _host = inject(CustomerDetailsViewMainComponent, { host: true });
private _store = inject(CustomerSearchStore);
private _customerService = inject(CrmCustomerService);
private _modal = inject(UiModalService);
private _navigation = inject(CustomerSearchNavigation);
shippingAddresses$ = this.select((state) => state.shippingAddresses);
selectedShippingAddress$ = this.select((state) => state.selectedShippingAddress);
get selectedShippingAddress() {
return this.get((s) => s.selectedShippingAddress);
}
private _onDestroy$ = new Subject<void>();
showCustomerAddress$ = combineLatest([
this._store.isBusinessKonto$,
this._store.isMitarbeiter$,
this._store.isKundenkarte$,
]).pipe(map(([isBusinessKonto, isMitarbeiter, isKundenkarte]) => isBusinessKonto || isMitarbeiter || isKundenkarte));
get showCustomerAddress() {
return this._store.isBusinessKonto || this._store.isMitarbeiter;
}
customer$ = this._store.customer$;
canAddNewAddress$ = combineLatest([
this._store.isOnlinekonto$,
this._store.isOnlineKontoMitKundenkarte$,
this._store.isKundenkarte$,
this._store.isBusinessKonto$,
this._store.isMitarbeiter$,
]).pipe(
map(
([isOnlinekonto, isOnlineKontoMitKundenkarte, isKundenkarte, isBusinessKonto, isMitarbeiter]) =>
isOnlinekonto || isOnlineKontoMitKundenkarte || isKundenkarte || isBusinessKonto || isMitarbeiter,
),
);
editRoute$ = combineLatest([this._store.processId$, this._store.customerId$, this._store.isBusinessKonto$]).pipe(
map(([processId, customerId, isB2b]) => this._navigation.editRoute({ processId, customerId, isB2b })),
);
addShippingAddressRoute$ = combineLatest([
this.canAddNewAddress$,
this._store.processId$,
this._store.customerId$,
]).pipe(
map(([canAddNewAddress, processId, customerId]) =>
canAddNewAddress ? this._navigation.addShippingAddressRoute({ processId, customerId }) : undefined,
),
);
editShippingAddressRoute$ = (shippingAddressId: number) =>
combineLatest([this.canEditAddress$, this._store.processId$, this._store.customerId$]).pipe(
map(([canEditAddress, processId, customerId]) => {
if (canEditAddress) {
return this._navigation.editShippingAddressRoute({ processId, customerId, shippingAddressId });
}
return undefined;
}),
);
canEditAddress$ = combineLatest([
this._store.isKundenkarte$,
this._store.isBusinessKonto$,
this._store.isMitarbeiter$,
]).pipe(map(([isKundenkarte, isBusinessKonto, isMitarbeiter]) => isKundenkarte || isBusinessKonto || isMitarbeiter));
constructor() {
super({
shippingAddresses: [],
selectedShippingAddress: undefined,
});
}
ngOnInit() {
this._store.customerId$.pipe(takeUntil(this._onDestroy$)).subscribe((customerId) => {
this.resetStore();
if (customerId) {
this.loadShippingAddresses(customerId);
}
});
combineLatest([this.selectedShippingAddress$, this._store.customer$])
.pipe(takeUntil(this._onDestroy$))
.subscribe(([selectedShippingAddress, customer]) => {
if (selectedShippingAddress) {
this._host.setShippingAddress(this._createShippingAddressFromShippingAddress(selectedShippingAddress));
} else if (this.showCustomerAddress) {
this._host.setShippingAddress(this._createShippingAddressFromCustomer(customer));
}
});
}
ngOnDestroy() {
this._onDestroy$.next();
this._onDestroy$.complete();
}
_createShippingAddressFromCustomer(customer: CustomerDTO) {
return {
reference: { id: customer.id },
gender: customer?.gender,
title: customer?.title,
firstName: customer?.firstName,
lastName: customer?.lastName,
communicationDetails: customer?.communicationDetails ? { ...customer?.communicationDetails } : undefined,
organisation: customer?.organisation ? { ...customer?.organisation } : undefined,
address: customer?.address ? { ...customer?.address } : undefined,
};
}
_createShippingAddressFromShippingAddress(address: ShippingAddressDTO) {
return {
reference: { id: address.id },
gender: address.gender,
title: address.title,
firstName: address.firstName,
lastName: address.lastName,
communicationDetails: address.communicationDetails ? { ...address.communicationDetails } : undefined,
organisation: address.organisation ? { ...address.organisation } : undefined,
address: address.address ? { ...address.address } : undefined,
source: address.id,
};
}
loadShippingAddresses = this.effect((customerId$: Observable<number>) =>
customerId$.pipe(
switchMap((customerId) =>
this._customerService
.getShippingAddresses({ customerId })
.pipe(tapResponse(this.handleLoadShippingAddressesResponse, this.handleLoadAssignedPayersError)),
),
),
);
handleLoadShippingAddressesResponse = (response: ListResponseArgsOfAssignedPayerDTO) => {
const selectedShippingAddress = response.result.reduce<ShippingAddressDTO>((prev, curr) => {
if (!this.showCustomerAddress && !prev) {
return curr;
}
const prevDate = new Date(prev?.isDefault ?? 0);
const currDate = new Date(curr?.isDefault ?? 0);
if (prevDate > currDate) {
return prev;
}
return curr;
}, undefined);
this.patchState({
shippingAddresses: response.result,
selectedShippingAddress,
});
};
handleLoadAssignedPayersError = (err: any) => {
this._modal.error('Laden der Lieferadressen fehlgeschlagen', err);
};
resetStore() {
this.patchState({
shippingAddresses: [],
selectedShippingAddress: undefined,
});
}
selectShippingAddress(shippingAddress: ShippingAddressDTO) {
this.patchState({
selectedShippingAddress: shippingAddress,
});
}
selectCustomerAddress() {
this.patchState({
selectedShippingAddress: undefined,
});
}
}
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
inject,
} from '@angular/core';
import { CustomerSearchStore } from '../../store';
import { CrmCustomerService } from '@domain/crm';
import { map, switchMap, takeUntil } from 'rxjs/operators';
import { Observable, Subject, combineLatest } from 'rxjs';
import {
ListResponseArgsOfAssignedPayerDTO,
ShippingAddressDTO,
} from '@generated/swagger/crm-api';
import { AsyncPipe } from '@angular/common';
import { CustomerPipesModule } from '@shared/pipes/customer';
import { ComponentStore } from '@ngrx/component-store';
import { tapResponse } from '@ngrx/operators';
import { UiModalService } from '@ui/modal';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { RouterLink } from '@angular/router';
import { CustomerDetailsViewMainComponent } from '../details-main-view.component';
import { CrmTabMetadataService, Customer } from '@isa/crm/data-access';
import { injectTabId } from '@isa/core/tabs';
import { ShippingAddressAdapter } from '@isa/checkout/data-access';
interface DetailsMainViewDeliveryAddressesComponentState {
shippingAddresses: ShippingAddressDTO[];
selectedShippingAddress: ShippingAddressDTO;
}
@Component({
selector: 'page-details-main-view-delivery-addresses',
templateUrl: 'details-main-view-delivery-addresses.component.html',
styleUrls: ['details-main-view-delivery-addresses.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-details-main-view-delivery-addresses' },
imports: [AsyncPipe, CustomerPipesModule, RouterLink],
})
export class DetailsMainViewDeliveryAddressesComponent
extends ComponentStore<DetailsMainViewDeliveryAddressesComponentState>
implements OnInit, OnDestroy
{
tabId = injectTabId();
crmTabMetadataService = inject(CrmTabMetadataService);
private _host = inject(CustomerDetailsViewMainComponent, { host: true });
private _store = inject(CustomerSearchStore);
private _customerService = inject(CrmCustomerService);
private _modal = inject(UiModalService);
private _navigation = inject(CustomerSearchNavigation);
shippingAddresses$ = this.select((state) => state.shippingAddresses);
selectedShippingAddress$ = this.select(
(state) => state.selectedShippingAddress,
);
get selectedShippingAddress() {
return this.get((s) => s.selectedShippingAddress);
}
private _onDestroy$ = new Subject<void>();
showCustomerAddress$ = combineLatest([
this._store.isBusinessKonto$,
this._store.isMitarbeiter$,
this._store.isKundenkarte$,
]).pipe(
map(
([isBusinessKonto, isMitarbeiter, isKundenkarte]) =>
isBusinessKonto || isMitarbeiter || isKundenkarte,
),
);
get showCustomerAddress() {
return this._store.isBusinessKonto || this._store.isMitarbeiter;
}
customer$ = this._store.customer$;
canAddNewAddress$ = combineLatest([
this._store.isOnlinekonto$,
this._store.isOnlineKontoMitKundenkarte$,
this._store.isKundenkarte$,
this._store.isBusinessKonto$,
this._store.isMitarbeiter$,
]).pipe(
map(
([
isOnlinekonto,
isOnlineKontoMitKundenkarte,
isKundenkarte,
isBusinessKonto,
isMitarbeiter,
]) =>
isOnlinekonto ||
isOnlineKontoMitKundenkarte ||
isKundenkarte ||
isBusinessKonto ||
isMitarbeiter,
),
);
editRoute$ = combineLatest([
this._store.processId$,
this._store.customerId$,
this._store.isBusinessKonto$,
]).pipe(
map(([processId, customerId, isB2b]) =>
this._navigation.editRoute({ processId, customerId, isB2b }),
),
);
addShippingAddressRoute$ = combineLatest([
this.canAddNewAddress$,
this._store.processId$,
this._store.customerId$,
]).pipe(
map(([canAddNewAddress, processId, customerId]) =>
canAddNewAddress
? this._navigation.addShippingAddressRoute({ processId, customerId })
: undefined,
),
);
editShippingAddressRoute$ = (shippingAddressId: number) =>
combineLatest([
this.canEditAddress$,
this._store.processId$,
this._store.customerId$,
]).pipe(
map(([canEditAddress, processId, customerId]) => {
if (canEditAddress) {
return this._navigation.editShippingAddressRoute({
processId,
customerId,
shippingAddressId,
});
}
return undefined;
}),
);
canEditAddress$ = combineLatest([
this._store.isKundenkarte$,
this._store.isBusinessKonto$,
this._store.isMitarbeiter$,
]).pipe(
map(
([isKundenkarte, isBusinessKonto, isMitarbeiter]) =>
isKundenkarte || isBusinessKonto || isMitarbeiter,
),
);
constructor() {
super({
shippingAddresses: [],
selectedShippingAddress: undefined,
});
}
ngOnInit() {
this._store.customerId$
.pipe(takeUntil(this._onDestroy$))
.subscribe((customerId) => {
this.resetStore();
if (customerId) {
this.loadShippingAddresses(customerId);
}
});
combineLatest([this.selectedShippingAddress$, this._store.customer$])
.pipe(takeUntil(this._onDestroy$))
.subscribe(([selectedShippingAddress, customer]) => {
if (selectedShippingAddress) {
this._host.setShippingAddress(
ShippingAddressAdapter.fromCrmShippingAddress(
selectedShippingAddress,
),
);
this.crmTabMetadataService.setSelectedShippingAddressId(
this.tabId(),
selectedShippingAddress?.id,
);
} else if (this.showCustomerAddress) {
this._host.setShippingAddress(
ShippingAddressAdapter.fromCustomer(
customer as unknown as Customer,
),
);
}
});
}
ngOnDestroy() {
this._onDestroy$.next();
this._onDestroy$.complete();
}
loadShippingAddresses = this.effect((customerId$: Observable<number>) =>
customerId$.pipe(
switchMap((customerId) =>
this._customerService
.getShippingAddresses({ customerId })
.pipe(
tapResponse(
this.handleLoadShippingAddressesResponse,
this.handleLoadAssignedPayersError,
),
),
),
),
);
handleLoadShippingAddressesResponse = (
response: ListResponseArgsOfAssignedPayerDTO,
) => {
const selectedShippingAddress = response.result.reduce<ShippingAddressDTO>(
(prev, curr) => {
if (!this.showCustomerAddress && !prev) {
return curr;
}
const prevDate = new Date(prev?.isDefault ?? 0);
const currDate = new Date(curr?.isDefault ?? 0);
if (prevDate > currDate) {
return prev;
}
return curr;
},
undefined,
);
this.patchState({
shippingAddresses: response.result,
selectedShippingAddress,
});
};
handleLoadAssignedPayersError = (err: unknown) => {
this._modal.error('Laden der Lieferadressen fehlgeschlagen', err as Error);
};
resetStore() {
this.patchState({
shippingAddresses: [],
selectedShippingAddress: undefined,
});
}
selectShippingAddress(shippingAddress: ShippingAddressDTO) {
this.patchState({
selectedShippingAddress: shippingAddress,
});
}
selectCustomerAddress() {
this.patchState({
selectedShippingAddress: undefined,
});
}
}

View File

@@ -1,185 +1,198 @@
<shared-loader [loading]="fetching$ | async" background="true" spinnerSize="32">
<div class="overflow-scroll max-h-[calc(100vh-15rem)]">
<div class="customer-details-header grid grid-flow-row pb-6">
<div class="customer-details-header-actions flex flex-row justify-end pt-4 px-4">
<page-customer-menu
[customerId]="customerId$ | async"
[processId]="processId$ | async"
[hasCustomerCard]="hasKundenkarte$ | async"
[showCustomerDetails]="false"
></page-customer-menu>
</div>
<div class="customer-details-header-body text-center -mt-3">
<h1 class="text-[1.625rem] font-bold">
{{ (isBusinessKonto$ | async) ? 'Firmendetails' : 'Kundendetails' }}
</h1>
<p>Sind Ihre Kundendaten korrekt?</p>
</div>
</div>
<div class="customer-details-customer-type flex flex-row justify-between items-center bg-surface-2 text-surface-2-content h-14">
<div class="pl-4 font-bold grid grid-flow-col justify-start items-center gap-2">
<shared-icon [icon]="customerType$ | async"></shared-icon>
<span>
{{ customerType$ | async }}
</span>
</div>
@if (showEditButton$ | async) {
@if (editRoute$ | async; as editRoute) {
<a
[routerLink]="editRoute.path"
[queryParams]="editRoute.queryParams"
[queryParamsHandling]="'merge'"
class="btn btn-label font-bold text-brand"
>
Bearbeiten
</a>
}
}
</div>
<div class="customer-details-customer-main-data px-5 py-3 grid grid-flow-row gap-3">
<div class="flex flex-row">
<div class="data-label">Erstellungsdatum</div>
@if (created$ | async; as created) {
<div class="data-value">
{{ created | date: 'dd.MM.yyyy' }} | {{ created | date: 'HH:mm' }} Uhr
</div>
}
</div>
<div class="flex flex-row">
<div class="data-label">Kundennummer</div>
<div class="data-value">{{ customerNumber$ | async }}</div>
</div>
@if (customerNumberDig$ | async; as customerNumberDig) {
<div class="flex flex-row">
<div class="data-label">Kundennummer-DIG</div>
<div class="data-value">{{ customerNumberDig }}</div>
</div>
}
@if (customerNumberBeeline$ | async; as customerNumberBeeline) {
<div class="flex flex-row">
<div class="data-label">Kundennummer-BEELINE</div>
<div class="data-value">{{ customerNumberBeeline }}</div>
</div>
}
</div>
@if (isBusinessKonto$ | async) {
<div class="customer-details-customer-main-row">
<div class="data-label">Firmenname</div>
<div class="data-value">{{ organisationName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Abteilung</div>
<div class="data-value">{{ department$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">USt-ID</div>
<div class="data-value">{{ vatId$ | async }}</div>
</div>
}
<div class="customer-details-customer-main-row">
<div class="data-label">Anrede</div>
<div class="data-value">{{ gender$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Titel</div>
<div class="data-value">{{ title$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Nachname</div>
<div class="data-value">{{ lastName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Vorname</div>
<div class="data-value">{{ firstName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">E-Mail</div>
<div class="data-value">{{ email$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Straße</div>
<div class="data-value">{{ street$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Hausnr.</div>
<div class="data-value">{{ streetNumber$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">PLZ</div>
<div class="data-value">{{ zipCode$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Ort</div>
<div class="data-value">{{ city$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Adresszusatz</div>
<div class="data-value">{{ info$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Land</div>
@if (country$ | async; as country) {
<div class="data-value">{{ country | country }}</div>
}
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Festnetznr.</div>
<div class="data-value">{{ landline$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Mobilnr.</div>
<div class="data-value">{{ mobile$ | async }}</div>
</div>
@if (!(isBusinessKonto$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Geburtstag</div>
<div class="data-value">{{ dateOfBirth$ | async | date: 'dd.MM.yyyy' }}</div>
</div>
}
@if (!(isBusinessKonto$ | async) && (organisationName$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Firmenname</div>
<div class="data-value">{{ organisationName$ | async }}</div>
</div>
@if (!(isOnlineOrCustomerCardUser$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Abteilung</div>
<div class="data-value">{{ department$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">USt-ID</div>
<div class="data-value">{{ vatId$ | async }}</div>
</div>
}
}
<page-details-main-view-billing-addresses></page-details-main-view-billing-addresses>
<page-details-main-view-delivery-addresses></page-details-main-view-delivery-addresses>
<div class="h-24"></div>
</div>
</shared-loader>
@if (shoppingCartHasNoItems$ | async) {
<button
type="button"
(click)="continue()"
class="text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
[disabled]="showLoader$ | async"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32">Weiter zur Artikelsuche</shared-loader>
</button>
}
@if (shoppingCartHasItems$ | async) {
<button
type="button"
(click)="continue()"
class="text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch"
[disabled]="showLoader$ | async"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32">Weiter zum Warenkorb</shared-loader>
</button>
}
<shared-loader [loading]="fetching$ | async" background="true" spinnerSize="32">
<div class="overflow-scroll max-h-[calc(100vh-15rem)]">
<div class="customer-details-header grid grid-flow-row pb-6">
<div
class="customer-details-header-actions flex flex-row justify-end pt-4 px-4"
>
<page-customer-menu
[customerId]="customerId$ | async"
[processId]="processId$ | async"
[hasCustomerCard]="hasKundenkarte$ | async"
[showCustomerDetails]="false"
></page-customer-menu>
</div>
<div class="customer-details-header-body text-center -mt-3">
<h1 class="text-[1.625rem] font-bold">
{{ (isBusinessKonto$ | async) ? 'Firmendetails' : 'Kundendetails' }}
</h1>
<p>Sind Ihre Kundendaten korrekt?</p>
</div>
</div>
<div
class="customer-details-customer-type flex flex-row justify-between items-center bg-surface-2 text-surface-2-content h-14"
>
<div
class="pl-4 font-bold grid grid-flow-col justify-start items-center gap-2"
>
<shared-icon [icon]="customerType$ | async"></shared-icon>
<span>
{{ customerType$ | async }}
</span>
</div>
@if (showEditButton$ | async) {
@if (editRoute$ | async; as editRoute) {
<a
[routerLink]="editRoute.path"
[queryParams]="editRoute.queryParams"
[queryParamsHandling]="'merge'"
class="btn btn-label font-bold text-brand"
>
Bearbeiten
</a>
}
}
</div>
<div
class="customer-details-customer-main-data px-5 py-3 grid grid-flow-row gap-3"
>
<div class="flex flex-row">
<div class="data-label">Erstellungsdatum</div>
@if (created$ | async; as created) {
<div class="data-value">
{{ created | date: 'dd.MM.yyyy' }} |
{{ created | date: 'HH:mm' }} Uhr
</div>
}
</div>
<div class="flex flex-row">
<div class="data-label">Kundennummer</div>
<div class="data-value">{{ customerNumber$ | async }}</div>
</div>
@if (customerNumberDig$ | async; as customerNumberDig) {
<div class="flex flex-row">
<div class="data-label">Kundennummer-DIG</div>
<div class="data-value">{{ customerNumberDig }}</div>
</div>
}
@if (customerNumberBeeline$ | async; as customerNumberBeeline) {
<div class="flex flex-row">
<div class="data-label">Kundennummer-BEELINE</div>
<div class="data-value">{{ customerNumberBeeline }}</div>
</div>
}
</div>
@if (isBusinessKonto$ | async) {
<div class="customer-details-customer-main-row">
<div class="data-label">Firmenname</div>
<div class="data-value">{{ organisationName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Abteilung</div>
<div class="data-value">{{ department$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">USt-ID</div>
<div class="data-value">{{ vatId$ | async }}</div>
</div>
}
<div class="customer-details-customer-main-row">
<div class="data-label">Anrede</div>
<div class="data-value">{{ gender$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Titel</div>
<div class="data-value">{{ title$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Nachname</div>
<div class="data-value">{{ lastName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Vorname</div>
<div class="data-value">{{ firstName$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">E-Mail</div>
<div class="data-value">{{ email$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Straße</div>
<div class="data-value">{{ street$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Hausnr.</div>
<div class="data-value">{{ streetNumber$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">PLZ</div>
<div class="data-value">{{ zipCode$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Ort</div>
<div class="data-value">{{ city$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Adresszusatz</div>
<div class="data-value">{{ info$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Land</div>
@if (country$ | async; as country) {
<div class="data-value">{{ country | country }}</div>
}
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Festnetznr.</div>
<div class="data-value">{{ landline$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">Mobilnr.</div>
<div class="data-value">{{ mobile$ | async }}</div>
</div>
@if (!(isBusinessKonto$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Geburtstag</div>
<div class="data-value">
{{ dateOfBirth$ | async | date: 'dd.MM.yyyy' }}
</div>
</div>
}
@if (!(isBusinessKonto$ | async) && (organisationName$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Firmenname</div>
<div class="data-value">{{ organisationName$ | async }}</div>
</div>
@if (!(isOnlineOrCustomerCardUser$ | async)) {
<div class="customer-details-customer-main-row">
<div class="data-label">Abteilung</div>
<div class="data-value">{{ department$ | async }}</div>
</div>
<div class="customer-details-customer-main-row">
<div class="data-label">USt-ID</div>
<div class="data-value">{{ vatId$ | async }}</div>
</div>
}
}
<page-details-main-view-billing-addresses></page-details-main-view-billing-addresses>
<page-details-main-view-delivery-addresses></page-details-main-view-delivery-addresses>
<div class="h-24"></div>
</div>
</shared-loader>
<button
type="button"
(click)="continue()"
[class]="
'text-white text-lg bg-brand rounded-full px-5 py-3 absolute top-[calc(100vh-19.375rem)] left-1/2 -translate-x-1/2 font-bold disabled:bg-inactive-branch' +
(hasReturnUrl() ? ' w-60' : '')
"
[disabled]="
hasReturnUrl()
? !(hasKundenkarte$ | async) || (showLoader$ | async)
: (showLoader$ | async)
"
>
<shared-loader [loading]="showLoader$ | async" spinnerSize="32">
@if (hasReturnUrl()) {
Auswählen
} @else if (shoppingCartHasItems$ | async) {
Weiter zum Warenkorb
} @else {
Weiter zur Artikelsuche
}
</shared-loader>
</button>

View File

@@ -17,6 +17,7 @@
class="justify-self-center"
[cardDetails]="karte"
[isCustomerCard]="true"
[customerId]="customerId$ | async"
></page-customer-kundenkarte>
}

View File

@@ -1,4 +1,10 @@
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, inject } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
inject,
} from '@angular/core';
import { CustomerSearchStore } from '../store';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { map, takeUntil, tap } from 'rxjs/operators';
@@ -21,6 +27,7 @@ import { CustomerSearchNavigation } from '@shared/services/navigation';
import { CustomerOrderItemListItemComponent } from './order-item-list-item/order-item-list-item.component';
import { groupBy } from '@ui/common';
import { EnvironmentService } from '@core/environment';
import { getEnabledCustomerFeature } from '@isa/crm/data-access';
@Component({
selector: 'page-customer-order-details-main-view',
@@ -52,38 +59,58 @@ export class OrderDetailsMainViewComponent implements OnInit, OnDestroy {
private _navigation = inject(CustomerSearchNavigation);
private _env = inject(EnvironmentService);
orderId$ = this._activateRoute.params.pipe(map((params) => Number(params.orderId)));
orderId$ = this._activateRoute.params.pipe(
map((params) => Number(params.orderId)),
);
order$ = this._store.order$;
orderTargetBranch$ = this.order$.pipe(map((order) => order?.targetBranch?.id));
orderTargetBranch$ = this.order$.pipe(
map((order) => order?.targetBranch?.id),
);
orderShippingTarget$ = this.order$.pipe(map((order) => order?.shipping?.data));
orderShippingTarget$ = this.order$.pipe(
map((order) => order?.shipping?.data),
);
customerId$ = this._activateRoute.params.pipe(map((params) => Number(params.customerId)));
customerId$ = this._activateRoute.params.pipe(
map((params) => Number(params.customerId)),
);
customer$ = this._store.customer$;
accountType$ = this.customer$.pipe(
map((customer) => customer?.features?.find((feature) => feature.group === 'd-customertype')),
map((customer) => getEnabledCustomerFeature(customer?.features)),
);
accountTypeKey$ = this.accountType$.pipe(map((accountType) => accountType?.key));
accountTypeKey$ = this.accountType$.pipe(
map((accountType) => accountType?.key),
);
accountTypeDescription$ = this.accountType$.pipe(map((accountType) => accountType?.description));
accountTypeDescription$ = this.accountType$.pipe(
map((accountType) => accountType?.description),
);
orderItemId$ = this._activateRoute.params.pipe(map((params) => Number(params.orderItemId)));
orderItemId$ = this._activateRoute.params.pipe(
map((params) => Number(params.orderItemId)),
);
orderItems$ = this.order$.pipe(map((order) => order?.items?.map((i) => i?.data)));
orderItems$ = this.order$.pipe(
map((order) => order?.items?.map((i) => i?.data)),
);
selectedOrderItem$ = this._store.selectedOrderItem$;
selectedOrderItemOrderType$ = this.selectedOrderItem$.pipe(map((orderItem) => orderItem?.features?.orderType));
selectedOrderItemOrderType$ = this.selectedOrderItem$.pipe(
map((orderItem) => orderItem?.features?.orderType),
);
private _onDestroy = new Subject<void>();
ordersRoute$ = combineLatest([this.customerId$, this._store.processId$]).pipe(
map(([customerId, processId]) => this._navigation.ordersRoute({ processId, customerId })),
map(([customerId, processId]) =>
this._navigation.ordersRoute({ processId, customerId }),
),
);
orderDetailsHistoryRoute$ = combineLatest([
@@ -93,27 +120,38 @@ export class OrderDetailsMainViewComponent implements OnInit, OnDestroy {
this.orderItemId$,
]).pipe(
map(([customerId, processId, orderId, orderItemId]) =>
this._navigation.orderDetailsHistoryRoute({ processId, customerId, orderId, orderItemId }),
this._navigation.orderDetailsHistoryRoute({
processId,
customerId,
orderId,
orderItemId,
}),
),
);
groupedOrderItemsByOrderType$ = this.orderItems$.pipe(
map((orderItems) => groupBy(orderItems, (orderItem) => orderItem?.features?.orderType)),
map((orderItems) =>
groupBy(orderItems, (orderItem) => orderItem?.features?.orderType),
),
tap((groupedOrderItems) => console.log(groupedOrderItems)),
);
showSelectedItem$ = this._env.matchDesktopXLarge$;
showItemList$ = this.showSelectedItem$.pipe(map((showSelectedItem) => !showSelectedItem));
showItemList$ = this.showSelectedItem$.pipe(
map((showSelectedItem) => !showSelectedItem),
);
ngOnInit(): void {
this.orderId$.pipe(takeUntil(this._onDestroy)).subscribe((orderId) => {
this._store.selectOrder(orderId);
});
this.customerId$.pipe(takeUntil(this._onDestroy)).subscribe((customerId) => {
this._store.selectCustomer({ customerId });
});
this.customerId$
.pipe(takeUntil(this._onDestroy))
.subscribe((customerId) => {
this._store.selectCustomer({ customerId });
});
}
ngOnDestroy(): void {

View File

@@ -1,113 +1,151 @@
<div class="page-customer-order-item-list-item__card-content grid grid-cols-[6rem_1fr] gap-4">
<div>
<img class="rounded shadow mx-auto w-[5.9rem]" [src]="orderItem?.product?.ean | productImage" [alt]="orderItem?.product?.name" />
</div>
<div class="grid grid-flow-row gap-2">
<div class="grid grid-flow-col justify-between items-end">
<span>{{ orderItem.product?.contributors }}</span>
@if (orderDetailsHistoryRoute$ | async; as orderDetailsHistoryRoute) {
<a
[routerLink]="orderDetailsHistoryRoute.path"
[queryParams]="orderDetailsHistoryRoute.urlTree.queryParams"
[queryParamsHandling]="'merge'"
class="text-brand font-bold text-xl"
>
Historie
</a>
}
</div>
<div class="font-bold text-lg">
{{ orderItem?.product?.name }}
</div>
<div>
<span class="isa-label">
{{ processingStatus$ | async | orderItemProcessingStatus }}
</span>
</div>
@if (orderItemSubsetItem$ | async; as orderItemSubsetItem) {
<div class="grid grid-flow-row gap-2">
<div class="col-data">
<div class="col-label">Menge</div>
<div class="col-value">{{ orderItem?.quantity?.quantity }}x</div>
</div>
<div class="col-data">
<div class="col-label">Format</div>
<div class="col-value grid-flow-col grid gap-3 items-center justify-start">
<shared-icon [icon]="orderItem?.product?.format"></shared-icon>
<span>{{ orderItem?.product?.formatDetail }}</span>
</div>
</div>
<div class="col-data">
<div class="col-label">ISBN/EAN</div>
<div class="col-value">{{ orderItem?.product?.ean }}</div>
</div>
<div class="col-data">
<div class="col-label">Preis</div>
<div class="col-value">{{ orderItem?.unitPrice?.value?.value | currency: orderItem?.unitPrice?.value?.currency : 'code' }}</div>
</div>
<div class="col-data">
<div class="col-label">MwSt</div>
<div class="col-value">{{ orderItem?.unitPrice?.vat?.inPercent }}%</div>
</div>
<hr />
<div class="col-data">
<div class="col-label">Lieferant</div>
<div class="col-value">
{{ orderItemSubsetItem?.supplier?.data?.name }}
</div>
</div>
<div class="col-data">
<div class="col-label">Meldenummer</div>
<div class="col-value">{{ orderItemSubsetItem?.ssc }} - {{ orderItemSubsetItem?.sscText }}</div>
</div>
<div class="col-data">
<div class="col-label">Vsl. Lieferdatum</div>
<div class="col-value">
{{ orderItemSubsetItem?.estimatedShippingDate | date: 'dd.MM.yyyy' }}
</div>
</div>
@if (orderItemSubsetItem?.preferredPickUpDate) {
<div class="col-data">
<div class="col-label">Zurücklegen bis</div>
<div class="col-value">
{{ orderItemSubsetItem?.preferredPickUpDate | date: 'dd.MM.yyyy' }}
</div>
</div>
}
<hr />
@if (orderItemSubsetItem?.compartmentCode) {
<div class="col-data">
<div class="col-label">Abholfachnummer</div>
<div class="col-value">
<span>{{ orderItemSubsetItem?.compartmentCode }}</span>
@if (orderItemSubsetItem?.compartmentInfo) {
<span>_{{ orderItemSubsetItem?.compartmentInfo }}</span>
}
</div>
</div>
}
<div class="col-data">
<div class="col-label">Vormerker</div>
<div class="col-value">{{ isPrebooked$ | async }}</div>
</div>
<hr />
<div class="col-data">
<div class="col-label">Zahlungsweg</div>
<div class="col-value">-</div>
</div>
<div class="col-data">
<div class="col-label">Zahlungsart</div>
<div class="col-value">
{{ orderPaymentType$ | async | paymentType }}
</div>
</div>
<div class="col-data">
<div class="col-label">Anmerkung</div>
<div class="col-value">
{{ orderItemSubsetItem?.specialComment || '-' }}
</div>
</div>
</div>
}
</div>
</div>
<div
class="page-customer-order-item-list-item__card-content grid grid-cols-[6rem_1fr] gap-4"
>
<div class="flex flex-col gap-2 justify-start items-center">
@let ean = orderItem?.product?.ean;
@let name = orderItem?.product?.name;
@if (ean && name) {
<img
class="rounded shadow mx-auto w-[5.9rem]"
[src]="ean | productImage"
[alt]="name"
/>
}
@if (hasRewardPoints$ | async) {
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
Prämie
</ui-label>
}
</div>
<div class="grid grid-flow-row gap-2">
<div class="grid grid-flow-col justify-between items-end">
<span>{{ orderItem.product?.contributors }}</span>
@if (orderDetailsHistoryRoute$ | async; as orderDetailsHistoryRoute) {
<a
[routerLink]="orderDetailsHistoryRoute.path"
[queryParams]="orderDetailsHistoryRoute.urlTree.queryParams"
[queryParamsHandling]="'merge'"
class="text-brand font-bold text-xl"
>
Historie
</a>
}
</div>
<div class="font-bold text-lg">
{{ orderItem?.product?.name }}
</div>
<div>
<span class="isa-label">
{{ processingStatus$ | async | orderItemProcessingStatus }}
</span>
</div>
@if (orderItemSubsetItem$ | async; as orderItemSubsetItem) {
<div class="grid grid-flow-row gap-2">
<div class="col-data">
<div class="col-label">Menge</div>
<div class="col-value">{{ orderItem?.quantity?.quantity }}x</div>
</div>
<div class="col-data">
<div class="col-label">Format</div>
<div
class="col-value grid-flow-col grid gap-3 items-center justify-start"
>
@let format = orderItem?.product?.format;
@if (format) {
<shared-icon [icon]="orderItem?.product?.format"></shared-icon>
}
<span>{{ orderItem?.product?.formatDetail }}</span>
</div>
</div>
<div class="col-data">
<div class="col-label">ISBN/EAN</div>
<div class="col-value">{{ orderItem?.product?.ean }}</div>
</div>
<div class="col-data">
@if (hasRewardPoints$ | async) {
<div class="col-label">Prämie</div>
<div class="col-value">{{ rewardPoints$ | async | number: '1.0-0' }} Lesepunkte</div>
} @else {
<div class="col-label">Preis</div>
<div class="col-value">
{{
orderItem?.unitPrice?.value?.value
| currency: orderItem?.unitPrice?.value?.currency : 'code'
}}
</div>
}
</div>
<div class="col-data">
<div class="col-label">MwSt</div>
<div class="col-value">
{{ orderItem?.unitPrice?.vat?.inPercent }}%
</div>
</div>
<hr />
<div class="col-data">
<div class="col-label">Lieferant</div>
<div class="col-value">
{{ orderItemSubsetItem?.supplier?.data?.name }}
</div>
</div>
<div class="col-data">
<div class="col-label">Meldenummer</div>
<div class="col-value">
{{ orderItemSubsetItem?.ssc }} - {{ orderItemSubsetItem?.sscText }}
</div>
</div>
<div class="col-data">
<div class="col-label">Vsl. Lieferdatum</div>
<div class="col-value">
{{
orderItemSubsetItem?.estimatedShippingDate | date: 'dd.MM.yyyy'
}}
</div>
</div>
@if (orderItemSubsetItem?.preferredPickUpDate) {
<div class="col-data">
<div class="col-label">Zurücklegen bis</div>
<div class="col-value">
{{
orderItemSubsetItem?.preferredPickUpDate | date: 'dd.MM.yyyy'
}}
</div>
</div>
}
<hr />
@if (orderItemSubsetItem?.compartmentCode) {
<div class="col-data">
<div class="col-label">Abholfachnummer</div>
<div class="col-value">
<span>{{ orderItemSubsetItem?.compartmentCode }}</span>
@if (orderItemSubsetItem?.compartmentInfo) {
<span>_{{ orderItemSubsetItem?.compartmentInfo }}</span>
}
</div>
</div>
}
<div class="col-data">
<div class="col-label">Vormerker</div>
<div class="col-value">{{ isPrebooked$ | async }}</div>
</div>
<hr />
<div class="col-data">
<div class="col-label">Zahlungsweg</div>
<div class="col-value">-</div>
</div>
<div class="col-data">
<div class="col-label">Zahlungsart</div>
<div class="col-value">
{{ orderPaymentType$ | async | paymentType }}
</div>
</div>
<div class="col-data">
<div class="col-label">Anmerkung</div>
<div class="col-value">
{{ orderItemSubsetItem?.specialComment || '-' }}
</div>
</div>
</div>
}
</div>
</div>

View File

@@ -1,88 +1,136 @@
import { AsyncPipe, CurrencyPipe, DatePipe } from '@angular/common';
import { Component, ChangeDetectionStrategy, Input, OnDestroy, OnInit, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ProductImagePipe } from '@cdn/product-image';
import { OrderItemProcessingStatusPipe } from '@shared/pipes/order';
import { OrderItemDTO } from '@generated/swagger/oms-api';
import { BehaviorSubject, Subject, combineLatest } from 'rxjs';
import { map, takeUntil } from 'rxjs/operators';
import { CustomerSearchStore } from '../../store';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { PaymentTypePipe } from '@shared/pipes/customer';
@Component({
selector: 'page-customer-order-item-list-item',
templateUrl: 'order-item-list-item.component.html',
styleUrls: ['order-item-list-item.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-customer-order-item-list-item' },
imports: [
AsyncPipe,
DatePipe,
ProductImagePipe,
CurrencyPipe,
RouterLink,
PaymentTypePipe,
OrderItemProcessingStatusPipe
],
})
export class CustomerOrderItemListItemComponent implements OnInit, OnDestroy {
private _activatedRoute = inject(ActivatedRoute);
private _store = inject(CustomerSearchStore);
private _navigation = inject(CustomerSearchNavigation);
private _onDestroy = new Subject<void>();
private _orderItemSub = new BehaviorSubject<OrderItemDTO>(undefined);
@Input()
get orderItem() {
return this._orderItemSub.getValue();
}
set orderItem(value: OrderItemDTO) {
this._orderItemSub.next(value);
}
orderId$ = this._activatedRoute.params.pipe(map((params) => Number(params.orderId)));
order$ = this._store.order$;
orderPaymentType$ = this.order$.pipe(map((order) => order?.paymentType));
customerId$ = this._activatedRoute.params.pipe(map((params) => Number(params.customerId)));
orderItemOrderType$ = this._orderItemSub.pipe(map((orderItem) => orderItem?.features?.orderType));
orderItemSubsetItem$ = this._orderItemSub.pipe(map((orderItem) => orderItem?.subsetItems?.[0]?.data));
orderDetailsHistoryRoute$ = combineLatest([
this.customerId$,
this._store.processId$,
this.orderId$,
this._orderItemSub,
]).pipe(
map(([customerId, processId, orderId, orderItem]) =>
this._navigation.orderDetailsHistoryRoute({ processId, customerId, orderId, orderItemId: orderItem?.id }),
),
);
isPrebooked$ = this.orderItemSubsetItem$.pipe(map((subsetItem) => (subsetItem?.isPrebooked ? 'Ja' : 'Nein')));
processingStatus$ = this.orderItemSubsetItem$.pipe(map((subsetItem) => subsetItem?.processingStatus));
ngOnInit() {
this.customerId$.pipe(takeUntil(this._onDestroy)).subscribe((customerId) => {
this._store.selectCustomer({ customerId });
});
this.orderId$.pipe(takeUntil(this._onDestroy)).subscribe((orderId) => {
this._store.selectOrder(+orderId);
});
}
ngOnDestroy() {
this._onDestroy.next();
this._onDestroy.complete();
this._orderItemSub.complete();
}
}
import {
AsyncPipe,
CurrencyPipe,
DatePipe,
DecimalPipe,
} from '@angular/common';
import {
Component,
ChangeDetectionStrategy,
Input,
OnDestroy,
OnInit,
inject,
} from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ProductImagePipe } from '@cdn/product-image';
import { OrderItemProcessingStatusPipe } from '@shared/pipes/order';
import { OrderItemDTO } from '@generated/swagger/oms-api';
import { BehaviorSubject, Subject, combineLatest } from 'rxjs';
import { map, takeUntil } from 'rxjs/operators';
import { CustomerSearchStore } from '../../store';
import { CustomerSearchNavigation } from '@shared/services/navigation';
import { PaymentTypePipe } from '@shared/pipes/customer';
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
import { IconComponent } from '@shared/components/icon';
@Component({
selector: 'page-customer-order-item-list-item',
templateUrl: 'order-item-list-item.component.html',
styleUrls: ['order-item-list-item.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'page-customer-order-item-list-item' },
imports: [
AsyncPipe,
DatePipe,
ProductImagePipe,
CurrencyPipe,
RouterLink,
PaymentTypePipe,
OrderItemProcessingStatusPipe,
LabelComponent,
IconComponent,
DecimalPipe,
],
})
export class CustomerOrderItemListItemComponent implements OnInit, OnDestroy {
private _activatedRoute = inject(ActivatedRoute);
private _store = inject(CustomerSearchStore);
private _navigation = inject(CustomerSearchNavigation);
private _onDestroy = new Subject<void>();
private _orderItemSub = new BehaviorSubject<OrderItemDTO>(undefined);
@Input()
get orderItem() {
return this._orderItemSub.getValue();
}
set orderItem(value: OrderItemDTO) {
this._orderItemSub.next(value);
}
orderId$ = this._activatedRoute.params.pipe(
map((params) => Number(params.orderId)),
);
order$ = this._store.order$;
orderPaymentType$ = this.order$.pipe(map((order) => order?.paymentType));
customerId$ = this._activatedRoute.params.pipe(
map((params) => Number(params.customerId)),
);
orderItemOrderType$ = this._orderItemSub.pipe(
map((orderItem) => orderItem?.features?.orderType),
);
orderItemSubsetItem$ = this._orderItemSub.pipe(
map((orderItem) => orderItem?.subsetItems?.[0]?.data),
);
orderDetailsHistoryRoute$ = combineLatest([
this.customerId$,
this._store.processId$,
this.orderId$,
this._orderItemSub,
]).pipe(
map(([customerId, processId, orderId, orderItem]) =>
this._navigation.orderDetailsHistoryRoute({
processId,
customerId,
orderId,
orderItemId: orderItem?.id,
}),
),
);
isPrebooked$ = this.orderItemSubsetItem$.pipe(
map((subsetItem) => (subsetItem?.isPrebooked ? 'Ja' : 'Nein')),
);
processingStatus$ = this.orderItemSubsetItem$.pipe(
map((subsetItem) => subsetItem?.processingStatus),
);
hasRewardPoints$ = this._orderItemSub.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem) !== undefined),
);
rewardPoints$ = this._orderItemSub.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem)),
);
Labeltype = Labeltype;
LabelPriority = LabelPriority;
ngOnInit() {
this.customerId$
.pipe(takeUntil(this._onDestroy))
.subscribe((customerId) => {
this._store.selectCustomer({ customerId });
});
this.orderId$.pipe(takeUntil(this._onDestroy)).subscribe((orderId) => {
this._store.selectOrder(+orderId);
});
}
ngOnDestroy() {
this._onDestroy.next();
this._onDestroy.complete();
this._orderItemSub.complete();
}
}

View File

@@ -1,10 +1,19 @@
<ng-container *ngIf="orderItem$ | async; let orderItem">
<div class="grid grid-flow-row gap-px-2">
<div class="bg-[#F5F7FA] flex flex-row justify-between items-center p-4 rounded-t">
<div class="grid grid-flow-col gap-[0.4375rem] items-center" *ngIf="fetchingCustomerDone$ | async; else featureLoading">
<ng-container *ngIf="features$ | async; let features">
<shared-icon *ngIf="features?.length > 0" [size]="24" icon="person"></shared-icon>
<div class="grid grid-flow-col gap-2 items-center font-bold text-p2" *ngFor="let feature of features">
<div
class="bg-[#F5F7FA] flex flex-row justify-between items-center p-4 rounded-t"
>
<div
class="grid grid-flow-col gap-[0.4375rem] items-center"
*ngIf="fetchingCustomerDone$ | async; else featureLoading"
>
<ng-container *ngIf="customerFeature$ | async; let feature">
<shared-icon
*ngIf="!!feature"
[size]="24"
icon="person"
></shared-icon>
<div class="grid grid-flow-col gap-2 items-center font-bold text-p2">
{{ feature?.description }}
</div>
</ng-container>
@@ -27,30 +36,69 @@
<shared-skeleton-loader class="w-64 h-6"></shared-skeleton-loader>
</ng-template>
<div class="page-pickup-shelf-details-header__details bg-white px-4 pt-4 pb-5">
<div class="flex flex-row items-center" [class.mb-8]="!orderItem?.features?.paid && !isKulturpass">
<page-pickup-shelf-details-header-nav-menu class="mr-2" [customer]="customer$ | async"></page-pickup-shelf-details-header-nav-menu>
<h2 class="page-pickup-shelf-details-header__details-header items-center">
<div
class="page-pickup-shelf-details-header__details bg-white px-4 pt-4 pb-5"
>
<div
class="flex flex-row items-center"
[class.mb-8]="!orderItem?.features?.paid && !isKulturpass"
>
<page-pickup-shelf-details-header-nav-menu
class="mr-2"
[customer]="customer$ | async"
></page-pickup-shelf-details-header-nav-menu>
<h2
class="page-pickup-shelf-details-header__details-header items-center"
>
<div class="text-h2">
{{ orderItem?.organisation }}
<ng-container *ngIf="!!orderItem?.organisation && (!!orderItem?.firstName || !!orderItem?.lastName)">-</ng-container>
<ng-container
*ngIf="
!!orderItem?.organisation &&
(!!orderItem?.firstName || !!orderItem?.lastName)
"
>-</ng-container
>
{{ orderItem?.lastName }}
{{ orderItem?.firstName }}
</div>
<div class="page-pickup-shelf-details-header__header-compartment text-h3">
{{ orderItem?.compartmentCode }}{{ orderItem?.compartmentInfo && '_' + orderItem?.compartmentInfo }}
<div
class="page-pickup-shelf-details-header__header-compartment text-h3"
>
{{ orderItem?.compartmentCode
}}{{
orderItem?.compartmentInfo && '_' + orderItem?.compartmentInfo
}}
</div>
</h2>
</div>
<div class="page-pickup-shelf-details-header__paid-marker mt-[0.375rem]" *ngIf="orderItem?.features?.paid && !isKulturpass">
<div class="font-bold flex flex-row items-center justify-center text-p2 text-[#26830C]">
<shared-icon class="flex items-center justify-center mr-[0.375rem]" [size]="24" icon="credit-card"></shared-icon>
<div
class="page-pickup-shelf-details-header__paid-marker mt-[0.375rem]"
*ngIf="orderItem?.features?.paid && !isKulturpass"
>
<div
class="font-bold flex flex-row items-center justify-center text-p2 text-[#26830C]"
>
<shared-icon
class="flex items-center justify-center mr-[0.375rem]"
[size]="24"
icon="credit-card"
></shared-icon>
{{ orderItem?.features?.paid }}
</div>
</div>
<div class="page-pickup-shelf-details-header__paid-marker mt-[0.375rem] text-[#26830C]" *ngIf="isKulturpass">
<svg class="fill-current mr-2" xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22">
<div
class="page-pickup-shelf-details-header__paid-marker mt-[0.375rem] text-[#26830C]"
*ngIf="isKulturpass"
>
<svg
class="fill-current mr-2"
xmlns="http://www.w3.org/2000/svg"
height="22"
viewBox="0 -960 960 960"
width="22"
>
<path
d="M880-740v520q0 24-18 42t-42 18H140q-24 0-42-18t-18-42v-520q0-24 18-42t42-18h680q24 0 42 18t18 42ZM140-631h680v-109H140v109Zm0 129v282h680v-282H140Zm0 282v-520 520Z"
/>
@@ -59,21 +107,42 @@
</div>
<div class="page-pickup-shelf-details-header__details-wrapper -mt-3">
<div class="flex flex-row page-pickup-shelf-details-header__buyer-number" data-detail-id="Kundennummer">
<div
class="flex flex-row page-pickup-shelf-details-header__buyer-number"
data-detail-id="Kundennummer"
>
<div class="min-w-[9rem]">Kundennummer</div>
<div class="flex flex-row font-bold">{{ orderItem?.buyerNumber }}</div>
<div class="flex flex-row font-bold">
{{ orderItem?.buyerNumber }}
</div>
</div>
<div class="flex flex-row page-pickup-shelf-details-header__order-number" data-detail-id="VorgangId">
<div
class="flex flex-row page-pickup-shelf-details-header__order-number"
data-detail-id="VorgangId"
>
<div class="min-w-[9rem]">Vorgang-ID</div>
<div class="flex flex-row font-bold">{{ orderItem?.orderNumber }}</div>
<div class="flex flex-row font-bold">
{{ orderItem?.orderNumber }}
</div>
</div>
<div class="flex flex-row page-pickup-shelf-details-header__order-date" data-detail-id="Bestelldatum">
<div
class="flex flex-row page-pickup-shelf-details-header__order-date"
data-detail-id="Bestelldatum"
>
<div class="min-w-[9rem]">Bestelldatum</div>
<div class="flex flex-row font-bold">{{ orderItem?.orderDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
<div class="flex flex-row font-bold">
{{ orderItem?.orderDate | date: 'dd.MM.yy | HH:mm' }} Uhr
</div>
</div>
<div class="flex flex-row page-pickup-shelf-details-header__processing-status justify-between" data-detail-id="Status">
<div
class="flex flex-row page-pickup-shelf-details-header__processing-status justify-between"
data-detail-id="Status"
>
<div class="min-w-[9rem]">Status</div>
<div *ngIf="!(changeStatusLoader$ | async)" class="flex flex-row font-bold -mr-[0.125rem]">
<div
*ngIf="!(changeStatusLoader$ | async)"
class="flex flex-row font-bold -mr-[0.125rem]"
>
<span *ngIf="!(canEditStatus$ | async)">
{{ orderItem?.processingStatus | processingStatus }}
</span>
@@ -97,7 +166,12 @@
icon="arrow-drop-down"
></shared-icon>
</button>
<ui-dropdown #statusDropdown yPosition="below" xPosition="after" [xOffset]="8">
<ui-dropdown
#statusDropdown
yPosition="below"
xPosition="after"
[xOffset]="8"
>
<button
uiDropdownItem
*ngFor="let action of statusActions$ | async"
@@ -111,12 +185,22 @@
</ui-dropdown>
</ng-container>
</div>
<ui-spinner *ngIf="changeStatusLoader$ | async; let loader" class="flex flex-row font-bold loader" [show]="loader"></ui-spinner>
<ui-spinner
*ngIf="changeStatusLoader$ | async; let loader"
class="flex flex-row font-bold loader"
[show]="loader"
></ui-spinner>
</div>
<div class="flex flex-row page-pickup-shelf-details-header__order-source" data-detail-id="Bestellkanal">
<div
class="flex flex-row page-pickup-shelf-details-header__order-source"
data-detail-id="Bestellkanal"
>
<div class="min-w-[9rem]">Bestellkanal</div>
<div class="flex flex-row font-bold">
<shared-skeleton-loader class="w-32" *ngIf="fetchingOrder$ | async; else orderSourceTmpl"></shared-skeleton-loader>
<shared-skeleton-loader
class="w-32"
*ngIf="fetchingOrder$ | async; else orderSourceTmpl"
></shared-skeleton-loader>
<ng-template #orderSourceTmpl>
{{ order()?.features?.orderSource }}
</ng-template>
@@ -142,19 +226,30 @@
<ng-template #changeDate>
<div class="min-w-[9rem]">Geändert</div>
<div class="flex flex-row font-bold">{{ orderItem?.processingStatusDate | date: 'dd.MM.yy | HH:mm' }} Uhr</div>
<div class="flex flex-row font-bold">
{{
orderItem?.processingStatusDate | date: 'dd.MM.yy | HH:mm'
}}
Uhr
</div>
</ng-template>
</div>
<div
class="flex flex-row page-pickup-shelf-details-header__pick-up justify-between"
data-detail-id="Wunschdatum"
*ngIf="orderItem.orderType === 1 && (orderItem.processingStatus === 16 || orderItem.processingStatus === 8192)"
*ngIf="
orderItem.orderType === 1 &&
(orderItem.processingStatus === 16 ||
orderItem.processingStatus === 8192)
"
>
<ng-container *ngTemplateOutlet="preferredPickUpDate"></ng-container>
</div>
<div class="flex flex-col page-pickup-shelf-details-header__dig-and-notification">
<div
class="flex flex-col page-pickup-shelf-details-header__dig-and-notification"
>
<div
*ngIf="orderItem.orderType === 1"
class="flex flex-row page-pickup-shelf-details-header__notification"
@@ -162,9 +257,14 @@
>
<div class="min-w-[9rem]">Benachrichtigung</div>
<div class="flex flex-row font-bold">
<shared-skeleton-loader class="w-32" *ngIf="fetchingOrder$ | async; else notificationsChannelTpl"></shared-skeleton-loader>
<shared-skeleton-loader
class="w-32"
*ngIf="fetchingOrder$ | async; else notificationsChannelTpl"
></shared-skeleton-loader>
<ng-template #notificationsChannelTpl>
{{ (notificationsChannel$ | async | notificationsChannel) || '-' }}
{{
(notificationsChannel$ | async | notificationsChannel) || '-'
}}
</ng-template>
</div>
</div>
@@ -175,11 +275,17 @@
<ng-template #abholfrist>
<div class="min-w-[9rem]">Abholfrist</div>
<div *ngIf="!(orderItemSubsetLoading$ | async); else featureLoading" class="flex flex-row font-bold">
<div
*ngIf="!(orderItemSubsetLoading$ | async); else featureLoading"
class="flex flex-row font-bold"
>
<button
[uiOverlayTrigger]="deadlineDatepicker"
#deadlineDatepickerTrigger="uiOverlayTrigger"
[disabled]="!isKulturpass && (!!orderItem?.features?.paid || (changeDateDisabled$ | async))"
[disabled]="
!isKulturpass &&
(!!orderItem?.features?.paid || (changeDateDisabled$ | async))
"
class="cta-pickup-deadline"
matomoClickCategory="pickup-shelf-details-header"
matomoClickAction="click"
@@ -188,7 +294,11 @@
<strong class="border-r border-[#AEB7C1] pr-4">
{{ orderItem?.pickUpDeadline | date: 'dd.MM.yy' }}
</strong>
<shared-icon class="text-[#596470] ml-4" [size]="24" icon="isa-calendar"></shared-icon>
<shared-icon
class="text-[#596470] ml-4"
[size]="24"
icon="isa-calendar"
></shared-icon>
</button>
<ui-datepicker
#deadlineDatepicker
@@ -205,23 +315,40 @@
<ng-template #preferredPickUpDate>
<div class="min-w-[9rem]">Zurücklegen bis</div>
<div *ngIf="!(orderItemSubsetLoading$ | async); else featureLoading" class="flex flex-row font-bold">
<div
*ngIf="!(orderItemSubsetLoading$ | async); else featureLoading"
class="flex flex-row font-bold"
>
<button
[uiOverlayTrigger]="preferredPickUpDatePicker"
#preferredPickUpDatePickerTrigger="uiOverlayTrigger"
[disabled]="(!isKulturpass && !!orderItem?.features?.paid) || (changeDateDisabled$ | async)"
[disabled]="
(!isKulturpass && !!orderItem?.features?.paid) ||
(changeDateDisabled$ | async)
"
class="cta-pickup-preferred"
matomoClickCategory="pickup-shelf-details-header"
matomoClickAction="click"
matomoClickName="pickup-preferred"
>
<strong class="border-r border-[#AEB7C1] pr-4" *ngIf="findLatestPreferredPickUpDate$ | async; let pickUpDate; else: selectTemplate">
<strong
class="border-r border-[#AEB7C1] pr-4"
*ngIf="
findLatestPreferredPickUpDate$ | async;
let pickUpDate;
else: selectTemplate
"
>
{{ pickUpDate | date: 'dd.MM.yy' }}
</strong>
<ng-template #selectTemplate>
<strong class="border-r border-[#AEB7C1] pr-4">Auswählen</strong>
</ng-template>
<shared-icon class="text-[#596470] ml-4" [size]="24" icon="isa-calendar"></shared-icon>
<shared-icon
class="text-[#596470] ml-4"
[size]="24"
icon="isa-calendar"
></shared-icon>
</button>
<ui-datepicker
#preferredPickUpDatePicker
@@ -238,7 +365,10 @@
<ng-template #vslLieferdatum>
<div class="min-w-[9rem]">vsl. Lieferdatum</div>
<div *ngIf="!(orderItemSubsetLoading$ | async); else featureLoading" class="flex flex-row font-bold">
<div
*ngIf="!(orderItemSubsetLoading$ | async); else featureLoading"
class="flex flex-row font-bold"
>
<button
class="cta-datepicker"
[disabled]="changeDateDisabled$ | async"
@@ -251,7 +381,11 @@
<span class="border-r border-[#AEB7C1] pr-4">
{{ orderItem?.estimatedShippingDate | date: 'dd.MM.yy' }}
</span>
<shared-icon class="text-[#596470] ml-4" [size]="24" icon="isa-calendar"></shared-icon>
<shared-icon
class="text-[#596470] ml-4"
[size]="24"
icon="isa-calendar"
></shared-icon>
</button>
<ui-datepicker
#uiDatepicker

View File

@@ -1,4 +1,12 @@
import { AsyncPipe, DatePipe, NgFor, NgIf, NgSwitch, NgSwitchCase, NgTemplateOutlet } from '@angular/common';
import {
AsyncPipe,
DatePipe,
NgFor,
NgIf,
NgSwitch,
NgSwitchCase,
NgTemplateOutlet,
} from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -28,6 +36,7 @@ import { PickUpShelfDetailsHeaderNavMenuComponent } from '../pickup-shelf-detail
import { SkeletonLoaderComponent } from '@shared/components/loader';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatomoModule } from 'ngx-matomo-client';
import { getEnabledCustomerFeature } from '@isa/crm/data-access';
@Component({
selector: 'page-pickup-shelf-details-header',
@@ -65,7 +74,10 @@ export class PickUpShelfDetailsHeaderComponent {
handleAction = new EventEmitter<KeyValueDTOOfStringAndString>();
@Output()
updateDate = new EventEmitter<{ date: Date; type?: 'delivery' | 'pickup' | 'preferred' }>();
updateDate = new EventEmitter<{
date: Date;
type?: 'delivery' | 'pickup' | 'preferred';
}>();
orderItemSubsetLoading$ = this._store.orderItemSubsetLoading$;
@@ -83,12 +95,19 @@ export class PickUpShelfDetailsHeaderComponent {
?.reduce((acc, item) => {
return [...acc, ...item.data.subsetItems];
}, [])
?.filter((a) => !!a.data.preferredPickUpDate && selectedOrderItemIds.find((id) => id === a.data.id));
?.filter(
(a) =>
!!a.data.preferredPickUpDate &&
selectedOrderItemIds.find((id) => id === a.data.id),
);
if (subsetItems?.length > 0) {
latestDate = new Date(
subsetItems?.reduce((a, b) => {
return new Date(a.data.preferredPickUpDate) > new Date(b.data.preferredPickUpDate) ? a : b;
return new Date(a.data.preferredPickUpDate) >
new Date(b.data.preferredPickUpDate)
? a
: b;
})?.data?.preferredPickUpDate,
);
}
@@ -108,14 +127,24 @@ export class PickUpShelfDetailsHeaderComponent {
return this.order()?.features?.orderSource === 'KulturPass';
}
minDateDatepicker = this.dateAdapter.addCalendarDays(this.dateAdapter.today(), -1);
minDateDatepicker = this.dateAdapter.addCalendarDays(
this.dateAdapter.today(),
-1,
);
today = this.dateAdapter.today();
// Daten die im Header Angezeigt werden sollen
orderItem$ = combineLatest([
this._store.selectedOrderItem$, // Wenn man im Abholfach ist muss das ausgewählte OrderItem genommen werden
this._store.selectedOrderItems$.pipe(map((orderItems) => orderItems?.find((_) => true))), // Wenn man in der Warenausgabe ist muss man das erste OrderItem nehmen
]).pipe(map(([selectedOrderItem, selectedOrderItems]) => selectedOrderItem || selectedOrderItems));
this._store.selectedOrderItems$.pipe(
map((orderItems) => orderItems?.find((_) => true)),
), // Wenn man in der Warenausgabe ist muss man das erste OrderItem nehmen
]).pipe(
map(
([selectedOrderItem, selectedOrderItems]) =>
selectedOrderItem || selectedOrderItems,
),
);
changeDateLoader$ = new BehaviorSubject<boolean>(false);
changePreferredDateLoader$ = new BehaviorSubject<boolean>(false);
@@ -124,28 +153,41 @@ export class PickUpShelfDetailsHeaderComponent {
changeDateDisabled$ = this.changeStatusDisabled$;
fetchingCustomerDone$ = this._store.fetchingCustomer$.pipe(map((fetchingCustomer) => !fetchingCustomer));
fetchingCustomerDone$ = this._store.fetchingCustomer$.pipe(
map((fetchingCustomer) => !fetchingCustomer),
);
customer$ = this._store.customer$;
features$ = this.customer$.pipe(
map((customer) => customer?.features || []),
map((features) => features.filter((f) => f.enabled && !!f.description)),
customerFeature$ = this.customer$.pipe(
map((customer) => getEnabledCustomerFeature(customer?.features)),
shareReplay(),
);
statusActions$ = this.orderItem$.pipe(
map((orderItem) => orderItem?.actions?.filter((action) => action.enabled === false)),
map((orderItem) =>
orderItem?.actions?.filter((action) => action.enabled === false),
),
);
crudaUpdate$ = this.orderItem$.pipe(map((orederItem) => !!(orederItem?.cruda & 4)));
crudaUpdate$ = this.orderItem$.pipe(
map((orederItem) => !!(orederItem?.cruda & 4)),
);
editButtonDisabled$ = combineLatest([this.changeStatusLoader$, this.crudaUpdate$]).pipe(
map(([changeStatusLoader, crudaUpdate]) => changeStatusLoader || !crudaUpdate),
editButtonDisabled$ = combineLatest([
this.changeStatusLoader$,
this.crudaUpdate$,
]).pipe(
map(
([changeStatusLoader, crudaUpdate]) => changeStatusLoader || !crudaUpdate,
),
);
canEditStatus$ = combineLatest([this.statusActions$, this.crudaUpdate$]).pipe(
map(([statusActions, crudaUpdate]) => statusActions?.length > 0 && crudaUpdate),
map(
([statusActions, crudaUpdate]) =>
statusActions?.length > 0 && crudaUpdate,
),
);
constructor(

View File

@@ -40,6 +40,11 @@
[src]="orderItem.product?.ean | productImage"
[alt]="orderItem.product?.name"
/>
@if (hasRewardPoints$ | async) {
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
Prämie
</ui-label>
}
</div>
<div class="page-pickup-shelf-details-item__details">
<div class="flex flex-row justify-between items-start mb-[1.3125rem]">
@@ -117,10 +122,15 @@
<div class="value">{{ orderItem.product?.ean }}</div>
</div>
}
@if (orderItem.price !== undefined) {
@if (orderItem.price !== undefined || (hasRewardPoints$ | async)) {
<div class="detail">
<div class="label">Preis</div>
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
@if (hasRewardPoints$ | async) {
<div class="label">Prämie</div>
<div class="value">{{ rewardPoints$ | async | number: '1.0-0' }} Lesepunkte</div>
} @else {
<div class="label">Preis</div>
<div class="value">{{ orderItem.price | currency: 'EUR' }}</div>
}
</div>
}
@if (!!orderItem.retailPrice?.vat?.inPercent) {

View File

@@ -21,6 +21,8 @@ button {
}
.page-pickup-shelf-details-item__thumbnail {
@apply flex flex-col items-center gap-2;
img {
@apply rounded shadow-cta w-[3.625rem] max-h-[5.9375rem];
}

View File

@@ -1,20 +1,22 @@
import { CdkTextareaAutosize, TextFieldModule } from '@angular/cdk/text-field';
import { AsyncPipe, CurrencyPipe, DatePipe } from '@angular/common';
import { AsyncPipe, CurrencyPipe, DatePipe, DecimalPipe } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
inject, OnDestroy,
inject,
OnDestroy,
} from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { NavigateOnClickDirective, ProductImageModule } from '@cdn/product-image';
import { DBHOrderItemListItemDTO, OrderDTO, ReceiptDTO } from '@generated/swagger/oms-api';
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
import { UiCommonModule } from '@ui/common';
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
import { UiTooltipModule } from '@ui/tooltip';
import { PickupShelfPaymentTypePipe } from '../pipes/payment-type.pipe';
import { IconModule } from '@shared/components/icon';
@@ -48,6 +50,7 @@ export interface PickUpShelfDetailsItemComponentState {
ReactiveFormsModule,
CurrencyPipe,
DatePipe,
DecimalPipe,
AsyncPipe,
ProductImageModule,
TextFieldModule,
@@ -56,12 +59,13 @@ export interface PickUpShelfDetailsItemComponentState {
UiQuantityDropdownModule,
NotificationTypePipe,
NavigateOnClickDirective,
MatomoModule
MatomoModule,
LabelComponent
],
})
export class PickUpShelfDetailsItemComponent
extends ComponentStore<PickUpShelfDetailsItemComponentState>
implements OnInit, OnDestroy
implements OnDestroy
{
private _store = inject(PickupShelfDetailsStore);
@@ -117,6 +121,22 @@ export class PickUpShelfDetailsItemComponent
hasSmsNotification$ = this.smsNotificationDates$.pipe(map((dates) => dates?.length > 0));
/**
* Observable that indicates whether the order item has reward points (Lesepunkte).
* Returns true if the item has a 'praemie' feature.
*/
hasRewardPoints$ = this.orderItem$.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem) !== undefined),
);
/**
* Observable that emits the reward points (Lesepunkte) value for the order item.
* Returns the parsed numeric value from the 'praemie' feature, or undefined if not present.
*/
rewardPoints$ = this.orderItem$.pipe(
map((orderItem) => getOrderItemRewardFeature(orderItem)),
);
canChangeQuantity$ = combineLatest([this.orderItem$, this._store.fetchPartial$]).pipe(
map(([item, partialPickup]) => ([16, 8192].includes(item?.processingStatus) || partialPickup) && item.quantity > 1),
);
@@ -167,12 +187,12 @@ export class PickUpShelfDetailsItemComponent
return this._store.receipts;
}
readonly receipts$ = this._store.receipts$;
set receipts(receipts: ReceiptDTO[]) {
this._store.updateReceipts(receipts);
}
readonly receipts$ = this._store.receipts$;
readonly receiptCount$ = this.receipts$.pipe(map((receipts) => receipts?.length));
specialCommentControl = new UntypedFormControl();
@@ -181,7 +201,11 @@ export class PickUpShelfDetailsItemComponent
private _onDestroy$ = new Subject<void>();
expanded: boolean = false;
expanded = false;
// Expose to template
Labeltype = Labeltype;
LabelPriority = LabelPriority;
constructor(private _cdr: ChangeDetectorRef) {
super({
@@ -189,8 +213,6 @@ export class PickUpShelfDetailsItemComponent
});
}
ngOnInit() {}
ngOnDestroy() {
// Remove Prev OrderItem from selected list
this._store.selectOrderItem(this.orderItem, false);

View File

@@ -39,6 +39,7 @@
.page-pickup-shelf-list-item__item-thumbnail {
grid-area: thumbnail;
@apply flex flex-col items-center gap-2;
}
.page-pickup-shelf-list-item__item-image {

View File

@@ -10,7 +10,7 @@
[class.page-pickup-shelf-list-item__item-grid-container-main]="primaryOutletActive"
[class.page-pickup-shelf-list-item__item-grid-container-secondary]="primaryOutletActive && isItemSelectable === undefined"
>
<div class="page-pickup-shelf-list-item__item-thumbnail text-center w-[3.125rem] h-[4.9375rem]">
<div class="page-pickup-shelf-list-item__item-thumbnail text-center">
@if (item?.product?.ean | productImage; as productImage) {
<img
class="page-pickup-shelf-list-item__item-image w-[3.125rem] max-h-[4.9375rem]"
@@ -20,6 +20,11 @@
[alt]="item?.product?.name"
/>
}
@if (hasRewardPoints) {
<ui-label [type]="Labeltype.Tag" [priority]="LabelPriority.High">
Prämie
</ui-label>
}
</div>
<div

View File

@@ -5,6 +5,8 @@ import { NavigateOnClickDirective, ProductImageModule } from '@cdn/product-image
import { EnvironmentService } from '@core/environment';
import { IconModule } from '@shared/components/icon';
import { DBHOrderItemListItemDTO } from '@generated/swagger/oms-api';
import { getOrderItemRewardFeature } from '@isa/oms/data-access';
import { LabelComponent, Labeltype, LabelPriority } from '@isa/ui/label';
import { UiCommonModule } from '@ui/common';
import { PickupShelfProcessingStatusPipe } from '../pipes/processing-status.pipe';
import { FormsModule } from '@angular/forms';
@@ -29,7 +31,8 @@ import { MatomoModule } from 'ngx-matomo-client';
UiCommonModule,
PickupShelfProcessingStatusPipe,
NavigateOnClickDirective,
MatomoModule
MatomoModule,
LabelComponent
],
providers: [PickupShelfProcessingStatusPipe],
})
@@ -77,12 +80,24 @@ export class PickUpShelfListItemComponent {
return { 'background-color': this._processingStatusPipe.transform(this.item?.processingStatus, true) };
}
/**
* Indicates whether the order item has reward points (Lesepunkte).
* Returns true if the item has a 'praemie' feature.
*/
get hasRewardPoints() {
return getOrderItemRewardFeature(this.item) !== undefined;
}
selected$ = this.store.selectedListItems$.pipe(
map((selectedListItems) =>
selectedListItems?.find((item) => item?.orderItemSubsetId === this.item?.orderItemSubsetId),
),
);
// Expose to template
Labeltype = Labeltype;
LabelPriority = LabelPriority;
constructor(
private _elRef: ElementRef,
private _environment: EnvironmentService,

View File

@@ -1,275 +1,328 @@
import { Injectable } from '@angular/core';
import { AuthService } from '@core/auth';
import { DomainAvailabilityService } from '@domain/availability';
import { OpenStreetMap, OpenStreetMapParams, PlaceDto } from '@external/openstreetmap';
import { ComponentStore } from '@ngrx/component-store';
import { tapResponse } from '@ngrx/operators';
import { BranchDTO, BranchType } from '@generated/swagger/checkout-api';
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
import { geoDistance, GeoLocation } from '@utils/common';
import { debounceTime, switchMap, tap, withLatestFrom } from 'rxjs/operators';
export interface BranchSelectorState {
query: string;
fetching: boolean;
branches: BranchDTO[];
filteredBranches: BranchDTO[];
selectedBranch?: BranchDTO;
online?: boolean;
orderingEnabled?: boolean;
shippingEnabled?: boolean;
filterCurrentBranch?: boolean;
currentBranchNumber?: string;
orderBy?: 'name' | 'distance';
branchType?: number;
}
function branchSorterFn(a: BranchDTO, b: BranchDTO, userBranch: BranchDTO) {
return (
geoDistance(userBranch?.address?.geoLocation, a?.address?.geoLocation) -
geoDistance(userBranch?.address?.geoLocation, b?.address?.geoLocation)
);
}
function selectBranches(state: BranchSelectorState) {
if (!state?.branches) {
return [];
}
let branches = state.branches;
if (typeof state.online === 'boolean') {
branches = branches.filter((branch) => !!branch?.isOnline === state.online);
}
if (typeof state.orderingEnabled === 'boolean') {
branches = branches.filter((branch) => !!branch?.isOrderingEnabled === state.orderingEnabled);
}
if (typeof state.shippingEnabled === 'boolean') {
branches = branches.filter((branch) => !!branch?.isShippingEnabled === state.shippingEnabled);
}
if (typeof state.filterCurrentBranch === 'boolean' && typeof state.currentBranchNumber === 'string') {
branches = branches.filter((branch) => branch?.branchNumber !== state.currentBranchNumber);
}
if (typeof state.orderBy === 'string' && typeof state.currentBranchNumber === 'string') {
switch (state.orderBy) {
case 'name':
branches?.sort((branchA, branchB) => branchA?.name?.localeCompare(branchB?.name));
break;
case 'distance':
const currentBranch = state.branches?.find((b) => b?.branchNumber === state.currentBranchNumber);
branches?.sort((a: BranchDTO, b: BranchDTO) => branchSorterFn(a, b, currentBranch));
break;
}
}
if (typeof state.branchType === 'number') {
branches = branches.filter((branch) => branch?.branchType === state.branchType);
}
return branches;
}
@Injectable()
export class BranchSelectorStore extends ComponentStore<BranchSelectorState> {
get query() {
return this.get((s) => s.query);
}
readonly query$ = this.select((s) => s.query);
get fetching() {
return this.get((s) => s.fetching);
}
readonly fetching$ = this.select((s) => s.fetching);
get branches() {
return this.get(selectBranches);
}
readonly branches$ = this.select(selectBranches);
get filteredBranches() {
return this.get((s) => s.filteredBranches);
}
readonly filteredBranches$ = this.select((s) => s.filteredBranches);
get selectedBranch() {
return this.get((s) => s.selectedBranch);
}
readonly selectedBranch$ = this.select((s) => s.selectedBranch);
constructor(
private _availabilityService: DomainAvailabilityService,
private _uiModal: UiModalService,
private _openStreetMap: OpenStreetMap,
auth: AuthService,
) {
super({
query: '',
fetching: false,
filteredBranches: [],
branches: [],
online: true,
orderingEnabled: true,
shippingEnabled: true,
filterCurrentBranch: undefined,
currentBranchNumber: auth.getClaimByKey('branch_no'),
orderBy: 'name',
branchType: undefined,
});
}
loadBranches = this.effect(($) =>
$.pipe(
tap((_) => this.setFetching(true)),
switchMap(() =>
this._availabilityService.getBranches().pipe(
withLatestFrom(this.selectedBranch$),
tapResponse(
([response, selectedBranch]) => this.loadBranchesResponseFn({ response, selectedBranch }),
(error: Error) => this.loadBranchesErrorFn(error),
),
),
),
),
);
perimeterSearch = this.effect(($) =>
$.pipe(
tap((_) => this.beforePerimeterSearch()),
debounceTime(500),
switchMap(() => {
const queryToken = {
country: 'Germany',
postalcode: this.query,
limit: 1,
} as OpenStreetMapParams.Query;
return this._openStreetMap.query(queryToken).pipe(
withLatestFrom(this.branches$),
tapResponse(
([response, branches]) => this.perimeterSearchResponseFn({ response, branches }),
(error: Error) => this.perimeterSearchErrorFn(error),
),
);
}),
),
);
beforePerimeterSearch = () => {
this.setFilteredBranches([]);
this.setFetching(true);
};
perimeterSearchResponseFn = ({ response, branches }: { response: PlaceDto[]; branches: BranchDTO[] }) => {
const place = response?.find((_) => true);
const branch = this._findNearestBranchByPlace({ place, branches });
const filteredBranches = [...branches]
?.sort((a: BranchDTO, b: BranchDTO) => branchSorterFn(a, b, branch))
?.slice(0, 10);
this.setFilteredBranches(filteredBranches ?? []);
};
perimeterSearchErrorFn = (error: Error) => {
this.setFilteredBranches([]);
console.error('OpenStreetMap Request Failed! ', error);
};
loadBranchesResponseFn = ({ response, selectedBranch }: { response: BranchDTO[]; selectedBranch?: BranchDTO }) => {
this.setBranches(response ?? []);
if (selectedBranch) {
this.setSelectedBranch(selectedBranch);
}
this.setFetching(false);
};
loadBranchesErrorFn = (error: Error) => {
this.setBranches([]);
this._uiModal.open({
title: 'Fehler beim Laden der Filialen',
content: UiErrorModalComponent,
data: error,
config: { showScrollbarY: false },
});
};
setBranches(branches: BranchDTO[]) {
this.patchState({ branches });
}
setFilteredBranches(filteredBranches: BranchDTO[]) {
this.patchState({ filteredBranches });
}
setSelectedBranch(selectedBranch?: BranchDTO) {
if (selectedBranch) {
this.patchState({
selectedBranch,
query: this.formatBranch(selectedBranch),
});
} else {
this.patchState({
selectedBranch,
query: '',
});
}
}
setQuery(query: string) {
this.patchState({ query });
}
setFetching(fetching: boolean) {
this.patchState({ fetching });
}
formatBranch(branch?: BranchDTO) {
return branch ? (branch.key ? branch.key + ' - ' + branch.name : branch.name) : '';
}
private _findNearestBranchByPlace({ place, branches }: { place: PlaceDto; branches: BranchDTO[] }): BranchDTO {
const placeGeoLocation = { longitude: Number(place?.lon), latitude: Number(place?.lat) } as GeoLocation;
return (
branches?.reduce((a, b) =>
geoDistance(placeGeoLocation, a.address.geoLocation) > geoDistance(placeGeoLocation, b.address.geoLocation)
? b
: a,
) ?? {}
);
}
getBranchById(id: number): BranchDTO {
return this.branches.find((branch) => branch.id === id);
}
setOnline(online: boolean) {
this.patchState({ online });
}
setOrderingEnabled(orderingEnabled: boolean) {
this.patchState({ orderingEnabled });
}
setShippingEnabled(shippingEnabled: boolean) {
this.patchState({ shippingEnabled });
}
setFilterCurrentBranch(filterCurrentBranch: boolean) {
this.patchState({ filterCurrentBranch });
}
setOrderBy(orderBy: 'name' | 'distance') {
this.patchState({ orderBy });
}
setBranchType(branchType: BranchType) {
this.patchState({ branchType });
}
}
import { Injectable } from '@angular/core';
import { AuthService } from '@core/auth';
import { DomainAvailabilityService } from '@domain/availability';
import {
OpenStreetMap,
OpenStreetMapParams,
PlaceDto,
} from '@external/openstreetmap';
import { ComponentStore } from '@ngrx/component-store';
import { tapResponse } from '@ngrx/operators';
import { BranchDTO, BranchType } from '@generated/swagger/checkout-api';
import { UiErrorModalComponent, UiModalService } from '@ui/modal';
import { geoDistance, GeoLocation } from '@utils/common';
import { debounceTime, switchMap, tap, withLatestFrom } from 'rxjs/operators';
export interface BranchSelectorState {
query: string;
fetching: boolean;
branches: BranchDTO[];
filteredBranches: BranchDTO[];
selectedBranch?: BranchDTO;
online?: boolean;
orderingEnabled?: boolean;
shippingEnabled?: boolean;
filterCurrentBranch?: boolean;
currentBranchNumber?: string;
orderBy?: 'name' | 'distance';
branchType?: number;
}
function branchSorterFn(a: BranchDTO, b: BranchDTO, userBranch: BranchDTO) {
return (
geoDistance(userBranch?.address?.geoLocation, a?.address?.geoLocation) -
geoDistance(userBranch?.address?.geoLocation, b?.address?.geoLocation)
);
}
function selectBranches(state: BranchSelectorState) {
if (!state?.branches) {
return [];
}
let branches = state.branches;
if (typeof state.online === 'boolean') {
branches = branches.filter((branch) => !!branch?.isOnline === state.online);
}
if (typeof state.orderingEnabled === 'boolean') {
branches = branches.filter(
(branch) => !!branch?.isOrderingEnabled === state.orderingEnabled,
);
}
if (typeof state.shippingEnabled === 'boolean') {
branches = branches.filter(
(branch) => !!branch?.isShippingEnabled === state.shippingEnabled,
);
}
if (
typeof state.filterCurrentBranch === 'boolean' &&
typeof state.currentBranchNumber === 'string'
) {
branches = branches.filter(
(branch) => branch?.branchNumber !== state.currentBranchNumber,
);
}
if (
typeof state.orderBy === 'string' &&
typeof state.currentBranchNumber === 'string'
) {
switch (state.orderBy) {
case 'name':
branches?.sort((branchA, branchB) =>
branchA?.name?.localeCompare(branchB?.name),
);
break;
case 'distance': {
const currentBranch = state.branches?.find(
(b) => b?.branchNumber === state.currentBranchNumber,
);
branches?.sort((a: BranchDTO, b: BranchDTO) =>
branchSorterFn(a, b, currentBranch),
);
break;
}
}
}
if (typeof state.branchType === 'number') {
branches = branches.filter(
(branch) => branch?.branchType === state.branchType,
);
}
return branches;
}
@Injectable()
export class BranchSelectorStore extends ComponentStore<BranchSelectorState> {
get query() {
return this.get((s) => s.query);
}
readonly query$ = this.select((s) => s.query);
get fetching() {
return this.get((s) => s.fetching);
}
readonly fetching$ = this.select((s) => s.fetching);
get branches() {
return this.get(selectBranches);
}
readonly branches$ = this.select(selectBranches);
get filteredBranches() {
return this.get((s) => s.filteredBranches);
}
readonly filteredBranches$ = this.select((s) => s.filteredBranches);
get selectedBranch() {
return this.get((s) => s.selectedBranch);
}
readonly selectedBranch$ = this.select((s) => s.selectedBranch);
constructor(
private _availabilityService: DomainAvailabilityService,
private _uiModal: UiModalService,
private _openStreetMap: OpenStreetMap,
auth: AuthService,
) {
super({
query: '',
fetching: false,
filteredBranches: [],
branches: [],
online: true,
orderingEnabled: true,
shippingEnabled: true,
filterCurrentBranch: undefined,
currentBranchNumber: auth.getClaimByKey('branch_no'),
orderBy: 'name',
branchType: undefined,
});
}
loadBranches = this.effect(($) =>
$.pipe(
tap(() => this.setFetching(true)),
switchMap(() =>
this._availabilityService.getBranches().pipe(
withLatestFrom(this.selectedBranch$),
tapResponse(
([response, selectedBranch]) =>
this.loadBranchesResponseFn({ response, selectedBranch }),
(error: Error) => this.loadBranchesErrorFn(error),
),
),
),
),
);
perimeterSearch = this.effect(($) =>
$.pipe(
tap(() => this.beforePerimeterSearch()),
debounceTime(500),
switchMap(() => {
const queryToken = {
country: 'Germany',
zipCode: this.query,
limit: 1,
} as OpenStreetMapParams.Query;
return this._openStreetMap.query(queryToken).pipe(
withLatestFrom(this.branches$),
tapResponse(
([response, branches]) =>
this.perimeterSearchResponseFn({ response, branches }),
(error: Error) => this.perimeterSearchErrorFn(error),
),
);
}),
),
);
beforePerimeterSearch = () => {
this.setFilteredBranches([]);
this.setFetching(true);
};
perimeterSearchResponseFn = ({
response,
branches,
}: {
response: PlaceDto[];
branches: BranchDTO[];
}) => {
const place = response?.[0];
const branch = this._findNearestBranchByPlace({ place, branches });
const filteredBranches = [...branches]
?.sort((a: BranchDTO, b: BranchDTO) => branchSorterFn(a, b, branch))
?.slice(0, 10);
this.setFilteredBranches(filteredBranches ?? []);
};
perimeterSearchErrorFn = (error: Error) => {
this.setFilteredBranches([]);
console.error('OpenStreetMap Request Failed! ', error);
};
loadBranchesResponseFn = ({
response,
selectedBranch,
}: {
response: BranchDTO[];
selectedBranch?: BranchDTO;
}) => {
this.setBranches(response ?? []);
if (selectedBranch) {
this.setSelectedBranch(selectedBranch);
}
this.setFetching(false);
};
loadBranchesErrorFn = (error: Error) => {
this.setBranches([]);
this._uiModal.open({
title: 'Fehler beim Laden der Filialen',
content: UiErrorModalComponent,
data: error,
config: { showScrollbarY: false },
});
};
setBranches(branches: BranchDTO[]) {
this.patchState({ branches });
}
setFilteredBranches(filteredBranches: BranchDTO[]) {
this.patchState({ filteredBranches });
}
setSelectedBranch(selectedBranch?: BranchDTO) {
if (selectedBranch) {
this.patchState({
selectedBranch,
query: this.formatBranch(selectedBranch),
});
} else {
this.patchState({
selectedBranch,
query: '',
});
}
}
setQuery(query: string) {
this.patchState({ query });
}
setFetching(fetching: boolean) {
this.patchState({ fetching });
}
formatBranch(branch?: BranchDTO) {
return branch
? branch.key
? branch.key + ' - ' + branch.name
: branch.name
: '';
}
private _findNearestBranchByPlace({
place,
branches,
}: {
place: PlaceDto;
branches: BranchDTO[];
}): BranchDTO {
const placeGeoLocation = {
longitude: Number(place?.lon),
latitude: Number(place?.lat),
} as GeoLocation;
return (
branches?.reduce((a, b) =>
geoDistance(placeGeoLocation, a.address.geoLocation) >
geoDistance(placeGeoLocation, b.address.geoLocation)
? b
: a,
) ?? {}
);
}
getBranchById(id: number): BranchDTO {
return this.branches.find((branch) => branch.id === id);
}
setOnline(online: boolean) {
this.patchState({ online });
}
setOrderingEnabled(orderingEnabled: boolean) {
this.patchState({ orderingEnabled });
}
setShippingEnabled(shippingEnabled: boolean) {
this.patchState({ shippingEnabled });
}
setFilterCurrentBranch(filterCurrentBranch: boolean) {
this.patchState({ filterCurrentBranch });
}
setOrderBy(orderBy: 'name' | 'distance') {
this.patchState({ orderBy });
}
setBranchType(branchType: BranchType) {
this.patchState({ branchType });
}
}

View File

@@ -1,73 +1,84 @@
<div class="searchbox-input-wrapper">
<div class="searchbox-hint-wrapper">
<input
id="searchbox"
class="searchbox-input"
autocomplete="off"
#input
type="text"
[placeholder]="placeholder"
[(ngModel)]="query"
(ngModelChange)="setQuery($event, true, true)"
(focus)="clearHint(); focused.emit(true)"
(blur)="focused.emit(false)"
(keyup)="onKeyup($event)"
(keyup.enter)="
tracker.trackEvent({ action: 'keyup enter', name: 'search' })
"
matomoTracker
#tracker="matomo"
matomoCategory="searchbox"
/>
@if (showHint) {
<div class="searchbox-hint" (click)="focus()">
{{ hint }}
</div>
}
</div>
@if (input.value) {
<button
(click)="clear(); focus()"
tabindex="-1"
class="searchbox-clear-btn"
type="button"
>
<shared-icon icon="close" [size]="32"></shared-icon>
</button>
}
@if (!loading) {
@if (!showScannerButton) {
<button
tabindex="0"
class="searchbox-search-btn"
type="button"
(click)="emitSearch()"
[disabled]="completeValue !== query"
matomoClickAction="click"
matomoClickCategory="searchbox"
matomoClickName="search"
>
<ui-icon icon="search" size="1.5rem"></ui-icon>
</button>
}
@if (showScannerButton) {
<button
tabindex="0"
class="searchbox-scan-btn"
type="button"
(click)="startScan()"
matomoClickAction="open"
matomoClickCategory="searchbox"
matomoClickName="scanner"
>
<shared-icon icon="barcode-scan" [size]="32"></shared-icon>
</button>
}
}
@if (loading) {
<div class="searchbox-load-indicator">
<ui-icon icon="spinner" size="32px"></ui-icon>
</div>
}
</div>
<ng-content select="ui-autocomplete"></ng-content>
<div class="searchbox-input-wrapper" role="search">
<div class="searchbox-hint-wrapper">
<input
id="searchbox"
class="searchbox-input"
autocomplete="off"
#input
type="text"
[placeholder]="placeholder"
[(ngModel)]="query"
(ngModelChange)="setQuery($event, true, true)"
(focus)="clearHint(); focused.emit(true)"
(blur)="focused.emit(false)"
(keyup)="onKeyup($event)"
(keyup.enter)="
tracker.trackEvent({ action: 'keyup enter', name: 'search' })
"
matomoTracker
#tracker="matomo"
matomoCategory="searchbox"
aria-label="Search input"
aria-autocomplete="list"
[attr.aria-expanded]="autocomplete?.opend || null"
[attr.aria-controls]="autocomplete?.opend ? 'searchbox-autocomplete' : null"
[attr.aria-activedescendant]="autocomplete?.activeItem ? 'searchbox-item-' + autocomplete?.listKeyManager?.activeItemIndex : null"
[attr.aria-busy]="loading || null"
[attr.aria-describedby]="showHint ? 'searchbox-hint' : null"
/>
@if (showHint) {
<div id="searchbox-hint" class="searchbox-hint" (click)="focus()" aria-hidden="true">
{{ hint }}
</div>
}
</div>
@if (input.value) {
<button
(click)="clear(); focus()"
tabindex="-1"
class="searchbox-clear-btn"
type="button"
aria-label="Clear"
>
<shared-icon icon="close" [size]="32" aria-hidden="true"></shared-icon>
</button>
}
@if (!loading) {
@if (!showScannerButton) {
<button
tabindex="0"
class="searchbox-search-btn"
type="button"
(click)="emitSearch()"
[disabled]="completeValue !== query"
[attr.aria-disabled]="completeValue !== query || null"
matomoClickAction="click"
matomoClickCategory="searchbox"
matomoClickName="search"
aria-label="Search"
>
<ui-icon icon="search" size="1.5rem" aria-hidden="true"></ui-icon>
</button>
}
@if (showScannerButton) {
<button
tabindex="0"
class="searchbox-scan-btn"
type="button"
(click)="startScan()"
matomoClickAction="open"
matomoClickCategory="searchbox"
matomoClickName="scanner"
aria-label="Scan barcode"
>
<shared-icon icon="barcode-scan" [size]="32" aria-hidden="true"></shared-icon>
</button>
}
}
@if (loading) {
<div class="searchbox-load-indicator" role="status" aria-live="polite" aria-label="Loading search results">
<ui-icon icon="spinner" size="32px" aria-hidden="true"></ui-icon>
</div>
}
</div>
<ng-content select="ui-autocomplete"></ng-content>

View File

@@ -6,7 +6,7 @@ import { NavigationRoute } from './defs/navigation-route';
import {
encodeFormData,
mapCustomerInfoDtoToCustomerCreateFormData,
} from 'apps/isa-app/src/page/customer';
} from '@page/customer';
@Injectable({ providedIn: 'root' })
export class CustomerCreateNavigation {
@@ -58,7 +58,7 @@ export class CustomerCreateNavigation {
},
];
let formData = params?.customerInfo
const formData = params?.customerInfo
? encodeFormData(
mapCustomerInfoDtoToCustomerCreateFormData(params.customerInfo),
)

View File

@@ -12,7 +12,7 @@
<span class="truncate">
{{ p.name }}
</span>
@if (p.type === 'cart') {
@if (showCart() && p.type === 'cart') {
<button
type="button"
class="rounded-full px-3 h-[2.375rem] font-bold text-p1 flex flex-row items-center justify-between shopping-cart-count ml-4"
@@ -21,9 +21,7 @@
(click)="$event?.preventDefault(); $event?.stopPropagation()"
>
<shared-icon icon="shopping-cart-bold" [size]="22"></shared-icon>
<span class="shopping-cart-count-label ml-2">{{
cartItemCount$ | async
}}</span>
<span class="shopping-cart-count-label ml-2">{{ itemCount() }}</span>
</button>
}
</a>

View File

@@ -12,6 +12,7 @@ import {
computed,
input,
effect,
untracked,
} from '@angular/core';
import { Router } from '@angular/router';
import { ApplicationProcess, ApplicationService } from '@core/application';
@@ -25,8 +26,12 @@ 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,
ShoppingCartResource,
} from '@isa/checkout/data-access';
@Component({
selector: 'shell-process-bar-item',
@@ -34,14 +39,33 @@ import { TabService } from '@isa/core/tabs';
styleUrls: ['process-bar-item.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false,
providers: [ShoppingCartResource],
})
export class ShellProcessBarItemComponent
implements OnInit, OnDestroy, OnChanges
{
#tabService = inject(TabService);
#checkoutMetadataService = inject(CheckoutMetadataService);
#shoppingCartResource = inject(ShoppingCartResource);
tab = computed(() => this.#tabService.entityMap()[this.process().id]);
shoppingCartId = computed(() => {
return this.#checkoutMetadataService.getShoppingCartId(this.process().id);
});
shoppingCartIdEffect = effect(() => {
const shoppingCartId = this.shoppingCartId();
untracked(() =>
this.#shoppingCartResource.setShoppingCartId(shoppingCartId),
);
});
itemCount = computed(() => {
const shoppingCart = this.#shoppingCartResource.resource.value();
return shoppingCart?.items?.length ?? 0;
});
private _process$ = new BehaviorSubject<ApplicationProcess>(undefined);
process$ = this._process$.asObservable();
@@ -52,15 +76,7 @@ export class ShellProcessBarItemComponent
closed = new EventEmitter();
showCart = computed(() => {
const tab = this.tab();
const pdata = tab.metadata?.process_data as { count?: number };
if (!pdata) {
return false;
}
return 'count' in pdata;
return true;
});
currentLocationUrlTree = computed(() => {
@@ -83,7 +99,7 @@ export class ShellProcessBarItemComponent
latestBreadcrumb$: Observable<Breadcrumb> = NEVER;
routerLink$: Observable<string[] | any[]> = NEVER;
routerLink$: Observable<string[] | unknown[]> = NEVER;
queryParams$: Observable<object> = NEVER;
@@ -112,7 +128,6 @@ export class ShellProcessBarItemComponent
this.initQueryParams$();
this.initIsActive$();
this.initShowCloseButton$();
this.initCartItemCount$();
}
scrollIntoView() {
@@ -171,15 +186,6 @@ export class ShellProcessBarItemComponent
}
}
initCartItemCount$() {
this.cartItemCount$ = this.process$.pipe(
switchMap((process) =>
this._checkout?.getShoppingCart({ processId: process?.id }),
),
map((cart) => cart?.items?.length ?? 0),
);
}
ngOnDestroy() {
this._process$.complete();
}

View File

@@ -1,72 +1,74 @@
<div
class="flex flex-row justify-start items-center h-full max-w-[1920px] desktop-xx-large:max-w-[2448px] relative"
(mouseenter)="hovered = true"
(mouseleave)="hovered = false"
>
@if (showScrollArrows) {
<button
class="scroll-button prev-button"
[class.invisible]="!this.hovered || showArrowLeft"
(click)="scrollLeft()"
>
<ui-icon icon="arrow_head" size="22px" rotate="180deg"></ui-icon>
</button>
}
<div
#processContainer
class="grid grid-flow-col max-w-[calc(100vw-9.5rem)] overflow-x-scroll"
(wheel)="onMouseWheel($event)"
(scroll)="checkScrollArrowVisibility()"
>
@for (process of processes$ | async; track trackByFn($index, process)) {
<shell-process-bar-item
[process]="process"
(closed)="checkScrollArrowVisibility()"
></shell-process-bar-item>
}
</div>
@if (showScrollArrows) {
<button
class="scroll-button next-button"
[class.invisible]="!this.hovered || showArrowRight"
(click)="scrollRight()"
>
<ui-icon icon="arrow_head" size="22px"></ui-icon>
</button>
}
<button
type="button"
class="grid px-3 shell-process-bar__create-process-btn-desktop start-process-btn grid-flow-col items-center justify-center gap-[0.625rem] grow-0 shrink-0"
(click)="createProcess('product')"
type="button"
>
<ng-container *ngTemplateOutlet="createProcessButtonContent"></ng-container>
</button>
<div class="grow"></div>
<button
type="button"
[disabled]="!(processes$ | async)?.length"
class="grow-0 shrink-0 px-3 mr-[.125rem] shell-process-bar__close-processes"
(click)="closeAllProcesses()"
>
<div
class="rounded border border-solid flex flex-row pl-3 pr-[0.625rem] py-[0.375rem]"
[class.text-brand]="(processes$ | async)?.length"
[class.border-brand]="(processes$ | async)?.length"
[class.text-[#AEB7C1]]="!(processes$ | async)?.length"
[class.border-[#AEB7C1]]="!(processes$ | async)?.length"
>
<span class="mr-1">{{ (processes$ | async)?.length }}</span>
<shared-icon icon="close"></shared-icon>
</div>
</button>
</div>
<ng-template #createProcessButtonContent>
<div class="bg-brand text-white w-[2.375rem] h-[2.375rem] rounded-full grid items-center justify-center mx-auto mb-1">
<shared-icon icon="add"></shared-icon>
</div>
@if (showStartProcessText$ | async) {
<span class="text-brand create-process-btn-text">Vorgang starten</span>
}
</ng-template>
<div
class="flex flex-row justify-start items-center h-full max-w-[1920px] desktop-xx-large:max-w-[2448px] relative"
(mouseenter)="hovered = true"
(mouseleave)="hovered = false"
>
@if (showScrollArrows) {
<button
class="scroll-button prev-button"
[class.invisible]="!this.hovered || showArrowLeft"
(click)="scrollLeft()"
>
<ui-icon icon="arrow_head" size="22px" rotate="180deg"></ui-icon>
</button>
}
<div
#processContainer
class="grid grid-flow-col max-w-[calc(100vw-9.5rem)] overflow-x-scroll"
(wheel)="onMouseWheel($event)"
(scroll)="checkScrollArrowVisibility()"
>
@for (process of processes$ | async; track process.id) {
<shell-process-bar-item
[process]="process"
(closed)="checkScrollArrowVisibility()"
></shell-process-bar-item>
}
</div>
@if (showScrollArrows) {
<button
class="scroll-button next-button"
[class.invisible]="!this.hovered || showArrowRight"
(click)="scrollRight()"
>
<ui-icon icon="arrow_head" size="22px"></ui-icon>
</button>
}
<button
type="button"
class="grid px-3 shell-process-bar__create-process-btn-desktop start-process-btn grid-flow-col items-center justify-center gap-[0.625rem] grow-0 shrink-0"
(click)="createProcess('product')"
type="button"
>
<ng-container *ngTemplateOutlet="createProcessButtonContent"></ng-container>
</button>
<div class="grow"></div>
<button
type="button"
[disabled]="!(processes$ | async)?.length"
class="grow-0 shrink-0 px-3 mr-[.125rem] shell-process-bar__close-processes"
(click)="closeAllProcesses()"
>
<div
class="rounded border border-solid flex flex-row pl-3 pr-[0.625rem] py-[0.375rem]"
[class.text-brand]="(processes$ | async)?.length"
[class.border-brand]="(processes$ | async)?.length"
[class.text-[#AEB7C1]]="!(processes$ | async)?.length"
[class.border-[#AEB7C1]]="!(processes$ | async)?.length"
>
<span class="mr-1">{{ (processes$ | async)?.length }}</span>
<shared-icon icon="close"></shared-icon>
</div>
</button>
</div>
<ng-template #createProcessButtonContent>
<div
class="bg-brand text-white w-[2.375rem] h-[2.375rem] rounded-full grid items-center justify-center mx-auto mb-1"
>
<shared-icon icon="add"></shared-icon>
</div>
@if (showStartProcessText$ | async) {
<span class="text-brand create-process-btn-text">Vorgang starten</span>
}
</ng-template>

View File

@@ -1,4 +1,3 @@
import { coerceArray } from '@angular/cdk/coercion';
import {
Component,
ChangeDetectionStrategy,
@@ -65,10 +64,15 @@ export class ShellProcessBarComponent implements OnInit {
}
initProcesses$() {
// TODO: Use implementation from develop
this.processes$ = this.section$.pipe(
switchMap((section) => this._app.getProcesses$(section)),
// TODO: Nach Prämie release kann der Filter rausgenommen werden
map((processes) =>
processes.filter((process) => process.type === 'cart'),
processes.filter(
(process) =>
process.type === 'cart' || process.type === 'cart-checkout',
),
),
);
}

View File

@@ -77,7 +77,7 @@
<span class="side-menu-group-item-label">Erfassen</span>
</a>
}
<!-- @if (customerRewardRoute()) {
@if (customerRewardRoute()) {
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
@@ -86,10 +86,18 @@
sharedRegexRouterLinkActive="active"
sharedRegexRouterLinkActiveTest="^\/\d*\/reward"
>
<span class="side-menu-group-item-icon"> </span>
<span class="side-menu-group-item-icon">
<shell-reward-shopping-cart-indicator />
@if (hasShoppingCartItems()) {
<span
class="w-2 h-2 bg-isa-accent-red rounded-full"
data-what="open-reward-tasks-indicator"
></span>
}
</span>
<span class="side-menu-group-item-label">Prämienshop</span>
</a>
} -->
}
</div>
</div>

View File

@@ -35,6 +35,7 @@ import { TabService } from '@isa/core/tabs';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaNavigationRemission2, isaNavigationReturn } from '@isa/icons';
import z from 'zod';
import { SelectedRewardShoppingCartResource } from '@isa/checkout/data-access';
@Component({
selector: 'shell-side-menu',
@@ -71,6 +72,7 @@ export class ShellSideMenuComponent {
#cdr = inject(ChangeDetectorRef);
#document = inject(DOCUMENT);
tabService = inject(TabService);
#shoppingCartResource = inject(SelectedRewardShoppingCartResource);
staticTabIds = Object.values(
this.#config.get('process.ids', z.record(z.coerce.number())),
@@ -151,6 +153,10 @@ export class ShellSideMenuComponent {
return this.#router.createUrlTree(['/', tabId || this.nextId(), routeName]);
});
hasShoppingCartItems = computed(() => {
return this.#shoppingCartResource.resource.value()?.items?.length > 0;
});
pickUpShelfOutRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {

View File

@@ -1,9 +1,9 @@
import { NgModule } from '@angular/core';
import { ShellSideMenuComponent } from './side-menu.component';
@NgModule({
imports: [ShellSideMenuComponent],
exports: [ShellSideMenuComponent],
})
export class ShellSideMenuModule {}
import { NgModule } from '@angular/core';
import { ShellSideMenuComponent } from './side-menu.component';
@NgModule({
imports: [ShellSideMenuComponent],
exports: [ShellSideMenuComponent],
})
export class ShellSideMenuModule {}

View File

@@ -10,6 +10,7 @@
@layer components {
@import "../../../libs/ui/buttons/src/buttons.scss";
@import "../../../libs/ui/bullet-list/src/bullet-list.scss";
@import "../../../libs/ui/carousel/src/lib/_carousel.scss";
@import "../../../libs/ui/datepicker/src/datepicker.scss";
@import "../../../libs/ui/dialog/src/dialog.scss";
@import "../../../libs/ui/input-controls/src/input-controls.scss";
@@ -19,6 +20,7 @@
@import "../../../libs/ui/skeleton-loader/src/skeleton-loader.scss";
@import "../../../libs/ui/tooltip/src/tooltip.scss";
@import "../../../libs/ui/label/src/label.scss";
@import "../../../libs/ui/switch/src/switch.scss";
.input-control {
@apply rounded border border-solid border-[#AEB7C1] px-4 py-[1.125rem] outline-none;

View File

@@ -0,0 +1,114 @@
import {
type Meta,
type StoryObj,
applicationConfig,
moduleMetadata,
} from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { DestinationInfoComponent } from '@isa/checkout/shared/product-info';
import { ShippingTarget } from '@isa/checkout/data-access';
const meta: Meta<DestinationInfoComponent> = {
title: 'checkout/shared/product-info/DestinationInfoComponent',
component: DestinationInfoComponent,
decorators: [
applicationConfig({
providers: [provideHttpClient()],
}),
moduleMetadata({
imports: [],
providers: [],
}),
],
};
export default meta;
type Story = StoryObj<DestinationInfoComponent>;
export const Delivery: Story = {
args: {
underline: true,
shoppingCartItem: {
availability: {
estimatedDelivery: {
start: '2024-06-10T00:00:00+02:00',
stop: '2024-06-12T00:00:00+02:00',
},
},
destination: {
data: {
target: ShippingTarget.Delivery,
},
},
features: {
orderType: 'Versand',
},
},
},
};
export const Pickup: Story = {
args: {
shoppingCartItem: {
availability: {
estimatedDelivery: {
start: '2024-06-10T00:00:00+02:00',
stop: '2024-06-12T00:00:00+02:00',
},
},
destination: {
data: {
target: ShippingTarget.Branch,
targetBranch: {
data: {
name: 'Musterfiliale',
address: {
street: 'Musterstraße',
streetNumber: '1',
zipCode: '12345',
city: 'Musterstadt',
},
},
},
},
},
features: {
orderType: 'Abholung',
},
},
},
};
export const InStore: Story = {
args: {
shoppingCartItem: {
availability: {
estimatedDelivery: {
start: '2024-06-10T00:00:00+02:00',
stop: '2024-06-12T00:00:00+02:00',
},
},
destination: {
data: {
target: ShippingTarget.Branch,
targetBranch: {
data: {
name: 'Musterfiliale',
address: {
street: 'Musterstraße',
streetNumber: '1',
zipCode: '12345',
city: 'Musterstadt',
},
},
},
},
},
features: {
orderType: 'Rücklage',
},
},
},
};

View File

@@ -0,0 +1,60 @@
import {
type Meta,
type StoryObj,
applicationConfig,
argsToTemplate,
moduleMetadata,
} from '@storybook/angular';
import { ProductInfoRedemptionComponent } from '@isa/checkout/shared/product-info';
import { provideProductImageUrl } from '@isa/shared/product-image';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
import { provideRouter } from '@angular/router';
const meta: Meta<ProductInfoRedemptionComponent> = {
title: 'checkout/shared/product-info/ProductInfoRedemption',
component: ProductInfoRedemptionComponent,
decorators: [
applicationConfig({
providers: [
provideRouter([
{ path: ':ean', component: ProductInfoRedemptionComponent },
]),
],
}),
moduleMetadata({
providers: [
provideProductImageUrl('https://produktbilder-test.paragon-data.net'),
provideProductRouterLinkBuilder((ean: string) => ean),
],
}),
],
};
export default meta;
type Story = StoryObj<ProductInfoRedemptionComponent>;
export const Primary: Story = {
args: {
item: {
product: {
ean: '9783498007706',
name: 'Die Assistentin',
contributors: 'Wahl, Caroline',
format: 'TB',
formatDetail: 'Taschenbuch (Kartoniert)',
manufacturer: 'Test Manufacturer',
publicationDate: '2023-01-01',
},
redemptionPoints: 100,
},
orientation: 'vertical',
},
argTypes: {
item: { control: 'object' },
orientation: {
control: { type: 'radio' },
options: ['horizontal', 'vertical'],
},
},
};

View File

@@ -0,0 +1,130 @@
import {
type Meta,
type StoryObj,
applicationConfig,
moduleMetadata,
} from '@storybook/angular';
import { ProductInfoComponent } from '@isa/checkout/shared/product-info';
import { provideProductImageUrl } from '@isa/shared/product-image';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
import { provideRouter } from '@angular/router';
const mockProduct = {
ean: '9783498007706',
name: 'Die Assistentin',
contributors: 'Wahl, Caroline',
};
const meta: Meta<ProductInfoComponent> = {
title: 'checkout/shared/product-info/ProductInfo',
component: ProductInfoComponent,
decorators: [
applicationConfig({
providers: [
provideRouter([{ path: ':ean', component: ProductInfoComponent }]),
],
}),
moduleMetadata({
providers: [
provideProductImageUrl('https://produktbilder-test.paragon-data.net'),
provideProductRouterLinkBuilder((ean: string) => ean),
],
}),
],
};
export default meta;
type Story = StoryObj<ProductInfoComponent>;
export const BasicWithoutContent: Story = {
args: {
item: mockProduct,
nameSize: 'medium',
},
argTypes: {
item: { control: 'object' },
nameSize: {
control: { type: 'radio' },
options: ['small', 'medium', 'large'],
},
},
};
export const SmallNameSize: Story = {
args: {
item: mockProduct,
nameSize: 'small',
},
};
export const MediumNameSize: Story = {
args: {
item: mockProduct,
nameSize: 'medium',
},
};
export const LargeNameSize: Story = {
args: {
item: mockProduct,
nameSize: 'large',
},
};
export const WithLesepunkte: Story = {
args: {
item: mockProduct,
nameSize: 'medium',
},
render: (args) => ({
props: args,
template: `
<checkout-product-info [item]="item" [nameSize]="nameSize">
<div class="isa-text-body-2-regular">
<span class="isa-text-body-2-bold">150</span> Lesepunkte
</div>
</checkout-product-info>
`,
}),
};
export const WithManufacturer: Story = {
args: {
item: mockProduct,
nameSize: 'medium',
},
render: (args) => ({
props: args,
template: `
<checkout-product-info [item]="item" [nameSize]="nameSize">
<div class="isa-text-body-2-regular text-neutral-600">
Rowohlt Taschenbuch
</div>
</checkout-product-info>
`,
}),
};
export const WithMultipleRows: Story = {
args: {
item: mockProduct,
nameSize: 'medium',
},
render: (args) => ({
props: args,
template: `
<checkout-product-info [item]="item" [nameSize]="nameSize">
<div class="isa-text-body-2-regular">
<span class="isa-text-body-2-bold">150</span> Lesepunkte
</div>
<div class="isa-text-body-2-regular text-neutral-600">
Rowohlt Taschenbuch
</div>
<div class="isa-text-body-2-regular text-neutral-600">
Erschienen: 01. Januar 2023
</div>
</checkout-product-info>
`,
}),
};

View File

@@ -0,0 +1,59 @@
import {
type Meta,
type StoryObj,
applicationConfig,
argsToTemplate,
moduleMetadata,
} from '@storybook/angular';
import { StockInfoComponent } from '@isa/checkout/shared/product-info';
import { RemissionStockService } from '@isa/remission/data-access';
import { StockInfoDTO } from '@generated/swagger/inventory-api';
const meta: Meta<StockInfoComponent> = {
title: 'checkout/shared/product-info/StockInfoComponent',
component: StockInfoComponent,
decorators: [
applicationConfig({
providers: [],
}),
moduleMetadata({
imports: [],
providers: [
{
provide: RemissionStockService,
useValue: {
fetchStock: async (
params: { itemIds: number[]; assignedStockId?: number },
abortSignal?: AbortSignal,
) => {
const result: StockInfoDTO = {
itemId: params.itemIds[0],
stockId: params.assignedStockId,
inStock: 14,
};
await new Promise((resolve) => setTimeout(resolve, 1000));
return [result];
},
},
},
],
}),
],
};
export default meta;
type Story = StoryObj<StockInfoComponent>;
export const Primary: Story = {
args: {
item: {
id: 123456,
catalogAvailability: {
ssc: '999',
sscText: 'Lieferbar in 1-3 Werktagen',
},
},
},
};

View File

@@ -0,0 +1,183 @@
import {
type Meta,
type StoryObj,
applicationConfig,
argsToTemplate,
} from '@storybook/angular';
import { AddressComponent, Address } from '@isa/shared/address';
import { CountryResource } from '@isa/crm/data-access';
const meta: Meta<AddressComponent> = {
title: 'shared/address/AddressComponent',
component: AddressComponent,
decorators: [
applicationConfig({
providers: [
{
provide: CountryResource,
useValue: {
resource: {
value: () => [
{ isO3166_A_3: 'DEU', name: 'Germany' },
{ isO3166_A_3: 'FRA', name: 'France' },
{ isO3166_A_3: 'AUT', name: 'Austria' },
{ isO3166_A_3: 'USA', name: 'United States' },
{ isO3166_A_3: 'CHE', name: 'Switzerland' },
{ isO3166_A_3: 'ITA', name: 'Italy' },
{ isO3166_A_3: 'ESP', name: 'Spain' },
],
},
},
},
],
}),
],
argTypes: {
address: {
control: 'object',
description: 'The address object to display',
},
},
render: (args) => ({
props: args,
template: `<shared-address ${argsToTemplate(args)}></shared-address>`,
}),
};
export default meta;
type Story = StoryObj<AddressComponent>;
export const Default: Story = {
args: {
address: {
careOf: 'John Doe',
street: 'Hauptstraße',
streetNumber: '42',
apartment: 'Apt 3B',
info: 'Building A, 3rd Floor',
zipCode: '10115',
city: 'Berlin',
country: 'DEU',
},
},
};
export const GermanAddress: Story = {
args: {
address: {
street: 'Maximilianstraße',
streetNumber: '15',
zipCode: '80539',
city: 'München',
country: 'DEU',
},
},
};
export const FrenchAddress: Story = {
args: {
address: {
street: 'Rue de la Paix',
streetNumber: '25',
zipCode: '75002',
city: 'Paris',
country: 'FRA',
},
},
};
export const AustrianAddress: Story = {
args: {
address: {
street: 'Stephansplatz',
streetNumber: '1',
zipCode: '1010',
city: 'Wien',
country: 'AUT',
},
},
};
export const SwissAddress: Story = {
args: {
address: {
street: 'Bahnhofstrasse',
streetNumber: '50',
zipCode: '8001',
city: 'Zürich',
country: 'CHE',
},
},
};
export const WithCareOf: Story = {
args: {
address: {
careOf: 'Maria Schmidt',
street: 'Berliner Straße',
streetNumber: '100',
zipCode: '60311',
city: 'Frankfurt am Main',
country: 'DEU',
},
},
};
export const WithApartment: Story = {
args: {
address: {
street: 'Lindenallee',
streetNumber: '23',
apartment: 'Wohnung 5A',
zipCode: '50668',
city: 'Köln',
country: 'DEU',
},
},
};
export const WithAdditionalInfo: Story = {
args: {
address: {
street: 'Industriestraße',
streetNumber: '7',
info: 'Hintereingang, 2. Stock rechts',
zipCode: '70565',
city: 'Stuttgart',
country: 'DEU',
},
},
};
export const MinimalAddress: Story = {
args: {
address: {
street: 'Dorfstraße',
city: 'Neustadt',
},
},
};
export const CompleteInternational: Story = {
args: {
address: {
careOf: 'Jane Smith',
street: 'Fifth Avenue',
streetNumber: '350',
apartment: 'Suite 2000',
info: 'Empire State Building',
zipCode: '10118',
city: 'New York',
state: 'NY',
country: 'USA',
},
},
};
export const EmptyAddress: Story = {
args: {
address: {},
},
};

View File

@@ -0,0 +1,191 @@
import {
type Meta,
type StoryObj,
applicationConfig,
argsToTemplate,
} from '@storybook/angular';
import { InlineAddressComponent, Address } from '@isa/shared/address';
import { CountryResource } from '@isa/crm/data-access';
const meta: Meta<InlineAddressComponent> = {
title: 'shared/address/InlineAddressComponent',
component: InlineAddressComponent,
decorators: [
applicationConfig({
providers: [
{
provide: CountryResource,
useValue: {
resource: {
value: () => [
{ isO3166_A_3: 'DEU', name: 'Germany' },
{ isO3166_A_3: 'FRA', name: 'France' },
{ isO3166_A_3: 'AUT', name: 'Austria' },
{ isO3166_A_3: 'USA', name: 'United States' },
{ isO3166_A_3: 'CHE', name: 'Switzerland' },
{ isO3166_A_3: 'ITA', name: 'Italy' },
{ isO3166_A_3: 'ESP', name: 'Spain' },
],
},
},
},
],
}),
],
argTypes: {
address: {
control: 'object',
description: 'The address object to display in inline format',
},
},
render: (args) => ({
props: args,
template: `<shared-inline-address ${argsToTemplate(args)}></shared-inline-address>`,
}),
};
export default meta;
type Story = StoryObj<InlineAddressComponent>;
export const Default: Story = {
args: {
address: {
street: 'Hauptstraße',
streetNumber: '42',
zipCode: '10115',
city: 'Berlin',
country: 'DEU',
},
},
};
export const GermanAddress: Story = {
args: {
address: {
street: 'Maximilianstraße',
streetNumber: '15',
zipCode: '80539',
city: 'München',
country: 'DEU',
},
},
};
export const FrenchAddress: Story = {
args: {
address: {
street: 'Rue de la Paix',
streetNumber: '25',
zipCode: '75002',
city: 'Paris',
country: 'FRA',
},
},
};
export const AustrianAddress: Story = {
args: {
address: {
street: 'Stephansplatz',
streetNumber: '1',
zipCode: '1010',
city: 'Wien',
country: 'AUT',
},
},
};
export const SwissAddress: Story = {
args: {
address: {
street: 'Bahnhofstrasse',
streetNumber: '50',
zipCode: '8001',
city: 'Zürich',
country: 'CHE',
},
},
};
export const USAddress: Story = {
args: {
address: {
street: 'Fifth Avenue',
streetNumber: '350',
zipCode: '10118',
city: 'New York',
country: 'USA',
},
},
};
export const ShortAddress: Story = {
args: {
address: {
street: 'Dorfstraße',
streetNumber: '5',
city: 'Neustadt',
},
},
};
export const StreetOnly: Story = {
args: {
address: {
street: 'Hauptstraße',
streetNumber: '10',
},
},
};
export const CityOnly: Story = {
args: {
address: {
zipCode: '12345',
city: 'Beispielstadt',
},
},
};
export const NoCountry: Story = {
args: {
address: {
street: 'Teststraße',
streetNumber: '99',
zipCode: '54321',
city: 'Musterstadt',
},
},
};
export const WithCountryLookup: Story = {
args: {
address: {
street: 'Via Roma',
streetNumber: '10',
zipCode: '00100',
city: 'Roma',
country: 'ITA',
},
},
};
export const SpanishAddress: Story = {
args: {
address: {
street: 'Calle Mayor',
streetNumber: '1',
zipCode: '28013',
city: 'Madrid',
country: 'ESP',
},
},
};
export const EmptyAddress: Story = {
args: {
address: {},
},
};

View File

@@ -1,6 +1,6 @@
import { Meta, argsToTemplate } from '@storybook/angular';
import { ProductFormatIconGroup } from '@isa/icons';
import { ProductFormatIconComponent } from '@isa/shared/product-foramt';
import { ProductFormatIconComponent } from '@isa/shared/product-format';
type ProductFormatInputs = {
format: string;

View File

@@ -1,48 +1,57 @@
import { argsToTemplate, Meta } from '@storybook/angular';
import { ProductFormatIconGroup } from '@isa/icons';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
type ProductFormatInputs = {
format: string;
formatDetail: string;
};
const options = Object.keys(ProductFormatIconGroup).map((key) =>
key.toUpperCase(),
);
const meta: Meta<ProductFormatInputs> = {
title: 'shared/product-format/ProductFormat',
component: ProductFormatComponent,
argTypes: {
format: {
control: {
type: 'select',
},
options,
description: 'The product format to display the icon for.',
defaultValue: options[0],
},
formatDetail: {
control: {
type: 'text',
},
description: 'The detail text for the product format.',
defaultValue: 'Default Format Detail',
},
},
args: {
format: options[0], // Default value for the product format
formatDetail: 'Default Format Detail', // Default value for the format detail
},
render: (args) => ({
props: args,
template: `<shared-product-format ${argsToTemplate(args)}></shared-product-format>`,
}),
};
export default meta;
type Story = typeof meta;
export const Default: Story = {};
import { argsToTemplate, Meta } from '@storybook/angular';
import { ProductFormatIconGroup } from '@isa/icons';
import { ProductFormatComponent } from '@isa/shared/product-format';
type ProductFormatInputs = {
format: string;
formatDetail: string;
formatDetailsBold: boolean;
};
const options = Object.keys(ProductFormatIconGroup).map((key) =>
key.toUpperCase(),
);
const meta: Meta<ProductFormatInputs> = {
title: 'shared/product-format/ProductFormat',
component: ProductFormatComponent,
argTypes: {
format: {
control: {
type: 'select',
},
options,
description: 'The product format to display the icon for.',
defaultValue: options[0],
},
formatDetail: {
control: {
type: 'text',
},
description: 'The detail text for the product format.',
defaultValue: 'Default Format Detail',
},
formatDetailsBold: {
control: {
type: 'boolean',
},
description: 'Whether the format detail text should be bold.',
defaultValue: false,
},
},
args: {
format: options[0], // Default value for the product format
formatDetail: 'Default Format Detail', // Default value for the format detail
formatDetailsBold: false, // Default value for the format details bold
},
render: (args) => ({
props: args,
template: `<shared-product-format ${argsToTemplate(args)}></shared-product-format>`,
}),
};
export default meta;
type Story = typeof meta;
export const Default: Story = {};

View File

@@ -0,0 +1,145 @@
import {
argsToTemplate,
moduleMetadata,
type Meta,
type StoryObj,
} from '@storybook/angular';
import { QuantityControlComponent } from '@isa/shared/quantity-control';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
interface QuantityControlStoryProps {
value: number;
disabled: boolean;
min?: number;
max?: number;
presetLimit?: number;
}
const meta: Meta<QuantityControlStoryProps> = {
component: QuantityControlComponent,
title: 'shared/quantity-control/QuantityControl',
argTypes: {
value: {
control: { type: 'number', min: 0, max: 99 },
description: 'The quantity value',
},
disabled: {
control: 'boolean',
description: 'Disables the control when true',
},
min: {
control: { type: 'number', min: 0, max: 10 },
description: 'Minimum selectable value',
},
max: {
control: { type: 'number', min: 1, max: 99 },
description: 'Maximum selectable value (e.g., stock available)',
},
presetLimit: {
control: { type: 'number', min: 1, max: 99 },
description: 'Number of preset options before requiring Edit',
},
},
args: {
value: 1,
disabled: false,
min: 1,
max: undefined,
presetLimit: 10,
},
render: (args) => ({
props: args,
template: `<shared-quantity-control ${argsToTemplate(args)} />`,
}),
};
export default meta;
type Story = StoryObj<QuantityControlStoryProps>;
export const Default: Story = {
args: {
value: 1,
disabled: false,
},
};
export const WithCustomValue: Story = {
args: {
value: 5,
disabled: false,
},
};
export const HighStock: Story = {
args: {
value: 15,
disabled: false,
min: 1,
max: 50,
presetLimit: 20, // Shows 1-20, Edit for 21-50
},
};
export const LimitedStock: Story = {
args: {
value: 3,
disabled: false,
min: 1,
max: 5,
presetLimit: 10, // Shows 1-5 (capped at max), no Edit
},
};
export const ExactStock: Story = {
args: {
value: 1,
disabled: false,
min: 1,
max: 10,
presetLimit: 10, // Shows 1-10, no Edit (max=10 == presetLimit)
},
};
export const StartFromZero: Story = {
args: {
value: 0,
disabled: false,
min: 0,
max: undefined,
presetLimit: 10, // Shows 0-9, Edit for unlimited
},
};
export const Disabled: Story = {
args: {
value: 3,
disabled: true,
},
};
export const InFormContext: Story = {
args: {
value: 2,
disabled: false,
},
decorators: [
moduleMetadata({
imports: [ReactiveFormsModule],
}),
],
render: (args) => ({
props: {
...args,
quantityControl: new FormControl(args.value),
},
template: `
<div style="display: flex; flex-direction: column; gap: 1rem;">
<shared-quantity-control [formControl]="quantityControl" />
<div style="margin-top: 1rem; padding: 1rem; background: #f5f5f5; border-radius: 4px;">
<strong>Form Value:</strong> {{ quantityControl.value }}
</div>
</div>
`,
}),
};

View File

@@ -0,0 +1,141 @@
import { type Meta, type StoryObj, moduleMetadata } from '@storybook/angular';
import { CarouselComponent } from '@isa/ui/carousel';
import { QuoteCardComponent } from './quote-card.component';
// Collection of developer/inspirational quotes
const quotes = [
{ id: 1, text: 'Code is like humor. When you have to explain it, it\'s bad.', author: 'Cory House' },
{ id: 2, text: 'First, solve the problem. Then, write the code.', author: 'John Johnson' },
{ id: 3, text: 'Simplicity is the soul of efficiency.', author: 'Austin Freeman' },
{ id: 4, text: 'Make it work, make it right, make it fast.', author: 'Kent Beck' },
{ id: 5, text: 'Clean code always looks like it was written by someone who cares.', author: 'Robert C. Martin' },
{ id: 6, text: 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.', author: 'Martin Fowler' },
{ id: 7, text: 'Experience is the name everyone gives to their mistakes.', author: 'Oscar Wilde' },
{ id: 8, text: 'In order to be irreplaceable, one must always be different.', author: 'Coco Chanel' },
{ id: 9, text: 'The best way to predict the future is to invent it.', author: 'Alan Kay' },
{ id: 10, text: 'Programs must be written for people to read, and only incidentally for machines to execute.', author: 'Harold Abelson' },
{ id: 11, text: 'Testing leads to failure, and failure leads to understanding.', author: 'Burt Rutan' },
{ id: 12, text: 'It\'s not a bug it\'s an undocumented feature.', author: 'Anonymous' },
{ id: 13, text: 'Software is a great combination between artistry and engineering.', author: 'Bill Gates' },
{ id: 14, text: 'Talk is cheap. Show me the code.', author: 'Linus Torvalds' },
{ id: 15, text: 'Sometimes it pays to stay in bed on Monday, rather than spending the rest of the week debugging Monday\'s code.', author: 'Dan Salomon' },
];
// Helper function to generate a specified number of quotes
function generateQuotes(count: number) {
const result = [];
for (let i = 0; i < count; i++) {
result.push(quotes[i % quotes.length]);
}
return result;
}
interface CarouselStoryProps {
gap: string;
arrowAutoHide: boolean;
itemCount: number;
}
const meta: Meta<CarouselStoryProps> = {
component: CarouselComponent,
title: 'ui/carousel/Carousel',
decorators: [
moduleMetadata({
imports: [QuoteCardComponent],
}),
],
argTypes: {
gap: {
control: 'text',
description: 'CSS gap value for spacing between carousel items',
},
arrowAutoHide: {
control: 'boolean',
description: 'Whether to auto-hide arrows until carousel is hovered or focused',
},
itemCount: {
control: { type: 'number', min: 3, max: 20 },
description: 'Number of quote cards to render',
},
},
args: {
gap: '1rem',
arrowAutoHide: true,
itemCount: 6,
},
render: (args) => ({
props: {
...args,
quotes: generateQuotes(args.itemCount),
},
template: `
<div style="padding: 2.5rem; background: #f5f5f5;">
<ui-carousel
[gap]="gap"
[arrowAutoHide]="arrowAutoHide"
>
@for (quote of quotes; track quote.id) {
<quote-card [quote]="quote.text" [author]="quote.author"></quote-card>
}
</ui-carousel>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<CarouselStoryProps>;
/**
* Default carousel with 6 quote cards.
* Demonstrates basic horizontal scrolling with auto-hide arrows.
*/
export const Default: Story = {
args: {
itemCount: 6,
},
};
/**
* Carousel with many items (15 cards).
* Shows behavior with extensive scrolling and tests navigation performance.
*/
export const ManyItems: Story = {
args: {
itemCount: 15,
},
};
/**
* Carousel with few items (3 cards).
* Demonstrates behavior when content might not overflow.
* Arrows should disable if not scrollable.
*/
export const FewItems: Story = {
args: {
itemCount: 3,
},
};
/**
* Carousel with persistent arrows (no auto-hide).
* Arrows are always visible when content is scrollable.
*/
export const AlwaysShowArrows: Story = {
args: {
itemCount: 8,
arrowAutoHide: false,
},
};
/**
* Carousel with custom gap spacing (2rem).
* Demonstrates configurable spacing between items.
*/
export const CustomGap: Story = {
args: {
itemCount: 6,
gap: '2rem',
},
};

View File

@@ -0,0 +1,63 @@
import { Component, input, ChangeDetectionStrategy } from '@angular/core';
/**
* Quote card component for Storybook carousel examples.
* Displays a quote with author in a styled card matching the Figma design.
*/
@Component({
selector: 'quote-card',
template: `
<div class="quote-card">
<div class="quote-card__content">
<p class="quote-card__quote isa-text-body-1-regular text-isa-neutral-900">
"{{ quote() }}"
</p>
@if (author()) {
<p class="quote-card__author isa-text-body-2-bold text-isa-neutral-700">
— {{ author() }}
</p>
}
</div>
</div>
`,
styles: [`
.quote-card {
background: white;
border-radius: 16px;
padding: 20px 22px;
min-width: 334px;
max-width: 334px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
transition: box-shadow 0.2s ease;
}
.quote-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.quote-card__content {
display: flex;
flex-direction: column;
gap: 12px;
}
.quote-card__quote {
font-style: italic;
line-height: 1.5;
margin: 0;
}
.quote-card__author {
margin: 0;
font-style: normal;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
})
export class QuoteCardComponent {
quote = input.required<string>();
author = input<string>('');
}

View File

@@ -0,0 +1,104 @@
import { argsToTemplate, type Meta, type StoryObj } from '@storybook/angular';
import { IconSwitchComponent, IconSwitchColor } from '@isa/ui/switch';
import { provideIcons } from '@ng-icons/core';
import { isaFiliale, IsaIcons, isaNavigationDashboard } from '@isa/icons';
type IconSwitchComponentInputs = {
icon: string;
checked: boolean;
color: IconSwitchColor;
disabled: boolean;
};
const meta: Meta<IconSwitchComponentInputs> = {
component: IconSwitchComponent,
title: 'ui/switch/IconSwitch',
decorators: [
(story) => ({
...story(),
applicationConfig: {
providers: [provideIcons(IsaIcons)],
},
}),
],
argTypes: {
icon: {
control: { type: 'select' },
options: Object.keys(IsaIcons),
description: 'The name of the icon to display in the switch',
},
checked: {
control: 'boolean',
description: 'Whether the switch is checked (on) or not (off)',
},
color: {
control: { type: 'select' },
options: Object.values(IconSwitchColor),
description: 'Determines the switch color theme',
},
disabled: {
control: 'boolean',
description: 'Disables the switch when true',
},
},
args: {
icon: 'isaFiliale',
checked: false,
color: 'primary',
disabled: false,
},
render: (args) => ({
props: args,
template: `<ui-icon-switch ${argsToTemplate(args)}></ui-icon-switch>`,
}),
};
export default meta;
type Story = StoryObj<IconSwitchComponent>;
export const Default: Story = {
args: {},
};
export const Enabled: Story = {
args: {
checked: true,
},
parameters: {
docs: {
description: {
story:
'The switch in its enabled/checked state with the primary color theme.',
},
},
},
};
export const Disabled: Story = {
args: {
checked: false,
disabled: true,
},
parameters: {
docs: {
description: {
story:
'The switch in a disabled state. User interactions are prevented.',
},
},
},
};
export const EnabledAndDisabled: Story = {
args: {
checked: true,
disabled: true,
},
parameters: {
docs: {
description: {
story: 'The switch in both enabled and disabled states simultaneously.',
},
},
},
};

View File

@@ -11,11 +11,17 @@ const meta: Meta<TooltipDirective> = {
control: 'multi-select',
options: ['click', 'hover', 'focus'],
},
variant: {
control: { type: 'select' },
options: ['default', 'warning'],
description: 'Determines the visual variant of the tooltip',
},
},
args: {
title: 'Tooltip Title',
content: 'This is the tooltip content.',
triggerOn: ['click', 'hover', 'focus'],
variant: 'default',
},
render: (args) => ({
props: args,
@@ -37,3 +43,12 @@ export const Default: Story = {
triggerOn: ['hover', 'click'],
},
};
export const Warning: Story = {
args: {
title: 'Warning Tooltip',
content: 'This is a warning message.',
triggerOn: ['hover', 'click'],
variant: 'warning',
},
};